從執行上下文(ES3,ES5)的角度來理解"閉包"

Echoyya、發表於2021-05-17

惰性十足,就是不願意花時間把看過的東西整理一下,其它的任何事都比寫部落格要有吸引力,嗯... 要反省自己。

今天看到一篇關於閉包的文章,裡面有這樣一句話 “就我而言對於閉包的理解僅止步於一些概念,看到相關程式碼知道這是個閉包,但閉包能解決哪些問題場景我瞭解的並不多”,這說的不就是我麼,每每在面試中被問及什麼是閉包,大部分情況下得到的答覆是(至少我以前是)A函式巢狀B函式,B函式使用了A函式的內部變數,且A函式返回B函式,這就是閉包。而往往面試官想要聽到的並不是這樣的答案,如果在多幾個這樣的回答,那麼恭喜你,基本就涼了。

在之前的面試中,關於閉包總是有種莫名的恐懼,想趕快結束這個話題,進入下一環節,有沒有?我原本想是深入學習一下閉包就好了,但經過我多方考查學習,發現閉包牽涉的知識點是很廣的,需要明白JS引擎的工作機制和一些底層的原理,瞭解了相關知識點之後,在回過頭理解閉包就容易多了,文章的最後,會介紹閉包的概念,形成、實現,和使用,以及對效能和記憶體的影響,其實還是很好理解的,學完這篇文章,至少可以讓你在下一次面試中,侃侃而談5分鐘吧。開始正文

介紹執行上下文和執行上下文棧概念

JS中可執行的程式碼一共就分為三種:全域性程式碼函式程式碼eval程式碼。由於eval一般不會使用,這裡不做討論。而程式碼的執行順序總是與程式碼編寫先後順序有所差異,先拋開非同步問題,就算是同步程式碼,它的執行也與預期的不一致,這說明程式碼在執行前一定發生了某些微妙的變化,JS引擎究竟做了什麼呢?

執行上下文

其實JS程式碼在執行前,JS引擎總要做一番準備工作,這裡的“準備工作”,用個更專業一點的說法,就叫做"執行上下文(execution context)",對應上述可執行的程式碼,會產生不同的執行上下文

1.全域性執行上下文:只有一個,在客戶端中一般由瀏覽器建立,也就是window物件,能通過this直接訪問到它。全域性物件window上預定義了大量的方法和屬性,在全域性環境的任意處都能直接訪問這些屬性方法,同時window物件還是var宣告的全域性變數的載體。我們通過var建立的全域性物件,都可以通過window直接訪問。
2.函式執行上下文:可存在無數個,每當一個函式被呼叫時都會建立一個函式上下文;需要注意的是,同一個函式被多次呼叫,都會建立一個新的上下文。

執行上下文棧

那麼接下來問題來了,寫的函式多了去了,如何管理建立的那麼多執行上下文呢? JavaScript 引擎建立了執行上下文棧(Execution context stack,ECS)來管理執行上下文。簡稱執行棧也叫呼叫棧,執行棧用於儲存程式碼執行期間建立的所有上下文,具有FILO(First In Last Out先進後出)的特性。

JS程式碼首次執行,都會先建立一個全域性執行上下文並壓入到執行棧中,之後每當有函式被呼叫,都會建立一個新的函式執行上下文並壓入棧內;由於執行棧FILO的特性,所以可以理解為,JS程式碼執行完畢前在執行棧底部永遠有個全域性執行上下文

棧中的執行順序為:先進後出

虛擬碼模擬分析以下程式碼中執行上下文棧的行為

function a() {
  b()
}

function b() {
  c()
}

function c() {
  console.log('c');
}
a()

定義一個陣列來模擬執行上下文棧的行為: ECStack = [];

當 JavaScript 開始要解釋執行程式碼時,最先遇到肯定是全域性程式碼,所以初始化的時候首先就會向執行上下文棧壓入一個全域性執行上下文,用 globalContext 表示它,並且只有當整個應用程式結束的時候,ECStack 才會被清空,所以程式結束之前, ECStack 最底部永遠有個 globalContext:

ECStack = [
    globalContext
];

