從 Closure 更深入理解 JavaScript 底層運作機制


Posted by Christy on 2021-10-03

前言:

這次學 Closure 時,變成先看懂文章,才懂影片,跟上次學 hoisting 的狀況不太一樣。

其實學閉包重點在於「理解作用域」跟「整個底層運作」,關於閉包老師舉了幾個常見錯誤(按鈕點擊只出現最後一個數、迴圈裡面的 var -> let)及實際運用(隱藏變數保護錢包)等等,理論我懂了,實作要多多運用才行。

因此先放上心得、結論與名詞解釋,接著是閱讀文章的筆記,最後才是影片內容的筆記。


以下為閱讀 所有的函式都是閉包:談 JS 中的作用域與 Closure 的筆記

以下談到了

  1. 結論:掌握三個重點,了解底層運作時,哪個步驟會產生什麼

  2. 閱讀文章的筆記:作用域、閉包、跟著胡立一起看規格書內容、一起跑一遍程式碼(稍微看一下回收機制)

  3. 附上有出現的 ECMA ES3 原文段落

一、先說結論:

1. 心得:

這裡被一些專有名詞迷惑了,像是「宣告」、「呼叫」、「執行」函式,其實是三個不同的步驟;ECMAScript ES3 的 attributes & property 非常難譯,只能說往後更了解一點,也許會找到更好的中文來解釋吧。

2. 名詞解釋:

a. scope: 作用域

b. static scope 又稱 lexical scope: 靜態作用域

c. scope chain: 作用域鏈

d. EC: execution context 執行環境

e. VO: variable object 一個給 global 存資料的物件

f. AO: activation object 給函式存資料的物件

g. Identifier: 像是名字的感覺,把它想成 id 好一點

h. property: 特性?特質?

i. attribute: 屬性

個人理解:property 感覺比較像特質,attribute 像是分類;例如說,一個人的個性可以分成內向、外向;而內向裡面又有「真的超級安靜」跟「其實熟了也可以很多話」這兩類

這裡把內向與外向兩種特質比喻為 property;內向裡面的兩種分類比喻為 attribute

javascript那些事儿——properties和attributes

3. 重點整理:

a. 宣告一個函式時,會產生一個作用域

這樣就叫「宣告」,但不會呼叫,也不會執行這個函式

function test() {
  var a = 10
  console.log(a)
}

// 作用域誕生了!
test.[[Scope]] = globalEC.scopeChain

PS 此時的作用域會是從 global 來的,可以看閱讀文章筆記裡面有詳解

b. 呼叫一個函式時,就會建立一個執行環境及一個給函式存資料的物件,並且初始化這個物件

function test() {
  var a = 10
  console.log(a)
}

test() // 這裡就是呼叫

// 設定長這樣
testEC: {
  AO: {
    a: undefined
  }
}
test.[[Scope]] = globalEC.scopeChain

c. 執行一個函式時,就會進入那個執行環境並且新增一個作用域鏈

function test() {
  var a = 10
  console.log(a)
}

test() // 這裡就是呼叫

// 作用域鏈誕生,變數值不一樣了
testEC: {
  AO: {
    a: 10
  }
  scopeChain: globalEC.scopeChain
}

d. 我覺得把握以上三點以後,去看執行過程,再理解整個規格書內容就會更清楚了


二、閱讀文章的筆記:

1. 靜態作用域 static scope 又稱 lexical scope

js 的靜態作用域在函式被宣告時就已經決定了。 因此下面程式碼輸出會是 100。

var a = 100
function echo() {
  console.log(a) // 100 or 200?
}

function test() {
  var a = 200
  echo()
}

test()

js 裡面的作用域叫做靜態作用域(static scope),可以用肉眼看出作用域是什麼,且不會被改變。

靜態作用域是在 function 被「宣告」的時候就決定了,而不是 function 被「執行」的時候。

如果一個程式語言是採動態作用域(dynamic scope)的話,那上面的範例 a 就會是 200。echo 裡面的 a 是在執行時被動態決定的。

PS 雖然 js 採的是靜態作用域,但 this 的原理跟動態作用域有點類似。(恩,把物件導向學完,就會學到 this 了)

2. 閉包(Closure)

定義:閉包是一種環境,裡面有對照的自由變數跟該變數的函式。

感覺閉包像是一部影片,把之前函式裡面發生的事情錄下來(?)

3. 10.1.4 Scope Chain and Identifier Resolution

來看看 ECMAScript ES3 裡面怎麼描寫作用域鏈吧:

每個執行環境(EC)都有一個作用域鏈,進入 EC 時,作用域鏈會被建立。

Every execution context has associated with it a scope chain. A scope chain is a list of objects that are searched when evaluating an Identifier. When control enters an execution context, a scope chain is created and populated with an initial set of objects, depending on the type of code.

每個 EC 都會連結到一個作用域鏈,當想要知道一個 Identifier 時,就會到作用域鏈裡面的物件清單搜尋。當函式被呼叫時,會進入 EC,而作用域鏈此時產生,並且初始化裡面的物件。

4. 作用域鏈裡面有什麼?

10.2.3 Function Code

裡面有 AO 跟一個叫 [[Scope]] 的 property

The scope chain is initialised to contain the activation object followed by the objects in the scope chain stored in the [[Scope]] property of the Function object.

進入 EC 的作用域鏈,會被初始化,裡面會有 AO 以及一個叫 [[Scope]] 的 property

5. 什麼是 activation object 及 [[Scope]]?

10.1.6 Activation Object

先來看什麼是 AO?

When control enters an execution context for function code, an object called the activation object is created and associated with the execution context.

The activation object is initialised with a property with name arguments and attributes { DontDelete }

The activation object is then used as the variable object for the purposes of variable instantiation.

當進入 EC 時,AO 被建立且與 EC 相關連。

AO 被初始化為有參數及屬性的一個 property(真心太難譯了)

AO 在 VO 裡被使用於變數的實例化。

我:所以實例化是什麼意思啊?是說把一個物體變成可以使用、實體化的意思嗎?感覺像是把概念實體化?

小結:

AO 只出現在函式的執行環境裡面

AO 跟 VO 的差別:AO 有 arguments, VO 沒有

6. 什麼是 [[Scope]]?

13.2 Creating Function Objects

Given an optional parameter list specified by FormalParameterList, a body specified by FunctionBody, and a scope chain specified by Scope, a Function object is constructed as follows

(中間省略)

7.Set the [[Scope]] property of F to a new scope chain (10.1.4) that contains the same objects as Scope.

宣告函式時,會產生一個 scope,並且把它放到作用域鏈裡面

當執行一個函式時,產生一個 EC,這個 EC 裡面的作用鏈會有一個 AO 跟一個 scope

7. 所以實際上,到底這些東西是怎麼產生的呢?

先備知識:

a. 宣告函式時,就會產生一個 scope

b. 呼叫函式時,就會產生一個 EC 與 AO

c. 進入 EC (也就是開始執行函式時),scope chain 就會被建立

d. 這個 EC 裡的 scope chain = activation object + [[Scope]]

e. AO 只出現在函式的執行環境裡面,也就是說 global 用的是 VO

f. AO 跟 VO 的差別:AO 有 arguments, VO 沒有

就用底下範例來一步一步執行函式吧

var v1 = 10
function test() {
  var vTest = 20
  function inner() {
    console.log(v1, vTest) //10 20
  }
  return inner
}
var inner = test()
inner()

a. 進入 global EC,初始化 VO 及 scope chain。但因為 global 不是一個函式,所以沒有 scope 且也沒有 AO 只會有 VO。因此就把 VO 拿進 scope chain 裡。

globalEC = {
  VO: {
   v1: undefined,
   inner: undefined,
   test: function 
  },
  scopeChain: globalEC.VO
}

test 這個函式已經被宣告了,因此要設置 test 的 scope = globalEC.VO

