1. 程式人生 > >從執行上下文深入理解閉包

從執行上下文深入理解閉包

1.概念

關於閉包的定義我看到過好多個版本,這裡簡單的列舉一下:
MDN:包是函式和宣告該函式的詞法環境的組合。(PS:個人理解詞法環境就是變數物件)
Tyler McGinnis:子函式在其父級函式的變數環境上“關閉”(譯者注:原文為a child function “closing” over the variable environment of its parent function)的概念,就叫做閉包。
w3school:閉包,指的是詞法表示包括不被計算的變數的函式,也就是說,函式可以使用函式之外定義的變數。
阮一峰:他的理解是,閉包就是能夠讀取其他函式內部變數的函式。由於在Javascript語言中,只有函式內部的子函式才能讀取區域性變數,因此可以把閉包簡單理解成"定義在一個函式內部的函式"。

一次性搞懂 JavaScript 閉包 —— 簡書:閉包簡單來說就是一個函式訪問了它的外部變數。

還有《JavaScript高階程式語言》,《JavaScript權威指南》親有沒有發現每個人感覺都給閉包有一個定義,如果你是一個小白你一定和我一樣的鬱悶( ˇˍˇ )。
最近了解了一下JavaScript執行上下文之後才突然發現這麼多概念原來說的其實都是一件事。如果你看了之後和我當初一樣,希望我下面的內容可以幫助你,進入正題。

2.執行上下文(或者叫作用域)

執行上下文是用來幫助Javascript引擎管理整個解析和執行程式碼的複雜過程。那麼現在我們瞭解了執行上下文的存在目的,下一個問題就是執行上下文是怎麼建立的?它們由什麼組成?

概念:當且僅當Javascript引擎首次開始解析程式碼(對應全域性執行上下文)或當一個函式被呼叫時,才會建立執行上下文。

全域性執行上下文:當Javascript引擎執行程式碼,第一個被建立的執行上下文叫做“全域性執行上下文”。最初,這個全域性上下文由這二位組成:一個全域性物件和一個this變數。this引用的是全域性物件,如果在瀏覽器中執行Javascript,那麼這個全域性物件就是window物件,如果在Node環境中執行,這個全域性物件就是global物件。 在全域性執行上下文的建立階段,Javascript引擎會:

  1. 建立一個全域性物件;
  2. 建立this物件,指向window;
  3. 給函式分配記憶體;
  4. 給變數分配記憶體;
  5. 給變數賦預設值undefined,把所有函式宣告放進記憶體。

函式執行上下文:當函式被呼叫,它就被創建出來了。函式執行上下文中應該建立的應該是arguments物件,所以當建立函式執行上下文時,Javascript引擎會:

  1. 1.建立一個全域性物件
  2. 建立一個arguments物件;
  3. 建立this物件,指向函式呼叫物件;
  4. 給函式分配記憶體;
  5. 給變數(包括內部定義的變數和引數變數)分配記憶體;
  6. 給變數賦預設值undefined,把所有函式宣告放進記憶體。

關於變數物件的建立有什麼疑問可以看看JavaScript深入之變數物件

舉個栗子:我們來說明一下:
實際操作

這裡有幾處重要細節需要注意。首先,傳入函式的所有引數都作為區域性變數存在於該函式的執行上下文中。在例子中,handle同時存在與全域性執行上下文和getURL執行上下文中,因為我們把它傳入了getURL函式做為引數。其次,在函式中宣告的變數存在於函式的執行上下文中。

作用域鏈:Javascript中一切皆物件,這些物件有一個[[Scope]]屬性,該屬性包含了函式被建立時的作用域中物件的集合,這個集合被稱為函式的作用域鏈(Scope Chain),它決定了哪些資料能被函式訪問。當函式建立的時候,它的[[scope]]屬性自動新增好全域性作用域。之所以要強調建立是因為JavaScript採用詞法作用域(lexical scoping),也就是靜態作用域.

舉個栗子:我們來通過簡單的程式碼說明一下作用域鏈:
實際操作

function a () {
  console.log('In fn a')
  function b () {
    console.log('In fn b')
    function c () {
      console.log('In fn c')
    }
    c()
  }
  b()
}

a()
複製程式碼

從圖中可以清楚的發現在函式的執行過程中,最開始建立了一個全域性執行上下文,然後沒執行一個函式就會建立一個函式執行上下文,當開始執行函式 C() 的時候 C 函式有一個[[scope]]屬性,裡面的值會是:

//c的作用域鏈
[
 0:{
     arguments:{length:0},
     this:window
 },
 1:{
     arguments:{length:0},
     this:window,
     c:fn()
 }
 2:{
     arguments:{length:0},
     this:window,
     b:fn()
 },
 3:{
     this:window,
     a:fn()
 }
]
複製程式碼

細心觀察你會發現每個函式執行完之後,每個函式的執行上下文會消失,事實上,Javascript引擎建立了一個叫“執行棧”(也叫呼叫棧)的東西。每當函式被呼叫,就建立一個新的執行上下文並把它加入到呼叫棧;每當一個函式執行完畢,就被從呼叫棧中彈出來。所以“通常情況下”函式執行完畢後函式的執行上下文就會消失,閉包就是不是“通常情況下”。

問題來了什麼叫做詞法作用域(也可以說靜態作用域)?

我們在舉個栗子:

var value = 1;

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

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

bar();

// 結果是 ???
複製程式碼

