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


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

今天看到一篇關於閉包的文章,里面有這樣一句話 “就我而言對於閉包的理解僅止步於一些概念,看到相關代碼知道這是個閉包,但閉包能解決哪些問題場景我了解的並不多”,這說的不就是我么,每每在面試中被問及什么是閉包,大部分情況下得到的答復是(至少我以前是)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對象就存在這個問題,因此我們圍繞這個問題可以做很多性能優化的工作,當然還有其他方面的優化,此處不再贅述,如果有幫到你,點個贊再走吧~~~


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM