JavaScript 的靜態做用域鏈與「動態」閉包鏈

ssh 封的東半球最本質的閉包文章 [吃瓜]。 javascript

讀完本文會解答你如下疑問:java

  • 靜態做用域鏈和動態做用域鏈的區別
  • 爲何會有閉包
  • 閉包何時建立的
  • [[scopes]] 屬性是什麼
  • 閉包保存什麼內容
  • 閉包存儲在哪
  • 爲何 eval 性能很差
  • eval 什麼狀況下會建立閉包

正文

在 JavaScript 裏面,函數、塊、模塊均可以造成做用域(一個存放變量的獨立空間),他們之間能夠相互嵌套,做用域之間會造成引用關係,這條鏈叫作做用域鏈。node

做用域鏈具體是什麼樣呢?webpack

靜態做用域鏈

好比這樣一段代碼web

function func() {
    const guang = 'guang';
    function func2() {
      const ssh = 'ssh';
      {
        function func3 () {
          const suzhe = 'suzhe';
        }
      }
    }
  }

複製代碼

其中,有 guang、ssh、suzhe 3個變量,有 func、func二、func3 3個函數,還有一個塊,他們之間的做用域鏈能夠用babel查看一下。編程

const parser = require('@babel/parser');
const traverse = require('@babel/traverse').default;
const code = ` function func() { const guang = 'guang'; function func2() { const ssh = 'ssh'; { function func3 () { const suzhe = 'suzhe'; } } } } `;

const ast = parser.parse(code);

traverse(ast, {
  FunctionDeclaration (path) {
    if (path.get('id.name').node === 'func3') {
      console.log(path.scope.dump());
    }
  }
})
複製代碼

結果是babel

用圖可視化一下就是這樣的markdown

image.png

函數和塊的做用域內的變量聲明會在做用域 (scope) 內建立一個綁定(變量名綁定到具體的值,也就是 binding),而後其他地方能夠引用 (refer) 這個 binding,這樣就是靜態做用域鏈的變量訪問順序。網絡

爲何叫「靜態」呢?閉包

由於這樣的嵌套關係是分析代碼就能夠得出的,不須要運行,按照這種順序訪問變量的鏈就是靜態做用域鏈,這種鏈的好處是能夠直觀的知道變量之間的引用關係。

相對的,還有動態做用域鏈,也就是做用域的引用關係與嵌套關係無關,與執行順序有關,會在執行的時候動態建立不一樣函數、塊的做用域的引用關係。缺點就是不直觀,無法靜態分析。

靜態做用域鏈是能夠作靜態分析的,好比咱們剛剛用 babel 分析的 scope 鏈就是。因此絕大多數編程語言都是做用域鏈設計都是選擇靜態的順序。

可是,JavaScript 除了靜態做用域鏈外,還有一個特色就是函數能夠做爲返回值。好比

function func () {
  const a = 1;
  return function () {
    console.log(a);
  }
}
const f2 = func();
複製代碼

這就致使了一個問題,原本按照順序建立調用一層層函數,按順序建立和銷燬做用域挺好的,可是若是內層函數返回了或者經過別的暴露出去了,那麼外層函數銷燬,內層函數卻沒有銷燬,這時候怎麼處理做用域,父做用域銷不銷燬? (好比這裏的 func 調用結束要不要銷燬做用域)

不按順序的函數調用與閉包

好比把上面的代碼作下改造,返回內部函數,而後在外面調用:

function func() {
  const guang = 'guang';
  function func2() {
    const ssh = 'ssh';
    function func3 () {
      const suzhe = 'suzhe';
    }
    return func3;
  }
  return func2;
}

const func2 = func();
複製代碼

當調用 func2 的時候 func1 已經執行完了,這時候銷不銷燬 ?因而 JavaScript 就設計了閉包的機制。

閉包怎麼設計?

先不看答案,考慮一下咱們解決這個靜態做用域鏈中的父做用域先於子做用域銷燬怎麼解決。

首先,父做用域要不要銷燬? 是否是父做用域不銷燬就好了?

不行的,父做用域中有不少東西與子函數無關,爲啥由於子函數沒結束就一直常駐內存。這樣確定有性能問題,因此仍是要銷燬。 可是銷燬了父做用域不能影響子函數,因此要再建立個對象,要把子函數內引用(refer)的父做用域的變量打包裏來,給子函數打包帶走。

