<kbd id="afajh"><form id="afajh"></form></kbd>
<strong id="afajh"><dl id="afajh"></dl></strong>
    <del id="afajh"><form id="afajh"></form></del>
        1. <th id="afajh"><progress id="afajh"></progress></th>
          <b id="afajh"><abbr id="afajh"></abbr></b>
          <th id="afajh"><progress id="afajh"></progress></th>

          JavaScript 的靜態(tài)作用域鏈與“動態(tài)”閉包鏈

          共 5337字,需瀏覽 11分鐘

           ·

          2021-05-13 14:53



          在 JavaScript 里面,函數(shù)、塊、模塊都可以形成作用域(一個存放變量的獨立空間),他們之間可以相互嵌套,作用域之間會形成引用關(guān)系,這條鏈叫做作用域鏈。


          作用域鏈具體是什么樣呢?


          靜態(tài)作用域鏈


          比如這樣一段代碼

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

          其中,有 guang、ssh、suzhe 3個變量,有 func、func2、func3 3個函數(shù),還有一個塊,他們之間的作用域鏈可以用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());
          }
          }})

          結(jié)果是

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

          函數(shù)和塊的作用域內(nèi)的變量聲明會在作用域 (scope) 內(nèi)創(chuàng)建一個綁定(變量名綁定到具體的值,也就是 binding),然后其余地方可以引用 (refer) 這個 binding,這樣就是靜態(tài)作用域鏈的變量訪問順序。


          為什么叫“靜態(tài)”呢?


          因為這樣的嵌套關(guān)系是分析代碼就可以得出的,不需要運行,按照這種順序訪問變量的鏈就是靜態(tài)作用域鏈,這種鏈的好處是可以直觀的知道變量之間的引用關(guān)系。


          相對的,還有動態(tài)作用域鏈,也就是作用域的引用關(guān)系與嵌套關(guān)系無關(guān),與執(zhí)行順序有關(guān),會在執(zhí)行的時候動態(tài)創(chuàng)建不同函數(shù)、塊的作用域的引用關(guān)系。缺點就是不直觀,沒法靜態(tài)分析。


          靜態(tài)作用域鏈是可以做靜態(tài)分析的,比如我們剛剛用 babel 分析的 scope 鏈就是。所以絕大多數(shù)編程語言都是作用域鏈設(shè)計都是選擇靜態(tài)的順序。


          但是,JavaScript 除了靜態(tài)作用域鏈外,還有一個特點就是函數(shù)可以作為返回值。比如

          function func () {
          const a = 1;
            return function () {
          console
          .log(a);

          }

          }

          const f2 = func();

          這就導(dǎo)致了一個問題,本來按照順序創(chuàng)建調(diào)用一層層函數(shù),按順序創(chuàng)建和銷毀作用域挺好的,但是如果內(nèi)層函數(shù)返回了或者通過別的暴露出去了,那么外層函數(shù)銷毀,內(nèi)層函數(shù)卻沒有銷毀,這時候怎么處理作用域,父作用域銷不銷毀?(比如這里的 func 調(diào)用結(jié)束要不要銷毀作用域)

          不按順序的函數(shù)調(diào)用與閉包

          比如把上面的代碼做下改造,返回內(nèi)部函數(shù),然后在外面調(diào)用:

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

          return func2;

          }

          const func2 = func();

          當(dāng)調(diào)用 func2 的時候 func1 已經(jīng)執(zhí)行完了,這時候要不要銷毀 ?為了解決這個問題,JavaScript 設(shè)計了閉包的機制。


          閉包怎么設(shè)計?


          先不看答案,考慮一下我們解決這個靜態(tài)作用域鏈中的父作用域先于子作用域銷毀怎么解決。


          首先,父作用域要不要銷毀?是不是父作用域不銷毀就行了?


          不行的,父作用域中有很多東西與子函數(shù)無關(guān),為啥因為子函數(shù)沒結(jié)束就一直常駐內(nèi)存。這樣肯定有性能問題,所以還是要銷毀。但是銷毀了父作用域不能影響子函數(shù),所以要再創(chuàng)建個對象,要把子函數(shù)內(nèi)引用(refer)的父作用域的變量打包里來,給子函數(shù)打包帶走。


          怎么讓子函數(shù)打包帶走?


          設(shè)計個獨特的屬性,比如 [[Scopes]] ,用這個來放函數(shù)打包帶走的用到的環(huán)境。并且這個屬性得是一個棧,因為函數(shù)有子函數(shù)、子函數(shù)可能還有子函數(shù),每次打包都要放在這里一個包,所以就要設(shè)計成一個棧結(jié)構(gòu),就像飯盒有多層一樣。


          我們所考慮的這個解決方案:銷毀父作用域后,把用到的變量包起來,打包給子函數(shù),放到一個屬性上。這就是閉包的機制。


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



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


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


          但是為啥 guang、ssh、suzhe 都沒有 ?suzhe 是因為不是外部的,只有外部變量的時候才會生成,比如我們改動下代碼,打印下這 3 個變量。



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

          這時候就有倆閉包了,為什么呢?suzhe 哪去了?


          首先,我們需要打包的只是環(huán)境內(nèi)沒有的,也就是閉包只保存外部引用。然后是在創(chuàng)建函數(shù)的時候保存到函數(shù)屬性上的,創(chuàng)建的函數(shù)返回的時候會打包給函數(shù),但是 JS 引擎怎么知道它要用到哪些外部引用呢,需要做 AST 掃描,很多 JS 引擎會做 Lazy Parsing,這時候去 parse 函數(shù),正好也能知道它用到了哪些外部引用,然后把這些外部用打包成 Closure 閉包,加到 [[scopes]] 中。


          所以,閉包是返回函數(shù)的時候掃描函數(shù)內(nèi)的標識符引用,把用到的本作用域的變量打成 Closure 包,放到 [[Scopes]] 里。


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


          調(diào)用 func3 的時候,JS 引擎 會取出 [[Scopes]] 中的打包的 Closure  + Global 鏈,設(shè)置成新的作用域鏈, 這就是函數(shù)用到的所有外部環(huán)境了,有了外部環(huán)境,自然就可以運行了。


          這里思考一個問題:調(diào)試代碼的時候為什么某個變量明明在作用域內(nèi)能訪問到,但就是沒有相關(guān)信息呢?

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


          不是的,如果你不知道原因,那是因為你還不理解閉包。因為這個 FunctionDeclaration 的函數(shù)是一個回調(diào)函數(shù),明顯是在另一個函數(shù)內(nèi)調(diào)用的,就需要在創(chuàng)建的時候打包帶走這個環(huán)境內(nèi)的東西,根據(jù)只打包必要的環(huán)境的原則(不浪費內(nèi)存),traverse 沒有被引用(refer),自然就不打包了。并不是 debugger 有 bug 了。


          所以我們只要訪問一下,就能在調(diào)試的時候訪問到了。

          是不是突然知道為啥調(diào)試的時候不能看一些變量的信息了,能解釋清楚這個現(xiàn)象,就算理解閉包了。


          再來思考一個問題:閉包需要掃描函數(shù)內(nèi)的標識符,做靜態(tài)分析,那 eval 怎么辦,他有可能內(nèi)容是從網(wǎng)絡(luò)記載的,從磁盤讀取的等等,內(nèi)容是動態(tài)的。用靜態(tài)去分析動態(tài)是不可能沒 bug 的。怎么辦?


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

          驗證一下:



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



          這個就是 eval 的實現(xiàn),因為沒法靜態(tài)分析動態(tài)內(nèi)容所以全部打包成閉包了,本來閉包就是為了不保存全部的作用域鏈的內(nèi)容,結(jié)果 eval 導(dǎo)致全部保存了,所以盡量不要用 eval,會導(dǎo)致閉包保存內(nèi)容過多。

          但是 JS 引擎只處理了直接調(diào)用,也就是說直接調(diào)用 eval 才會打包整個作用域,如果不直接調(diào)用 eval,就沒法分析引用,也就沒法形成閉包了。


          這種特殊情況有的時候還能用來完成一些黑魔法,比如利用不直接調(diào)用 eval 不會生成閉包,會在全局上下文執(zhí)行的特性。



          給閉包下個定義


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


          閉包是在函數(shù)創(chuàng)建的時候,讓函數(shù)打包帶走的根據(jù)函數(shù)內(nèi)的外部引用來過濾作用域鏈剩下的鏈。它是在函數(shù)創(chuàng)建的時候生成的作用域鏈的子集,是打包的外部環(huán)境。evel 因為沒法分析內(nèi)容,所以直接調(diào)用會把整個作用域打包(所以盡量不要用 eval,容易在閉包保存過多的無用變量),而不直接調(diào)用則沒有閉包。


          過濾規(guī)則:


          1. 全局作用域不會被過濾掉,一定包含。所以在何處調(diào)用函數(shù)都能訪問到。


          2. 其余作用域會根據(jù)是否內(nèi)部有變量被當(dāng)前函數(shù)所引用而過濾掉一些。不是每個返回的子函數(shù)都會生成閉包。


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

          閉包的缺點

          JavaScript 是靜態(tài)作用域的設(shè)計,閉包是為了解決子函數(shù)晚于父函數(shù)銷毀的問題,我們會在父函數(shù)銷毀時,把子函數(shù)引用到的變量達成 Closure 包放到函數(shù)的 [[Scopes]] 上,讓它計算父函數(shù)銷毀了也隨時隨地能訪問外部環(huán)境。


          這樣設(shè)計確實解決了問題,但是有沒有什么缺點呢?


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


          我們知道 JavaScript 引擎會把內(nèi)存分為函數(shù)調(diào)用棧、全局作用域和堆,其中堆用于放一些動態(tài)的對象,調(diào)用棧每一個棧幀放一個函數(shù)的執(zhí)行上下文,里面有一個 local 變量環(huán)境用于放內(nèi)部聲明的一些變量,如果是對象,會在堆上分配空間,然后把引用保存在棧幀的 local 環(huán)境中。全局作用域也是一樣,只不過一般用于放靜態(tài)的一些東西,有時候也叫靜態(tài)域。

          每個棧幀的執(zhí)行上下文包含函數(shù)執(zhí)行需要訪問的所有環(huán)境,包括 local 環(huán)境、作用域鏈、this等。


          那么如果子函數(shù)返回了會發(fā)生什么呢?


          首先父函數(shù)的棧幀會銷毀,子函數(shù)這個時候其實還沒有被調(diào)用,所以還是一個堆中的對象,沒有對應(yīng)的棧幀,這時候父函數(shù)把作用域鏈過濾出需要用到的,形成閉包鏈,設(shè)置到子函數(shù)的 [[Scopes]] 屬性上。

          父函數(shù)銷毀,棧幀對應(yīng)的內(nèi)存馬上釋放,用到的 ssh Obj 會被 gc 回收,而返回的函數(shù)會把作用域鏈過濾出用到的引用形成閉包鏈放在堆中。這就導(dǎo)致了一個隱患:本來作用域是隨著函數(shù)調(diào)用的結(jié)束而銷毀的,因為整個棧幀都會被馬上銷毀。而形成閉包以后,轉(zhuǎn)移到了堆內(nèi)存。當(dāng)運行這個子函數(shù)的時候,子函數(shù)會創(chuàng)建棧幀,如果這個函數(shù)一直在運行,那么它在堆內(nèi)存中的閉包就一直占用著內(nèi)存,就會使可用內(nèi)存減少,嚴重到一定程度就算是內(nèi)存泄漏了。所以閉包不要亂用,少打包一點東西到堆內(nèi)存。

          總結(jié)

          我們從靜態(tài)作用域開始聊起,明確了什么是作用域,通過 babel 靜態(tài)分析了一下作用域,了解了下靜態(tài)和動態(tài)作用域,然后引入了子函數(shù)先于父函數(shù)銷毀的問題,思考了下方案,然后引入了閉包的概念,分析下閉包生成的流程,保存的位置。我們還用閉包的特性分析了下為什么有時候調(diào)試的時候查看不了變量信息,之后分析了下 eval 為什么沒法精確生成閉包,什么時候全部打包作用域、什么時候不生成閉包, eval 為什么會導(dǎo)致內(nèi)存占用過多。之后分析了下帶有閉包的函數(shù)在內(nèi)存中的特點,解釋了下為啥可能會內(nèi)存泄漏。


          閉包是在返回一個函數(shù)的時候,為了把環(huán)境保存下載,創(chuàng)建的一個快照,對作用域鏈做了tree shking,只留下必要的閉包鏈,保存在堆里,作為對象的 [[scopes]] 屬性,讓函數(shù)不管走到哪,隨時隨地可訪問用到的外部環(huán)境。


          因為還沒執(zhí)行函數(shù),所以要靜態(tài)分析標識符引用。靜態(tài)分析動態(tài)這件事情被無數(shù)個框架證明做不了,所以返回的函數(shù)有eval 只能全部打包或者不生成閉包。類似webpack 的動態(tài)import沒法分析一樣。


          如果你覺得這篇內(nèi)容對你挺有啟發(fā),我想邀請你幫我三個小忙:

          1. 點個「在看」,讓更多的人也能看到這篇內(nèi)容(喜歡不點在看,都是耍流氓 -_-)
          2. 歡迎加我微信「TH0000666」一起交流學(xué)習(xí)...
          3. 關(guān)注公眾號「前端Sharing」,持續(xù)為你推送精選好文。



          瀏覽 52
          點贊
          評論
          收藏
          分享

          手機掃一掃分享

          分享
          舉報
          評論
          圖片
          表情
          推薦
          點贊
          評論
          收藏
          分享

          手機掃一掃分享

          分享
          舉報
          <kbd id="afajh"><form id="afajh"></form></kbd>
          <strong id="afajh"><dl id="afajh"></dl></strong>
            <del id="afajh"><form id="afajh"></form></del>
                1. <th id="afajh"><progress id="afajh"></progress></th>
                  <b id="afajh"><abbr id="afajh"></abbr></b>
                  <th id="afajh"><progress id="afajh"></progress></th>
                  18久久| 黄频免费看 | 青娱乐偷窥成人 | AA一级黄片| 中文字幕在线一区 |