執行一個函式,都會建立一個執行上下文,並且壓入執行上下文棧中的棧頂,當函式執行完畢後,就會將該函式的執行上下文從棧頂彈出。

// 按照執行順序,分別建立對應函式的執行上下文,並且壓入執行上下文棧的棧頂
ECStack.push(functionAContext)    // push a
ECStack.push(functionBContext)    // push b
ECStack.push(functionCContext)    // push c
 
// 棧執行,首先C函式執行完畢,先進後出,
ECStack.pop()   // 彈出c
ECStack.pop()   // 彈出b
ECStack.pop()   // 彈出a

// javascript接著執行下面的程式碼,但是ECStack底層永遠有個globalContext,直到整個應用程式結束的時候,ECStack 才會被清空
// ......
// ......

程式碼模擬實現棧的執行過程

class Stack {
  constructor(){
    this.items = []
  }
  push(ele) {
    this.items.push(ele)
  }
  pop() {
    return this.items.pop()
  }
}

let stack = new Stack()
stack.push(1)
stack.push(2)
stack.push(3)
console.log(stack.pop())    // 3
console.log(stack.pop())    // 2
console.log(stack.pop())    // 1

通過ES3提出的老概念—理解執行上下文

我在閱讀相關資料時,遇到了一個問題,就是關於執行上下文說法不一,不過大致可以分為兩種觀點,一個是變數物件,活動物件,詞法作用域,作用域鏈,另一個是詞法環境,變數環境,一番查閱可以確定的是,變數物件與活動物件的概念是ES3提出的老概念,從ES5開始就用詞法環境和變數環境替代了,因為更好解釋。
先大致講一下變數物件,活動物件,詞法作用域,作用域鏈吧

1.變數物件和活動物件

變數物件是與執行上下文相關的資料作用域,儲存了在上下文中定義的變數和函式宣告。不同執行上下文中的變數物件不同,分別看一下全域性上下文中的變數物件函式上下文中的變數物件

全域性上下文中的變數物件

全域性上下文中的變數物件就是全域性物件。W3School 中有介紹:

  • 全域性物件是預定義的物件,作為 JavaScript 的全域性函式和全域性屬性的佔位符。通過使用全域性物件,可以訪問所有其他所有預定義的物件、函式和屬性。
  • 在頂層 JavaScript 程式碼中,可以用關鍵字 this 引用全域性物件。因為全域性物件是作用域鏈的頭,這意味著所有非限定性的變數和函式名都會作為該物件的屬性來查詢。

函式上下文中的變數物件

在函式上下文中用活動物件(activation object, AO)來表示變數物件(VO)。

活動物件和變數物件其實是一個東西,只是變數物件是規範上的或者說是引擎實現上的,不可在 JavaScript 環境中訪問,只有進入一個執行上下文中時,這個執行上下文的變數物件才會被啟用,所以才叫活動物件,而只有被啟用的變數物件(也就是活動物件)上的各種屬性才能被訪問。
換句話說:未進入執行階段之前,變數物件(VO)中的屬性都不能訪問!進入執行階段之後,變數物件(VO)轉變為了活動物件(AO),裡面的屬性可以被訪問,並開始進行執行階段的操作。它們其實都是同一個物件,只是處於執行上下文的不同生命週期

但是從嚴格角度來說,AO 實際上是包含了 VO 的。因為除了 VO 之外,AO 還包含函式的 parameters,以及 arguments 這個特殊物件。也就是說 AO 的確是在進入到執行階段的時候被啟用,但是啟用的除了 VO 之外,還包括函式執行時傳入的引數和 arguments 這個特殊物件。
AO = VO + function parameters + arguments
活動物件是在進入函式上下文時刻被啟用,通過函式的 arguments 屬性初始化。

執行上下文的程式碼會分成兩個階段進行處理,預解析和執行:

  • 預解析的過程會啟用AO物件,解析形參,變數提升及函式宣告等
  • 在程式碼執行階段,會從上到下順序執行程式碼,根據程式碼,修改變數物件的值。

2.詞法作用域