怎麼讓子函數打包帶走?

設計個獨特的屬性,好比 [[Scopes]] ,用這個來放函數打包帶走的用到的環境。而且這個屬性得是一個棧,由於函數有子函數、子函數可能還有子函數,每次打包都要放在這裏一個包,因此就要設計成一個棧結構,就像飯盒有多層同樣。

咱們所考慮的這個解決方案:銷燬父做用域後,把用到的變量包起來,打包給子函數,放到一個屬性上。這就是閉包的機制。

咱們來試驗一下閉包的特性:

image.png

這個 func3 需不須要打包一些東西? 會不會有閉包?

image.png

其實仍是有閉包的,閉包最少會包含全局做用域。

可是爲啥 guang、ssh、suzhe 都沒有 ? suzhe是由於不是外部的,只有外部變量的時候纔會生成,好比咱們改動下代碼,打印下這 3 個變量。

image.png

再次查看 [[Scopes]] (打包帶走的閉包環境):

image.png

這時候就有倆閉包了,爲何呢? suzhe 哪去了?

首先,咱們須要打包的只是環境內沒有的,也就是閉包只保存外部引用。而後是在建立函數的時候保存到函數屬性上的,建立的函數返回的時候會打包給函數,可是 JS 引擎怎麼知道它要用到哪些外部引用呢,須要作 AST 掃描,不少 JS 引擎會作 Lazy Parsing,這時候去 parse 函數,正好也能知道它用到了哪些外部引用,而後把這些外部用打包成 Closure 閉包,加到 [[scopes]] 中。

因此,閉包是返回函數的時候掃描函數內的標識符引用,把用到的本做用域的變量打成 Closure 包,放到 [[Scopes]] 裏。

因此上面的函數會在 func3 返回的時候掃描函數內的標識符,把 guang、ssh 掃描出來了,就順着做用域鏈條查找這倆變量,過濾出來打包成兩個 Closure(由於屬於兩個做用域,因此生成兩個 Closure),再加上最外層 Global,設置給函數 func3 的 [[scopes]] 屬性,讓它打包帶走。

調用 func3 的時候,JS 引擎 會取出 [[Scopes]] 中的打包的 Closure + Global 鏈,設置成新的做用域鏈, 這就是函數用到的全部外部環境了,有了外部環境,天然就能夠運行了。

這裏思考一個問題: 調試代碼的時候爲何遇到過某個變量明明在做用域內能訪問到,但就是沒有相關信息呢?

image.png

這個 traverse,明明能訪問到的,爲啥就是不顯示信息呢?是 debugger 作的太爛了麼?

不是的,若是你不知道緣由,那是由於你還不理解閉包,由於這個 FunctionDeclaration 的函數是一個回調函數,明顯是在另外一個函數內調用的,就須要在建立的時候打包帶走這個環境內的東西,根據只打包必要的環境的原則(不浪費內存),traverse 沒有被引用(refer),天然就不打包了。並非 debugger 有 bug 了。

因此咱們只要訪問一下,就能在調試的時候訪問到了。

image.png

是否是忽然知道爲啥調試的時候不能看一些變量的信息了,能解釋清楚這個現象,就算理解閉包了。

eval

再來思考一個問題: 閉包須要掃描函數內的標識符,作靜態分析,那 eval 怎麼辦,他有可能內容是從網絡記載的,從磁盤讀取的等等,內容是動態的。用靜態去分析動態是不可能沒 bug 的。怎麼辦?

沒錯,eval 確實無法分析外部引用,也就無法打包閉包,這種就特殊處理一下,打包整個做用域就行了。

驗證一下:

image.png

這個就像上面所說的,會把外部引用的打包成閉包

image.png

這個就是 eval 的實現,由於無法靜態分析動態內容因此所有打包成閉包了,原本閉包就是爲了避免保存所有的做用域鏈的內容,結果 eval 致使所有保存了,因此儘可能不要用 eval。會致使閉包保存內容過多。

image.png

可是 JS 引擎只處理了直接調用,也就是說直接調用 eval 纔會打包整個做用域,若是不直接調用 eval,就無法分析引用,也就無法造成閉包了。

