深入理解執行上下文、作用域鏈和閉包

XFY發表於2018-11-02

1. 變數物件和堆記憶體

變數物件是生成執行上下文時建立的一個特殊的物件。JS的基礎資料型別(number,string,boolean,undefined),按值訪問,通常儲存在變數物件中。JS的引用資料型別(如物件、陣列、函式, new Number(4))的值儲存在堆記憶體中,而JS不允許直接訪問堆記憶體,只能通過引用訪問。引用其實就是儲存在變數物件中的一個地址指標,這個地址指向堆記憶體中的實際值。
?:

var a = 0         // 變數a和值0都寸與變數中物件 
var b = 'string'  // 變數b和值0都寸與變數中物件 
var c = null      // 變數c和值0都寸與變數中物件 
var d = { m: 1 }  // 變數d存在於變數物件中,{m: 1} 作為物件存在於堆記憶體中 
var e = [1, 2, 3] // 變數e存在於變數物件中,[1, 2, 3] 作為物件存在於堆記憶體中
var f = function () {...}   // 變數f存在於變數物件中,function作為物件存在於堆記憶體中
複製程式碼

記憶體儲存方式示意圖:

深入理解執行上下文、作用域鏈和閉包

訪問引用資料型別時,實際上是先從變數物件中獲取地址,再根據地址從堆記憶體中取值。

?:

// demo1
var a = 20;
var b = a;
b = 30;
console.log(a)     // 20
複製程式碼
// demo2
var m = { a: 10, b: 20 }
var n = m;
n.a = 15;
console.log(m.a)   // 15
複製程式碼

2. 執行上下文

執行上下文可以理解為程式碼的執行環境,會形成一個作用域。JS主要有兩種執行上下文:

  • 全域性執行上下文:JS程式碼執行起來會首先進入全域性執行上下文
  • 函式執行上下文:當函式被呼叫執行時,會進入當前函式的執行上下文

JS程式碼通常是函式內呼叫函式,必然產生多個執行上下文,JS引擎會以棧(FILO)的方式來處理他們,這個棧就是函式呼叫棧(callstack)。函式呼叫棧棧底定是全域性執行上下文,棧頂是當前正在執行的函式的執行上下文。棧頂的上下文執行完之後,該上下文出棧。
?:

function fn1 () {
  var a = 1
    
  function fn2 () {
    const b = 2
    console.log(a + b)
  }

  fn2()
}
fn1()
複製程式碼

Call Stack示意圖:

深入理解執行上下文、作用域鏈和閉包

瀏覽器檢視程式碼執行過程的call stack:

深入理解執行上下文、作用域鏈和閉包

深入理解執行上下文、作用域鏈和閉包

深入理解執行上下文、作用域鏈和閉包

3. 變數物件(Variable Object)

本節探討的時生成執行上下文的時候都做了什麼。 執行上下文生命週期:

深入理解執行上下文、作用域鏈和閉包

執行上下文的建立階段其實就是為程式碼執行做準備,準備工作就包含了生成變數物件。 變數物件的生成過程包含以下三個步驟。

深入理解執行上下文、作用域鏈和閉包

  1. 建立arguments物件。
  2. 檢查函式宣告(function):在變數物件中以函式名建立一個屬性,屬性值為指向該函式所在記憶體地址的引用。如果函式名的屬性已經存在,那麼該屬性將會被新的引用所覆蓋。
  3. 檢查變數宣告(var):每找到一個變數宣告,就在變數物件中以變數名建立一個屬性,屬性值為undefined。如果該變數名的屬性已經存在,為了防止同名的函式被修改為undefined,則會直接跳過,原屬性值不會被修改。
  • 注意:函式宣告比變數宣告優先順序高。

建立完成之後在程式碼執行階段,JS解析器就能在變數物件中找到宣告的變數或者函式,進行一系列的操作。現在明白變數提升咋個回事兒了吧。

?:

function fn1 () {
  console.log(a)       // undefined
  console.log(fn2)     // f fn2(){}
  console.log(fn2())   // 2

  var a = 1
  function fn2 () {
    return 2
  }

  console.log(a)      // 1
}

fn1()
複製程式碼

建立階段:fn1執行上下文,建立階段生成變數物件

fn1EC = {
  VO: {
    arguments: { ... },
    fn1: <fn1 reference>,
    a: undefined
  }
  ...
}
複製程式碼

執行階段: VO --> AO(Active Object)

fn1EC = {
  AO: {
    arguments: { ... },
    fn1: <fn1 reference>,
    a: 1
  }
  ...
}
複製程式碼

執行階段變數物件變為活動物件,可以訪問屬性了,上面程式碼相當於

function fn1 () {
  function fn2 () {
    return 2
  }
  var a

  console.log(a)      // undefined
  console.log(fn2)    // f fn2(){}
  console.log(fn2())  // 2

  a = 1
  console.log(a)      // 1
}

fn1()
複製程式碼
  • 注:全域性上下文VO = window

4. 作用域鏈

第二節講到在執行上下文的建立階段,有三個任務:建立變數物件、建立作用域鏈、明確this指向。 本節講作用域鏈。

作用域鏈,是由當前環境與上層環境的一系列變數物件組成,它保證了當前執行環境對符合訪問許可權的變數和函式的有序訪問.

?:

var a = 1

function fn1 () {
  var b = a + 1
  var c = 3

  function fn2 () {
    var c = 4
    return b + c   // 6
  }
    
  fn2()
}

fn1()
複製程式碼

我們知道,fn1能夠訪問全域性變數a,fn2能夠訪問fn1中的變數b、c,但是c使用的是本作用域的c,反過來fn1不能訪問fn2種的變數c,JS引擎是如何實現變數的查詢?答案就是:沿著作用域鏈查詢

fn2 Scope Chain:

深入理解執行上下文、作用域鏈和閉包

5. 閉包

JS的函式外部無法讀取到函式內的區域性變數:

function fn1() {
  var a = 1
  console.log(a)
}
console.log(a)  // error
複製程式碼

如何從外部讀取區域性變數:

var a = 1
          
function fn1 () {
  var b = a + 1

  function fn2 () {
    console.log(b)  // 2
    console.log(c)  // error: c is not defined
  }

  return fn2
}

var res = fn1()
function fn3() {
  var c = 3
  res()
}

fn3()
複製程式碼

在函式fn1內部定義一個函式fn2,fn2內訪問fn1的變數,並把fn2作為返回值,在外部執行fn2的時候,就訪問到fn1的區域性變數了。 這就形成了閉包,有些地方稱fn1是閉包,有的稱fn2為閉包, 我們和chrom保持一致,稱父函式fn1為閉包。

閉包可以理解為集中技巧,使得在函式外部能夠訪問函式內部的變數,且這些變數的值始終儲存在記憶體中。例子中,fn1是fn2的父函式,而fn2被賦給了一個全域性變數,這導致fn2始終在記憶體中,而fn2的存在依賴於fn1,因此fn1也始終在記憶體中,不會在呼叫結束後,被垃圾回收機制回收。

此時:fn2 ScopeChain = [fn2 VO, fn1 VO, global VO], 因此fn2能訪問b, 但不能訪問c, fn1執行完了,fn1已經出棧,但是fn1並沒有被釋放。

所以,使用閉包會造成記憶體佔用較大。

練習:

for (var i=0; i<5; i++) {
  setTimeout( function () {
    console.log(i);
  }, i*1000 );
}
複製程式碼

輸出什麼??

如何改動實現輸出1,2,3,4,5: 每次迴圈將i值儲存到了閉包中:

for (var i=0; i<5; i++) {
  setTimeout((function (i) {
    function () {
      console.log(i);
    }, i*1000 )
  }(i)
}
複製程式碼

閉包最大的應用就是模組化。


菜鳥一隻,若有不對,歡迎並感謝大家指正?。

參考文章如下(侵刪)
  1. www.ruanyifeng.com/blog/2009/0…
  2. www.jianshu.com/p/330b1505e…
  3. www.jianshu.com/p/21a16d44f…

相關文章