作用域是指程式碼中定義變數的區域。其規定了如何查詢變數,也就是確定當前執行程式碼對變數的訪問許可權。
JavaScript 採用詞法作用域(lexical scoping),也就是靜態作用域,函式的作用域在函式定義的時候就決定了。
詞法作用域根據原始碼中宣告變數的位置來確定該變數在何處可用。巢狀函式可訪問宣告於它們外部作用域的變數

// 詞法作用域
var value = 1;

function foo() {
    console.log(value);
}

function bar() {
    var value = 2;
    foo();
}

bar();    // 1

分析下執行過程:執行 foo ,先從 foo 內部查詢是否有區域性變數 value,如果沒有,就根據書寫的位置,查詢上一層作用域,也就是 value=1,所以列印 1。
看個例子:

var scope = "global scope";
function checkscope(){
    var scope = "local scope";
    function f(){
        return scope;
    }
    return f();
}
checkscope();
var scope = "global scope";
function checkscope(){
    var scope = "local scope";
    function f(){
        return scope;
    }
    return f;
}
checkscope()();

兩段程式碼都會列印:local scope。原因也很簡單,因為JavaScript採用的是詞法作用域,函式的作用域基於函式建立的位置。

雖然兩段程式碼執行的結果一樣,但是兩段程式碼究竟有什麼不同呢?詞法作用域只是其中的一小部分,還有一個答案就是:執行上下文棧的變化不一樣

模擬第一段程式碼執行時棧中的變化:

ECStack.push(<checkscope> functionContext);
ECStack.push(<f> functionContext);
ECStack.pop();
ECStack.pop();

模擬第二段程式碼執行時棧中的變化:

ECStack.push(<checkscope> functionContext);
ECStack.pop();
ECStack.push(<f> functionContext);
ECStack.pop();

3.作用域鏈

每個函式都有自己的執行上下文環境,當程式碼在這個環境中執行時,會建立變數物件的作用域鏈,作用域鏈類似一個物件列表,它保證了變數物件的有序訪問。作用域鏈的最前端是當前程式碼執行環境的變數物件,也稱“活躍物件AO”,當查詢變數的時候,會先從當前上下文的變數物件中查詢,如果找到就停止查詢,如果沒有就會繼續向上級作用域(父級執行上下文的變數物件)查詢,直到找到全域性上下文的變數物件(全域性物件)

特別注意:作用域鏈的逐級查詢,也會影響到程式的效能,變數作用域鏈越長對效能影響越大,這也是為什麼要儘量避免使用全域性變數的一個主要原因。

那麼這個作用域鏈是怎麼形成的呢?
這是因為函式有一個內部屬性 [[scope]]:當函式建立時,會儲存所有父變數物件到其中,可以理解 [[scope]] 就是所有父變數物件的層級鏈,當函式啟用時,進入函式上下文,會將當前啟用的活動物件新增到作用鏈的最前端。此時就可以理解,查詢變數時首先找自己,沒有再找父親

下面以一個函式的建立和啟用兩個時期來講解作用域鏈是如何建立和變化的。

function foo() {
    function bar() {
        ...
    }
}

函式建立時,各自的[[scope]]為:

foo.[[scope]] = [
  globalContext.VO
];

bar.[[scope]] = [
    fooContext.AO,
    globalContext.VO
];

當函式啟用時,進入函式上下文,就會將當前啟用的活動物件新增到作用鏈的前端。
這時候當前的執行上下文的作用域鏈為 Scope = [AO].concat([[Scope]]);

以下面程式碼為例,結合變數物件和執行上下文棧,來總結一下函式執行上下文中作用域鏈和變數物件的建立過程:

var scope = "global scope";
function checkscope(){
    var scope2 = 'local scope';
    return scope2;
}
checkscope();

執行過程如下(虛擬碼):

// 1.checkscope 函式被建立,儲存父變數物件到 內部屬性[[scope]] 
checkscope.[[scope]] = [
    globalContext.VO
];

// 2.執行 checkscope 函式,建立 checkscope 函式執行上下文,checkscope 函式執行上下文被壓入執行上下文棧
ECStack = [
    checkscopeContext,
    globalContext
];