b. 執行第一行,把 10 賦值給 v1;執行第九行,進入 test 函式,初始化 test EC & AO,記得加上 scope chain

老師寫的很清楚,直接用他的

testEC = {
  AO: {
    arguments,
    vTest: undefined,
    inner: function
  },
  scopeChain: 
    [testEC.AO, test.[[Scope]]]
  = [testEC.AO, globalEC.scopeChain]
  = [testEC.AO, globalEC.VO]
}

globalEC = {
  VO: {
   v1: 10,
   inner: undefined,
   test: function 
  },
  scopeChain: globalEC.VO
}

test.[[Scope]] = globalEC.scopeChain

接著設置 inner 函式的 scope

c. 執行第三行,把 20 賦值給 vTest,執行第七行,回傳 inner,test 函式執行結束。

testEC = {
  AO: {
    arguments,
    vTest: 20,
    inner: function
  },
  scopeChain: [testEC.AO, globalEC.VO]
}

globalEC = {
  VO: {
   v1: 10,
   inner: function,
   test: function 
  },
  scopeChain: globalEC.VO
}

inner.[[Scope]] = testEC.scopeChain = [testEC.AO, globalEC.VO]

當 test 被執行完以後,照理說資源要被回收才對,可是 inner 還記著 testEC.AO,所以沒有辦法這樣做。

這裡是第一次聽說回收機制,參考 js--閉包與垃圾回收機制,大致看一下,之後有遇到再回來複習。

d. 進入 inner EC,初始化 AO

innerEC = {
  AO: {
    arguments
  },
  scopeChain:
  [innerEC.AO, inner.[[Scope]]]
= [innerEC.AO, testEC.scopeChain]
= [innerEC.AO, testEC.AO, globalEC.VO]
}

testEC = {
  AO: {
    arguments,
    vTest: 20,
    inner: function
  },
  scopeChain: [testEC.AO, globalEC.VO]
}

globalEC = {
  VO: {
   v1: 10,
   inner: function,
   test: function 
  },
  scopeChain: globalEC.VO
}

inner.[[Scope]] = testEC.scopeChain = [testEC.AO, globalEC.VO]

e. 執行 inner 函式,執行第五行,但是在自己裡面找不到 v1, vTest 這兩個變數,因此往上層 testEC.AO 找到 vTest 為 20;再往上層 globalEC.VO 找到 v1 為 10,最後輸出 10, 20,inner 執行結束。


三、本文中有出現 ECMA ES3 的原文段落:

p38 Ch10 10.1.4

10.1.4 Scope Chain and Identifier Resolution

Every execution context has associated with it a scope chain. A scope chain is a list of objects that are searched when evaluating an Identifier. When control enters an execution context, a scope chain is created and populated with an initial set of objects, depending on the type of code. During execution within an execution context, the scope chain of the execution context is affected only by with statements (see 12.10) and catch clauses (see 12.14).
During execution, the syntactic production PrimaryExpression : Identifier is evaluated using the following algorithm:

  1. Get the next object in the scope chain. If there isn't one, go to step 5.

  2. Call the [[HasProperty]] method of Result(1), passing the Identifier as the property.

  3. If Result(2) is true, return a value of type Reference whose base object is Result(1) and whose property name is the Identifier.

  4. Go to step 1.

  5. Return a value of type Reference whose base object is null and whose property name is the Identifier.

The result of evaluating an identifier is always a value of type Reference with its member name component equal to the identifier string.

10.1.6 Activation Object

When control enters an execution context for function code, an object called the activation object is created and associated with the execution context. The activation object is initialised with a property with name arguments and attributes { DontDelete }. The initial value of this property is the arguments object described below.

The activation object is then used as the variable object for the purposes of variable instantiation.

The activation object is purely a specification mechanism. It is impossible for an ECMAScript program to access the activation object. It can access members of the activation object, but not the activation object itself. When the call operation is applied to a Reference value whose base object is an activation object, null is used as the this value of the call.

13.2 Creating Function Objects

