一道 JS 面試題引發的思考

Kuitos發表於2016-11-23

前陣子幫部門面試一前端,看了下面試題,其中有一道題是這樣的

首先A、B兩段程式碼輸出返回的都是 “local scope”,如果對這一點還有疑問的同學請自覺回去溫習一下js作用域的相關知識。。
那麼既然輸出一樣那這兩段程式碼具體的差異在哪呢?大部分人會說執行環境和作用域不一樣,但根本上是哪裡不一樣就不是人人都能說清楚了。前陣子就這個問題重新翻了下js基礎跟ecmascript標準,如果我們想要刨根問底給出標準答案,那麼我們需要先理解下面幾個概念:

變數物件(variable object)

原文:Every execution context has associated with it a variable object. Variables and functions declared in the source text are added as properties of the variable object. For function code, parameters are added as properties of the variable object.

簡言之就是:每一個執行上下文都會分配一個變數物件(variable object),變數物件的屬性由 變數(variable) 和 函式宣告(function declaration) 構成。在函式上下文情況下,引數列表(parameter list)也會被加入到變數物件(variable object)中作為屬性。變數物件與當前作用域息息相關。不同作用域的變數物件互不相同,它儲存了當前作用域的所有函式和變數。

這裡有一點特殊就是隻有 函式宣告(function declaration) 會被加入到變數物件中,而 **函式表示式(function expression)**則不會。看程式碼:

函式宣告的方式下,a會被加入到變數物件中,故當前作用域能列印出 a。
函式表示式情況下,a作為變數會加入到變數物件中,_a作為函式表示式則不會加入,故 a 在當前作用域能被正確找到,_a則不會。
另外,關於變數如何初始化,看這裡
image2015-3-10-13-20-41

關於Global Object
當js編譯器開始執行的時候會初始化一個Global Object用於關聯全域性的作用域。對於全域性環境而言,global object就是變數物件(variable object)。變數物件對於程式而言是不可讀的,只有編譯器才有權訪問變數物件。在瀏覽器端,global object被具象成window物件,也就是說 global object === window === 全域性環境的variable object。因此global object對於程式而言也是唯一可讀的variable object。

活動物件(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.

簡言之:當函式被啟用,那麼一個活動物件(activation object)就會被建立並且分配給執行上下文。活動物件由特殊物件 arguments 初始化而成。隨後,他被當做變數物件(variable object)用於變數初始化。
用程式碼來說明就是:

a被呼叫時,在a的執行上下文會建立一個活動物件AO,並且被初始化為 AO = [arguments]。隨後AO又被當做變數物件(variable object)VO進行變數初始化,此時 VO = [arguments].concat([name,age,gender,b])。

執行環境和作用域鏈(execution context and scope chain)

  • execution context
    顧名思義 執行環境/執行上下文。在javascript中,執行環境可以抽象的理解為一個object,它由以下幾個屬性構成:

    此外在js直譯器執行階段還會維護一個環境棧,當執行流進入一個函式時,函式的環境就會被壓入環境棧,當函式執行完後會將其環境彈出,並將控制權返回前一個執行環境。環境棧的頂端始終是當前正在執行的環境。
  • scope chain
    作用域鏈,它在直譯器進入到一個執行環境時初始化完成並將其分配給當前執行環境。每個執行環境的作用域鏈由當前環境的變數物件及父級環境的作用域鏈構成。
    作用域鏈具體是如何構建起來的呢,先上程式碼:
    1. 執行流開始 初始化function test,test函式會維護一個私有屬性 [[scope]],並使用當前環境的作用域鏈初始化,在這裡就是 test.[[Scope]]=global scope.
    2. test函式執行,這時候會為test函式建立一個執行環境,然後通過複製函式的[[Scope]]屬性構建起test函式的作用域鏈。此時 test.scopeChain = [test.[[Scope]]]
    3. test函式的活動物件被初始化,隨後活動物件被當做變數物件用於初始化。即 test.variableObject = test.activationObject.contact[num,a] = [arguments].contact[num,a]
    4. test函式的變數物件被壓入其作用域鏈,此時 test.scopeChain = [ test.variableObject, test.[[scope]]];

    至此test的作用域鏈構建完成。

說了這麼多概念,回到面試題上,返回結果相同那麼A、B兩段程式碼究竟不同在哪裡,個人覺得標準答案在這裡:

答案來了

首先是A:

  1. 進入全域性環境上下文,全域性環境被壓入環境棧,contextStack = [globalContext]
  2. 全域性上下文環境初始化,

    ,同時checkscope函式被建立,此時 checkscope.[[Scope]] = globalContext.scopeChain
  3. 執行checkscope函式,進入checkscope函式上下文,checkscope被壓入環境棧,contextStack=[checkscopeContext, globalContext]。隨後checkscope上下文被初始化,它會複製checkscope函式的[[Scope]]變數構建作用域,即 checkscopeContext={ scopeChain : [checkscope.[[Scope]]] }
  4. checkscope的活動物件被建立 此時 checkscope.activationObject = [arguments], 隨後活動物件被當做變數物件用於初始化,checkscope.variableObject = checkscope.activationObject = [arguments, scope, f],隨後變數物件被壓入checkscope作用域鏈前端,(checckscope.scopeChain = [checkscope.variableObject, checkscope.[[Scope]] ]) == [[arguments, scope, f], globalContext.scopeChain]
  5. 函式f被初始化,f.[[Scope]] = checkscope.scopeChain。
  6. checkscope執行流繼續往下走到 return f(),進入函式f執行上下文。函式f執行上下文被壓入環境棧,contextStack = [fContext, checkscopeContext, globalContext]。函式f重複 第4步 動作。最後 f.scopeChain = [f.variableObject,checkscope.scopeChain]
  7. 函式f執行完畢,f的上下文從環境棧中彈出,此時 contextStack = [checkscopeContext, globalContext]。同時返回 scope, 直譯器根據f.scopeChain查詢變數scope,在checkscope.scopeChain中找到scope(local scope)。
  8. checkscope函式執行完畢,其上下文從環境棧中彈出,contextStack = [globalContext]

如果你理解了A的執行流程,那麼B的流程在細節上一致,唯一的區別在於B的環境棧變化不一樣,

A: contextStack = [globalContext] —> contextStack = [checkscopeContext, globalContext] —> contextStack = [fContext, checkscopeContext, globalContext] —> contextStack = [checkscopeContext, globalContext] —> contextStack = [globalContext]

B: contextStack = [globalContext] —> contextStack = [checkscopeContext, globalContext] —> contextStack = [fContext, globalContext] —> contextStack = [globalContext]

也就是說,真要說這兩段程式碼有啥不同,那就是他們執行過程中環境棧的變化不一樣,其他的兩種方式都一樣。

其實對於理解這兩段程式碼而言最根本的一點在於,javascript是使用靜態作用域的語言,他的作用域在函式建立的時候便已經確定(不含arguments)。

說了這麼一大坨偏理論的東西,能堅持看下來的同學估計都要睡著了…是的,這麼一套理論性的東西糾結有什麼用呢,我只要知道函式作用域在建立時便已經生成不就好了麼。沒有實踐價值的理論往往得不到重視。那我們來看看,當我們瞭解到這一套理論之後我們的世界到底會發生了什麼變化:

這樣一段程式碼

ps:最後,關於閉包引起的記憶體洩露那都是因為瀏覽器的gc問題(IE8以下為首)導致的,跟js本身沒有關係,所以,請不要再問js閉包會不會引發記憶體洩露了,謝謝合作!

所有原文都可以在我的 github 上找到。

相關文章