// 3.checkscope 函式並不立刻執行,開始做準備工作,第一步:複製函式[[scope]]屬性建立作用域鏈
checkscopeContext = {
    Scope: checkscope.[[scope]],
}

// 4.用 arguments 建立活動物件,隨後初始化活動物件,加入形參、函式宣告、變數宣告
checkscopeContext = {
    AO: {
        arguments: {
            length: 0
        },
        scope2: undefined
    },
    Scope: checkscope.[[scope]],
}

// 5.將活動物件壓入 checkscope 作用域鏈Scope的頂端
checkscopeContext = {
    AO: {
        arguments: {
            length: 0
        },
        scope2: undefined
    },
    Scope: [AO, [[Scope]]]
}

// 6.準備工作做完,開始執行函式,隨著函式的執行,修改 AO 的屬性值
checkscopeContext = {
    AO: {
        arguments: {
            length: 0
        },
        scope2: 'local scope'
    },
    Scope: [AO, [[Scope]]]
}

// 7.查詢到 scope2 的值,返回後函式執行完畢,函式上下文從執行上下文棧中彈出
ECStack = [
    globalContext
];

4.活學活用 — 案例分析

通過案例分析的形式,串聯上述所有知識點,模擬執行上下文建立執行的過程

var scope = "global scope";
function checkscope(){
  var scope = "local scope";
  function f(){
      return scope;
  }
  return f();
}
checkscope();

// 1.執行全域性程式碼,建立全域性執行上下文,全域性上下文被壓入執行上下文棧
  ECStack = [
    globalContext
  ];

// 2.全域性上下文初始化
  globalContext = {
    VO: [global],
    Scope: [globalContext.VO],
    this: globalContext.VO
  }

// 3.初始化的同時,checkscope 函式被建立,儲存作用域鏈到函式的內部屬性[[scope]]
  checkscope.[[scope]] = [
    globalContext.VO
  ];

// 4.執行 checkscope 函式,建立 checkscope 函式執行上下文,並壓入執行上下文棧
  ECStack = [
    checkscopeContext,
    globalContext
  ];

// 5.checkscope 函式執行上下文初始化:
/**
 * 複製函式 [[scope]] 屬性建立作用域鏈,
 * 用 arguments 建立活動物件,
 * 初始化活動物件,即加入形參、函式宣告、變數宣告,
 * 將活動物件壓入 checkscope 作用域鏈頂端。
 * 同時 f 函式被建立,儲存作用域鏈到 f 函式的內部屬性[[scope]]
 */
  checkscopeContext = {
    AO: {
      arguments: {
          length: 0
      },
      scope: undefined,
      f: reference to function f(){}    // 引用函式
    },
    Scope: [AO, globalContext.VO],
    this: undefined
  }

// 6.執行 f 函式,建立 f 函式執行上下文,f 函式執行上下文被壓入執行上下文棧
  ECStack = [
    fContext,
    checkscopeContext,
    globalContext
  ];

// 7.f 函式執行上下文初始化, 以下跟第 5 步相同:
  /**
  複製函式 [[scope]] 屬性建立作用域鏈
  用 arguments 建立活動物件
  初始化活動物件,即加入形參、函式宣告、變數宣告
  將活動物件壓入 f 作用域鏈頂端
  */
  fContext = {
    AO: {
      arguments: {
          length: 0
      }
    },
    Scope: [AO, checkscopeContext.AO, globalContext.VO],
    this: undefined
  }
// 8.f 函式執行,沿著作用域鏈查詢 scope 值,返回 scope 值

// 9.f 函式執行完畢,f 函式上下文從執行上下文棧中彈出
  ECStack = [
    checkscopeContext,
    globalContext
  ];

// 10.checkscope 函式執行完畢,checkscope 執行上下文從執行上下文棧中彈出
  ECStack = [
    globalContext
  ];

通過ES5提出的新概念—理解執行上下文

執行上下文建立分為建立階段執行階段兩個階段,較為難理解應該是建立階段。
建立階段主要負責三件事:

  • 確定this
  • 建立詞法環境(LexicalEnvironment)
  • 建立變數環境(VariableEnvironment)