Given an optional parameter list specified by FormalParameterList, a body specified by FunctionBody, and a scope chain specified by Scope, a Function object is constructed as follows:

  1. If there already exists an object E that was created by an earlier call to this section's algorithm, and if that call to this section's algorithm was given a FunctionBody that is equated to the FunctionBody given now, then go to step 13. (If there is more than one object E satisfying these criteria, choose one at the implementation's discretion.)

  2. Create a new native ECMAScript object and let F be that object.

  3. Set the [[Class]] property of F to "Function".

  4. Set the [[Prototype]] property of F to the original Function prototype object as specified in 15.3.3.1.

  5. Set the [[Call]] property of F as described in 13.2.1.

  6. Set the [[Construct]] property of F as described in 13.2.2.

  7. Set the [[Scope]] property of F to a new scope chain (10.1.4) that contains the same objects as Scope.

  8. Set the length property of F to the number of formal properties specified in FormalParameterList. If no parameters are specified, set the length property of F to 0. This property is given attributes as specified in 15.3.5.1.

  9. Create a new object as would be constructed by the expression new Object().

  1. Set the constructor property of Result(9) to F. This property is given attributes { DontEnum }.

  2. Set the prototype property of F to Result(9). This property is given attributes as specified in 15.3.5.2.

  3. Return F.

  4. At the implementation's discretion, go to either step 2 or step 14.

  5. Create a new native ECMAScript object joined to E and let F be that object. Copy all non-internal properties and their attributes from E to F so that all non internal properties are identical in E and F.

  6. Set the [[Class]] property of F to "Function".

  7. Set the [[Prototype]] property of F to the original Function prototype object as specified in 15.3.3.1.

  8. Set the [[Call]] property of F as described in 13.2.1.

  9. Set the [[Construct]] property of F as described in 13.2.2.

  10. Set the [[Scope]] property of F to a new scope chain (10.1.4) that contains the same objects as Scope.

  11. Return F.

NOTE

A prototype property is automatically created for every function, to allow for the possibility that the function will be used as a constructor.

Step 1 allows an implementation to optimise the common case of a function A that has a nested function B where B is not dependent on A. In this case the implementation is allowed to reuse the same object for B instead of creating a new one every time A is called. Step 13 makes this optimisation optional; an implementation that chooses not to implement it will go to step 2.

For example, in the code

function A() {
  function B(x) {return x*x;}
  return B;
}
  function C() {
  return eval("(function (x) {return x*x;})");
}
  var b1 = A();
  var b2 = A();
  function b3(x) {return x*x;}
  function b4(x) {return x*x;}
  var b5 = C();
  var b6 = C();

an implementation is allowed, but not required, to join b1 and b2. In fact, it may make b1 and b2 the same object because there is no way to detect the difference between their [[Scope]] properties. On the other hand, an implementation must not join b3 and b4 because their source codes are not equated (13.1.1). Also, an implementation must not join b5 and b6 because they were produced by two different calls to eval and therefore their source codes are not equated.

In practice it's likely to be productive to join two Function objects only in the cases where an implementation can prove that the differences between their [[Scope]] properties are not observable, so one object can be reused. By following this policy, an implementation will only encounter the vacuous case of an object being joined with itself.

Standard ECMA-262, 3r d Edition - December 1999


本篇為 [JS201] 進階 JavaScript:那些你一直搞不懂的地方筆記之三。

一、Closure 是什麼?

1. 閉包:在函式裡面回傳一個函式

function test() {
  var a = 10
  function inner() {
    a++
    console.log(a)
  }
  // 跟 return inner() 不同,這個是執行函式
  // 這裡是回傳一個函式
  return inner
}

// 這裡要有一個變數來接住函式 inner
var func = test()
func() // 呼叫這個函式

2. 從 ECMAScript 看作用域

a. 看一下規格書吧

10.1.4 Scope Chain and Identifier Resolution

Every execution context has associated with it a scope chain.

每個 EC 都有一個 scope chain

When control enters an execution context, a scope chain is created... 後略

當進入一個新的 EC,scope chain 就會被建立

10.2 Entering An Execution Context

When control enters an execution context, the scope chain is created and initialised, variable instantiation is performed, and the this value is determined.

當進入一個 EC,scope chain 被建立且被初始化

10.2.3 Function Code

The scope chain is initialised to contain the activation object followed by the objects in the scope chain stored in the [[Scope]] property of the Function object.

執行函式時(進入一個 EC),scope chain 會被初始化成 AO + [[Scope]] 這兩個東西

10.1.6 Activation Object

When control enters an execution context for function code, an object called the activation object is created and associated with the execution context. The activation object is initialised with a property with name arguments and attributes { DontDelete }.

執行函式時,AO 會被新增,裡面會有一個預設的屬性叫 arguments

The activation object is then used as the variable object for the purposes of variable instantiation.

AO 也可以被當作 VO 來用

b. 跑一個程式碼來理解

var a = 1
function test() {
  var b = 2
  function inner() {
    var c =3
    console.log(b)
    console.log(a)
  }
  inner()
}

test()

b.1 先進 global 產先這些 EC 與 VO,重點是 test scope 在這裡誕生了!

globalEC: {
  VO: {
    a: undefined,
    test: func
  },
  scopeChain: [globalEC.VO]
}

test.[[Scope]] = globalEC.scopeChain

b.2 再進 test,初始化完以後,又新增了 inner scope

testEC: {
  AO: {
    b: undefined,
    inner: func
  },
  scopeChain: [testEC.AO, test.[[Scope]]]
  = [testEC.AO, globalEC.VO]
}

inner.[[Scope]] = testEC.scopeChain
= [testEC.AO, globalEC.VO]

globalEC: {
  VO: {
    a: 1,
    test: func
  },
  scopeChain: [globalEC.VO]
}

test.[[Scope]] = globalEC.scopeChain

b.3 再進 inner,又產生了一個 inner scope chain

innerEC:{
  AO: {
    c: undefined,
  }, 
  scopeChain: [innerEC.AO, inner.[[Scope]]]
  = [innerEC.AO, testEC.scopeChain]
  = [innerEC.AO, testEC.AO, globalEC.VO]
}

testEC: {
  AO: {
    b: 2,
    inner: func
  },
  scopeChain: [testEC.AO, test.[[Scope]]]
  = [testEC.AO, globalEC.VO]
}

inner.[[Scope]] = testEC.scopeChain
= [testEC.AO, globalEC.VO]

globalEC: {
  VO: {
    a: 1,
    test: func
  },
  scopeChain: [globalEC.VO]
}

test.[[Scope]] = globalEC.scopeChain

小結:

scope 感覺是保存上一個人的東西,scope chain 就是自己的 AO 加上保存的 scope

3. 再次 cosplay JS 引擎

這個影片把程式碼全部跑過一遍,scope、scope chain、VO、AO、EC 整個運作說得非常清楚,推推。

閉包說穿了就是一開始在設 scope 的時候,保存了上一個函式的資料這樣。

有可能會保存到不想要保存的值,用閉包時要小心。

4. 日常生活中的作用域陷阱

比如說下面的函式,第零個是 5 而不是 0,可惡。

var arr = []
for (var i = 0; i < 5; i++) {
  arr[i] = function() {
    console.log(i)
  }
}

arr[0]() // 5

其實把 var 改成 let 就不會有這個問題了...

順便提到了 IIFE: Immediately invoked function expression 立即呼叫函式

把函式用小括號包起來,後面再給一個小括號,就會立刻執行這個函式

(function () {
  console.log(123)
})()

5. Closure 可以應用在哪裡?

想要隱藏某些資訊:把東西封裝在函式裡面,外部就沒辦法改了。










Related Posts

筆記、[ALG101] 看懂題目+寫程式

筆記、[ALG101] 看懂題目+寫程式

Python-常用檔案類型 & pandas資料處理

Python-常用檔案類型 & pandas資料處理

The Zen Programmer 程式設計之禪書摘

The Zen Programmer 程式設計之禪書摘


Comments