這種特殊狀況有的時候還能用來完成一些黑魔法,好比利用不直接調用 eval 不會生成閉包,會在全局上下文執行的特性。

給閉包下個定義

用咱們剛剛的試驗來給閉包下個定義:

閉包是在函數建立的時候,讓函數打包帶走的根據函數內的外部引用來過濾做用域鏈剩下的鏈。它是在函數建立的時候生成的做用域鏈的子集,是打包的外部環境。evel 由於無法分析內容,因此直接調用會把整個做用域打包(因此儘可能不要用 eval,容易在閉包保存過多的無用變量),而不直接調用則沒有閉包。

過濾規則:

  1. 全局做用域不會被過濾掉,必定包含。因此在何處調用函數都能訪問到。

  2. 其他做用域會根據是否內部有變量被當前函數所引用而過濾掉一些。不是每一個返回的子函數都會生成閉包。

  3. 被引用的做用域也會過濾掉沒有被引用的 binding (變量聲明)。只把用到的變量打個包。

閉包的缺點

JavaScript 是靜態做用域的設計,閉包是爲了解決子函數晚於父函數銷燬的問題,咱們會在父函數銷燬時,把子函數引用到的變量打成 Closure 包放到函數的 [[Scopes]] 上,讓它計算父函數銷燬了也隨時隨地能訪問外部環境。

這樣設計確實解決了問題,可是有沒有什麼缺點呢?

其實問題就在於這個 [[Scopes]] 屬性上

咱們知道 JavaScript 引擎會把內存分爲函數調用棧、全局做用域和堆,其中堆用於放一些動態的對象,調用棧每個棧幀放一個函數的執行上下文,裏面有一個 local 變量環境用於放內部聲明的一些變量,若是是對象,會在堆上分配空間,而後把引用保存在棧幀的 local 環境中。全局做用域也是同樣,只不過通常用於放靜態的一些東西,有時候也叫靜態域。

image.png

每一個棧幀的執行上下文包含函數執行須要訪問的全部環境,包括 local 環境、做用域鏈、this等。

那麼若是子函數返回了會發生什麼呢?

首先父函數的棧幀會銷燬,子函數這個時候其實尚未被調用,因此仍是一個堆中的對象,沒有對應的棧幀,這時候父函數把做用域鏈過濾出須要用到的,造成閉包鏈,設置到子函數的 [[Scopes]] 屬性上。

image.png

父函數銷燬,棧幀對應的內存立刻釋放,用到的 ssh Obj 會被 gc 回收,而返回的函數會把做用域鏈過濾出用到的引用造成閉包鏈放在堆中。 這就致使了一個隱患: 若是一個很大的對象被函數引用,原本函數調用結束就能銷燬,可是如今引用卻被經過閉包保存到了堆裏,並且還一直用不到,那這塊堆內存就一直無法使用,嚴重到必定程度就算是內存泄漏了。因此閉包不要亂用,少打包一點東西到堆內存。

總結

咱們從靜態做用域開始聊起,明確了什麼是做用域,經過 babel 靜態分析了一下做用域,瞭解了下靜態和動態做用域,而後引入了子函數先於父函數銷燬的問題,思考了下方案,而後引入了閉包的概念,分析下閉包生成的流程,保存的位置。咱們還用閉包的特性分析了下爲何有時候調試的時候查看不了變量信息,以後分析了下 eval 爲何無法精確生成閉包,何時所有打包做用域、何時不生成閉包, eval 爲何會致使內存佔用過多。以後分析了下帶有閉包的函數在內存中的特色,解釋了下爲啥可能會內存泄漏。

閉包是在返回一個函數的時候,爲了把環境保存下載,建立的一個快照,對做用域鏈作了tree shking,只留下必要的閉包鏈,保存在堆裏,做爲對象的 [[scopes]] 屬性,讓函數無論走到哪,隨時隨地可訪問用到的外部環境。在執行這個函數的時候,會利用這個「快照」,恢復做用域鏈。

由於還沒執行函數,因此要靜態分析標識符引用。靜態分析動態這件事情被無數個框架證實作不了,因此返回的函數有eval 只能所有打包或者不生成閉包。相似webpack 的動態import無法分析同樣。

相關文章
相關標籤/搜索