建立階段

ExecutionContext = {  
    ThisBinding = <this value>,  // 確定this
    LexicalEnvironment = {},     // 建立詞法環境
    VariableEnvironment = {},    // 建立變數環境
};

1. 確定this
官方稱呼為:This Binding,在全域性執行上下文中,this總是指向全域性物件,例如瀏覽器環境下this指向window物件。而在函式執行上下文中,this的值取決於函式的呼叫方式,如果被一個物件呼叫,那麼this指向這個物件。否則this一般指向全域性物件window或者undefined(嚴格模式)。

2. 詞法環境
詞法環境中包含識別符號和變數的對映關係,識別符號表示變數/函式的名稱,變數是對實際物件【包括函式型別物件】或原始值的引用。

詞法環境由環境記錄外部環境引入記錄兩個部分組成。

  • 環境記錄:用於儲存當前環境中的變數和函式宣告的實際位置;
  • 外部環境引入記錄:用於儲存自身環境可以訪問的其它外部環境,有點作用域鏈的意思

那麼全域性執行上下文函式執行上下文,也導致了詞法環境分為全域性詞法環境函式詞法環境兩種。

  • 全域性詞法環境:對外部環境的引入記錄為 null,因為它本身就是最外層環境,除此之外它還記錄了當前環境下的所有屬性、方法位置。
  • 函式詞法環境:包含使用者在函式中定義的所有屬性方法外,還包含一個arguments物件(該物件包含了索引和傳遞給函式的引數之間的對映以及傳遞給函式的引數的長度)。函式詞法環境的外部環境引入可以是全域性環境,也可以是其它函式環境,這個根據實際程式碼而定。

環境記錄在全域性和函式中也不同,全域性中的環境記錄叫物件環境記錄,函式中環境記錄叫宣告性環境記錄,下方有展示:

  • 物件環境記錄: 用於定義在全域性執行上下文中出現的變數和函式的關聯。全域性環境包含物件環境記錄。
  • 宣告性環境記錄 儲存變數、函式和引數。一個函式環境包含宣告性環境記錄。
GlobalExectionContext = {    // 全域性環境
  LexicalEnvironment: {      // 全域性詞法環境
    EnvironmentRecord: {     // 型別為物件環境記錄
      Type: "Object", 
      // 識別符號繫結在這裡 
    },
    outer: < null >
  }
};

FunctionExectionContext = {   // 函式環境
  LexicalEnvironment: {       // 函式詞法環境
    EnvironmentRecord: {      // 型別為宣告性環境記錄
      Type: "Declarative", 
      // 識別符號繫結在這裡 
    },
    outer: < Global or outerfunction environment reference >
  }
};

3. 變數環境

它也是一個詞法環境,它具備詞法環境所有屬性,同樣有環境記錄與外部環境引入。

在ES6中唯一的區別在於詞法環境用於儲存函式宣告與let const宣告的變數,而變數環境僅僅儲存var宣告的變數

let a = 20;  
const b = 30;  
var c;

function multiply(e, f) {  
 var g = 20;  
 return e * f * g;  
}

c = multiply(20, 30);

上述程式碼中執行上下文的建立過程:

//全域性執行上下文
GlobalExectionContext = {
    ThisBinding: Global Object,  // this繫結為全域性物件
    LexicalEnvironment: {          // 詞法環境
      EnvironmentRecord: {         
        Type: "Object",            // 物件環境記錄
        // let const建立的變數a b在這
        a:  uninitialized ,  
        b:  uninitialized ,  
        multiply: < func >  
      }
      outer: null                // 全域性環境外部環境引入為null
    },

    VariableEnvironment: {         // 變數環境
      EnvironmentRecord: {         
        Type: "Object",            // 物件環境記錄
        // var建立的c在這
        c: undefined,  
      }
      outer: null                // 全域性環境外部環境引入為null
    }  
  }