答案是 1 (小夥伴你答對了麼?)
解釋:因也很簡單,因為JavaScript採用的是詞法作用域(如果不明白可以看看《JavaScript深入之詞法作用域和動態作用域》),函式的作用域基於函式建立的位置。函式foo() 定義在全域性作用域下,當列印value時沿著作用於鏈查詢就找到了全域性執行上下文,而不是bar函式執行上下文。所以結果是1。

而引用《JavaScript權威指南》的回答就是: JavaScript 函式的執行用到了作用域鏈,這個作用域鏈是在函式定義的時候建立的。巢狀的函式 f() 定義在這個作用域鏈裡,其中的變數 scope 一定是區域性變數,不管何時何地執行函式 f(),這種繫結在執行 f() 時依然有效。

3.閉包

閉包就是不是“通常情況下”,如果你在一個函式中嵌入了另一個函式,並且讓外部一個指標引用內部的函式,例外情況就產生了。這種函式套函式的情況下,即使父級函式的執行上下文從呼叫棧彈出了,子級函式仍然能夠訪問父級函式的作用域。
實際操作

makeAdder執行上下文從呼叫棧彈出後,Javascript Visualizer建立了一個Closure Scope(閉包作用域)。Closure Scope中的變數環境和makeAdder執行上下文中的變數環境相同。這是因為我們在函式中嵌入了另一個函式。在本例中,inner函式嵌在makeAdder中,所以inner在makeAdder變數環境的基礎上建立了一個閉包。因為閉包作用域的存在,即使makeAdder已經從呼叫棧彈出了,inner仍然能夠訪問到x變數(通過作用域鏈)。

現在是不是感覺自己明白了一點什麼是閉包呢?反正閉包的定義我還下不了,但是我還是要粗略的表達一下我自己的想法就是:閉包就是使一個函式作為另一個函式的返回,從而達到內部函式可以讀取外部函式內部的變數和讓外部函式中的變數的值始終保持在記憶體中的作用的一個寫法。(PS.不知道大家可不可以接受,不喜勿噴!!!)

4.小檢驗

function func() {
    var arr = [];
    for(var i = 0;i<3;i++){
        arr.push(()=> {
            console.log(i);
        })
    }
    return arr
}
var result = func();
result.forEach((item)=> {
    item();
})
複製程式碼

輸出結果是:3 3 3 有沒有答對呢 ?
解釋:首先在func函式執行上下文中建立了 i 變數(這裡涉及到變數提升的知識,不瞭解自己可以看一下),當執行匿名的函式要console.log(i)的時候發現在改匿名函式的執行上下文沒有這個變數,則沿著作用域鏈向上查詢,發現在func的作用域中有i,這個i的值是3(for迴圈最後結束後i記錄為3)。

我們使用閉包進行如下修改,親,你再猜猜?

function func() {
    var arr = [];
    for(var i = 0;i<3;i++){
        (function(){
            arr.push(()=> {
                console.log(i);
            })
        })()
    }
    return arr
}
var result = func();
result.forEach((item)=> {
    item();
})
複製程式碼

答案是 3 3 3,解釋和上面的一樣。如果你想輸出 0 ,1 ,2 有兩種方案:閉包和使用let

//方案一
function func() {
    var arr = [];
    for(var i = 0;i<3;i++){
        (function(i){
            arr.push(()=> {
                console.log(i);
            })
        })(i)
    }
    return arr
}
var result = func();
result.forEach((item)=> {
    item();
})
//輸出 0 1 2

//方案二
function func() {
    var arr = [];
    for(let i = 0;i<3;i++){
        arr.push(()=> {
            console.log(i);
        })
    }
    return arr
}
var result = func();
result.forEach((item)=> {
    item();
})
//輸出 0 1 2
複製程式碼

5.使用閉包的注意點

  1. 由於閉包會使得函式中的變數都被儲存在記憶體中,記憶體消耗很大,所以不能濫用閉包,否則會造成網頁的效能問題,在IE中可能導致記憶體洩露。解決方法是,在退出函式之前,將不使用的區域性變數全部刪除。

  2. 閉包會在父函式外部,改變父函式內部變數的值。所以,如果你把父函式當作物件(object)使用,把閉包當作它的公用方法(Public Method),把內部變數當作它的私有屬性(private value),這時一定要小心,不要隨便改變父函式內部變數的值。

6.閉包執行機制

思考題:
程式碼一

var name = "The Window";
  var object = {
    name : "My Object",

    getNameFunc : function(){
      return function(){
        return this.name;
      };

    }
  };
  alert(object.getNameFunc()());//The Window
複製程式碼

程式碼二

var name = "The Window";
  var object = {
    name : "My Object",

    getNameFunc : function(){
      var that=this;
      return function(){
        return that.name;
      };

    }
  };
  alert(object.getNameFunc()());//My Object
複製程式碼

javascript 中this的定義:就是上下文物件,即被呼叫函式所處的環境,也就是說,this 在函式內部指向了呼叫函式的物件。如果沒有搞懂就去研究一下javascript的this吧

7.引用

  1. 【譯】終極指南:變數提升、作用域和閉包
  2. 閉包的錯誤使用
  3. 學習Javascript閉包(Closure)--阮一峰
  4. 一次性搞懂JavaScript閉包--簡書
  5. 高效使用 JavaScript 閉包
  6. JavaScript深入之詞法作用域和動態作用域
  7. JavaScript深入之變數物件

結束語

後面發現好的閉包的內容我還會加進來,如果有什麼不對的地方歡迎指正。