// 函式執行上下文
FunctionExectionContext = {
  ThisBinding: Global Object, //由於函式是預設呼叫 this繫結同樣是全域性物件
  LexicalEnvironment: {          // 詞法環境
    EnvironmentRecord: {         
      Type: "Declarative",       // 宣告性環境記錄
      // arguments物件在這
      Arguments: {0: 20, 1: 30, length: 2},  
    },  
    outer: GlobalEnvironment    // 外部環境引入記錄為Global
  },

  VariableEnvironment: {          // 變數環境
    EnvironmentRecord: {          
      Type: "Declarative",        // 宣告性環境記錄
      // var建立的g在這
      g: undefined  
    },  
    outer: GlobalEnvironment    // 外部環境引入記錄為Global
  }  
}

這會引發我們另外一個思考,那就是變數提升的原因:我們會發現在建立階段,程式碼會被掃描並解析變數和函式宣告,其中let 和 const 定義的變數沒有任何與之關聯的值,會保持未初始化的狀態,但 var 定義的變數設定為 undefined。所以這就是為什麼可以在宣告之前,訪問到 var 宣告的變數(儘管是 undefined),但如果在宣告之前訪問 let 和 const 宣告的變數就會報錯的原因,也就是常說的暫時性死區,

在執行上下文建立階段,函式宣告與var宣告的變數在建立階段已經被賦予了一個值,var宣告被設定為了undefined,函式被設定為了自身函式,而let const被設定為未初始化。這是因為執行上下文建立階段JS引擎對兩者初始化賦值不同。

執行階段

上下文除了建立階段外,還有執行階段,程式碼執行時根據之前的環境記錄對應賦值,比如早期var在建立階段為undefined,如果有值就對應賦值,像let const值為未初始化,如果有值就賦值,無值則賦予undefined。

執行上下文總結

  1. 全域性執行上下文一般由瀏覽器建立,程式碼執行時就會建立;函式執行上下文只有函式被呼叫時才會建立,同一個函式被多次呼叫,都會建立一個新的上下文。

  2. 呼叫棧用於存放所有執行上下文,滿足FILO特性。

  3. 執行上下文建立階段分為繫結this,建立詞法環境,變數環境三步,兩者區別在於詞法環境存放函式宣告與const let宣告的變數,而變數環境只儲存var宣告的變數。

  4. 詞法環境主要由環境記錄與外部環境引入記錄兩個部分組成,全域性上下文與函式上下文的外部環境引入記錄不一樣,全域性為null,函式為全域性環境或者其它函式環境。環境記錄也不一樣,全域性叫物件環境記錄,函式叫宣告性環境記錄。

  5. ES3之前的變數物件與活動物件的概念在ES5之後由詞法環境,變數環境來解釋,兩者概念不衝突,後者理解更為通俗易懂。

閉包

上文說了這麼多,其實我本意只是想聊一聊閉包的,終於迴歸正題。

閉包是什麼?

MDN 對閉包的定義簡單理解就是閉包是由函式以及宣告該函式的詞法環境組合而成的。該環境包含了這個閉包建立時作用域內的任何區域性變數(閉包維持了一個對它的詞法環境的引用:在一個函式內部定義的函式,會將外部函式的活躍物件新增到自己的作用域鏈中)。所以可以在一個內層函式中訪問到其外層函式的作用域。在 JavaScript 中,每當建立一個函式,閉包就會在函式建立的同時被建立出來。

人們常說的閉包無非就是:函式內部返回一個函式,一是可以讀取並操作函式內部的變數,二是可以讓這些變數的值始終儲存在記憶體中。

而在《JavaScript權威指南》中講到:從理論的角度講,所有的JavaScript函式都是閉包。

  1. 從理論角度:所有的函式。因為它們都在建立時儲存了上層上下文的資料。哪怕是簡單的全域性變數也是如此,因為函式中訪問全域性變數就相當於是在訪問自由變數,這個時候使用最外層的作用域。
  2. 從實踐角度:閉包無非滿足以下兩點:
    • 閉包首先得是一個函式。
    • 閉包能訪問外部函式作用域中的自由變數,即使外部函式上下文已銷燬。(也可以理解為是自帶了執行環境的函式)

閉包的形成與實現

上文中介紹過JavaScript是採用詞法作用域的,講的是函式的執行依賴於函式定義的時候所產生的變數作用域。為了去實現這種詞法作用域,JavaScript函式物件的內部狀態不僅包含函式邏輯的程式碼,還包含當前作用域鏈的引用。函式物件可以通過這個作用域鏈相互關聯起來,函式體內部的變數都可以儲存在函式的作用域內

let scope = "global scope";
function checkscope() {
    let scope = "local scope";   // 自由變數
    function f() {    // 閉包
        console.log(scope);
    };
    return f;
};

let foo = checkscope();
foo();
// 1. 虛擬碼分別表示執行棧中上下文的變化,以及上下文建立的過程,首先執行棧中永遠都會存在一個全域性執行上下文。
ECStack = [GlobalExecutionContext];

// 2. 此時全域性上下文中存在兩個變數scope、foo與一個函式checkscope,上下文用虛擬碼表示具體是這樣:
GlobalExecutionContext = {     // 全域性執行上下文
    ThisBinding: Global Object  ,
    LexicalEnvironment: {      // 詞法環境
        EnvironmentRecord: {
            Type: "Object",    // 物件環境記錄
            scope: uninitialized ,
            foo: uninitialized ,
            checkscope: func 
        }
        outer: null   // 全域性環境外部環境引入為null
    }
}
// 3. 全域性上下文建立階段結束,進入執行階段,全域性執行上下文的識別符號中像scope、foo之類的變數被賦值,然後開始執行checkscope函式,於是一個新的函式執行上下文被建立,並壓入執行棧中:
ECStack = [checkscopeExecutionContext,GlobalExecutionContext];

// 4. checkscope函式執行上下文進入建立階段:
checkscopeExecutionContext = {      // 函式執行上下文
    ThisBinding: Global Object,
    LexicalEnvironment: {           // 詞法環境
        EnvironmentRecord: {
            Type: "Declarative",    // 宣告性環境記錄
            Arguments: {},
            scope: uninitialized ,
            f: func 
        },
        outer: GlobalLexicalEnvironment   // 外部環境引入記錄為<Global>
    }
}

// 5. checkscope() 等同於window.checkscope() ,所以checkExectionContext 中this指向全域性,而且外部環境引用outer也指向了全域性(作用域鏈),其次在識別符號中記錄了arguments物件以及變數scope與函式f
// 6. 函式 checkscope 執行到返回函式 f 時,函式執行完畢,checkscope 的執行上下文被彈出執行棧,所以此時執行棧中又只剩下全域性執行上下文:
ECStack = [GlobalExecutionContext];

// 7. 程式碼foo()執行,建立foo的執行上下文,
ECStack = [fooExecutionContext, GlobalExecutionContext];

// 8. foo的執行上下文是這樣:
fooExecutionContext = {
    ThisBinding: Global Object ,
    LexicalEnvironment: {             // 詞法環境
        EnvironmentRecord: {
            Type: "Declarative",      // 宣告性環境記錄
            Arguments: {},
        },
        outer: checkscopeEnvironment  // 外部環境引入記錄為<checkscope>
    }
}
// 9. foo()等同於window.foo(),所以this指向全域性window,但outer外部環境引入有點不同,指向了外層函式 checkscope(原因是JS採用詞法作用域,也就是靜態作用域,函式的作用域在定義時就確定了,而不是執行時確定)
/**
 * a. 但是可以發現的是,現在執行棧中只有 fooExecutionContext 和 GlobalExecutionContext, checkscopeExecutionContext 在執行完後就被釋放了,怎麼還能訪問到 其中的變數?
 * b. 正常來說確實是不可以,但是因為閉包 foo 外部環境 outer 的引用,從而讓 checkscope作用域中的變數依舊存活在記憶體中,無法被釋放,所以有時有必要手動釋放自由變數。
 * c. 總結一句,閉包是指能使用其它作用域自由變數的函式,即使作用域已銷燬。
 */

閉包有什麼用?

說閉包聊閉包,結果閉包有啥用都不知道,甚至遇到了一個閉包第一時間都沒反應過來這是閉包,說說閉包有啥用:

1.模擬私有屬性、方法

所謂私有屬性方法其實就是這些屬性方法只能被同一個類中的其它方法所呼叫,但是JavaScript中並未提供專門用於建立私有屬性的方法,但可以通過閉包模擬它:
私有方法不僅有利於限制對程式碼的訪問:還提供了管理全域性名稱空間的強大能力,避免非核心的方法弄亂了程式碼的公共介面部分。
例一:通過自執行函式返回了一個物件,只建立了一個詞法環境,為三個閉包函式所共享:fn.incrementfn.decrementfn.value,除了這三個方法能訪問到變數privateCounterchangeBy函式外,無法再通過其它手段操作它們。

let fn = (function () {
  var privateCounter = 0;

  function changeBy(val) {
    privateCounter += val;
  };
  return {
    increment: function () {
        changeBy(1);
    },
    decrement: function () {
        changeBy(-1);
    },
    value: function () {
        console.log(privateCounter);
    }
  };
})();
fn.value();     // 0
fn.increment();
fn.increment();
fn.value();     // 2
fn.decrement();
fn.value();     // 1

例二:建構函式中也有閉包:

function Echo(name) {
  var age = 26;       // 私有屬性
  this.name = name;   // 構造器屬性
  this.hello = function () {
      console.log(`我的名字是${this.name},我今年${age}了`);
  };
};
var person = new Echo('yya');
person.hello();    //我的名字是yya,我今年26了

若某個屬性方法在所有例項中都需要使用,一般都會推薦加在建構函式的原型上,還有種做法就是利用私有屬性。比如這個例子中所有例項都可以正常使用變數 age,將age稱為私有屬性的同時,也會將this.hello稱為特權方法,因為只有通過這個方法才能訪問被保護的私有屬性age。

2.工廠函式

使用函式工廠建立了兩個新函式 — 一個將其引數和 5 求和,另一個和 10 求和。 add5 和 add10 都是閉包。它們共享相同的函式定義,但是儲存了不同的詞法環境。在 add5 的環境中,x 為 5。在 add10 中,x 則為 10。
利用了閉包自帶執行環境的特性(即使外層作用域已銷燬),僅僅使用一個形參完成了兩個形參求和。當然例子函式還有個更專業的名詞,叫函式柯里化。

function makeAdder(x) {
    return function (y) {
        console.log(x + y);
    };
};

var add5 = makeAdder(5);
var add10 = makeAdder(10);
add5(2); // 7
add10(2); // 12

閉包對效能和記憶體的影響

  1. 閉包會額外附帶函式的作用域(內部匿名函式攜帶外部函式的作用域),會比其它函式多佔用些記憶體空間,過度的使用可能會導致記憶體佔用的增加。
  2. 閉包中包含與函式執行上下文相同的作用域鏈引用,因此會產生一定的負面作用,當函式中活躍物件和執行上下文銷燬時,由於閉包仍存在對活躍物件的引用,導致活躍物件無法銷燬,可能會導致記憶體洩漏。
  3. 閉包中如果存在對外部變數的訪問,會增加識別符號的查詢路徑,在一定的情況下,也會造成效能方面的損失。解決此類問題的辦法:儘量將外部變數存入到區域性變數中,減少作用域鏈的查詢長度。

綜上所述:如果不是某些特定任務需要使用閉包,在其它函式中建立函式是不明智的,因為在處理速度和記憶體消耗方面對指令碼效能具有負面影響。

瞭解了JS引擎的工作機制之後,我們不能只停留在理解概念的層面,而要將其作為基礎工具,用以優化和改善我們在實際工作中的程式碼,提高執行效率,產生實際價值才是我們真正的目的。就拿變數查詢機制來說,如果程式碼巢狀很深,每引用一次全域性變數,JS引擎就要查詢整個作用域鏈,比如處於作用域鏈的最底端window和document物件就存在這個問題,因此我們圍繞這個問題可以做很多效能優化的工作,當然還有其他方面的優化,此處不再贅述,如果有幫到你,點個贊再走吧~~~

相關文章