擒賊先擒王,簡單談一下JavaScript作用域鏈(Scope Chain)

不願意透露姓名的聶先生發表於2019-02-28
alt

前言

我們都知道一個執行上下文的資料(變數、函式宣告和函式的形參)作為屬性儲存在變數物件中,同時我們也應該知道變數物件在每次進入上下文時建立並填入初始值,值的更新出現在程式碼執行階段。那麼我們們專門討論與執行上下文直接相關的更多細節,這次我們將提及一個議題——作用域鏈。

英文原文:http://dmitrysoshnikov.com/ecmascript/chapter-4-scope-chain/
中文參考:http://www.denisdeng.com/?p=908
本文絕大部分內容來自上述地址,僅做少許修改,感謝作者
如有雷同,純屬抄襲,吼吼
複製程式碼

定義

如果要簡要的描述並展示其重點,那麼作用域鏈大多處與內部函式相關。

我們知道,ECMAScript允許建立內部函式,我們甚至能從父函式中返回這些函式。

var x = 10
function foo () {
    var y = 20
    function bar () {
        alert(x + y)
    }
    return bar
}

foo()() // 30
複製程式碼

這樣,很明顯每個上下文都擁有自己的變數物件:對於全域性上下文,它是全域性物件自身;對於函式,它是活動物件。

作用域鏈正是內部上下文所有變數物件(包括父變數物件)的列表,此鏈用來變數查詢。即在上面的的例子中,“bar”上下文的作用域包括AO(foo)、AO(foo)和VO(global)

但是,讓我們仔細討論這個問題。

讓我們從定義開始,並進一步的討論示例。

作用域鏈與一個執行上下文相關,變數物件的鏈用於在識別符號解析中變數查詢

函式上下文的作用域鏈在函式呼叫時建立的,包含活動物件和這個函式內部的[scope]屬性,下面我們詳細討論一個函式[scope]屬性。

在上下文中示意如下:

activeExecutionContext = {
    VO: {...}, // or AO
    this: thisValue,
    Scope: [ // Scope chain
      // 所有變數物件的列表
      // for identifiers lookup
    ]
};
複製程式碼

其scope定義如下:

Scope = AO + [[Scope]]
複製程式碼

這種聯合和識別符號解釋過程,我們下面討論,與函式的宣告週期有關。


函式的生命週期

函式的生命週期分為建立和啟用階段(呼叫時),讓我們詳細研究它

函式建立

眾所周知,在進入上下文時函式宣告放到變數/活動(VO/AO)物件中。讓我們看看在全域性上下文中的變數和函式宣告(這裡變數物件是全域性物件自身,我們還記得,是吧?)

var x = 10;
 
function foo() {
  var y = 20;
  alert(x + y);
}
 
foo(); // 30
複製程式碼

在函式啟用時,我們得到正確的(預期的)結果--30。但是,有一個很重要的特點。

此前,我們僅僅談到有關當前上下文的變數物件。這裡,我們看到變數“y”在函式“foo”中定義(意味著它在foo上下文的AO中),但是變數“x”並未在“foo”上下文中定義,相應地,它也不會新增到“foo”的AO中。乍一看,變數“x”相對於函式“foo”根本就不存在;但正如我們在下面看到的——也僅僅是“一瞥”,我們發現,“foo”上下文的活動物件中僅包含一個屬性--“y”。

fooContext.AO = {
  y: undefined // undefined – 進入上下文的時候是20 – at activation
};
複製程式碼

函式“foo”如何訪問到變數“x”?理論上函式應該能訪問一個更高一層上下文的變數物件。實際上它正是這樣,這種機制通過函式內部的[[scope]]屬性來實現的,[[scope]]是所有父變數物件的層級鏈,處於當前函式上下文之上,在函式建立時存於其中。

注意這重要的一點 [[scope]]在函式建立時被儲存,永遠永遠,直至函式被銷燬,即:函式可以永遠不呼叫,但[[scope]]屬性已經寫入,並儲存在函式物件中。

另外一個需要考慮的是,與作用域鏈對比,[[scope]]是函式的一個屬性而不是上下文。考慮到上面的例子,函式“foo”的[[scope]]如下:

foo.[[Scope]] = [
  globalContext.VO // === Global
];
複製程式碼

舉例來說,我們用通常的ECMAScript 陣列展現作用域和[[scope]]。
繼而,我們知道在函式呼叫時進入上下文,這時候活動物件被建立,this和作用域(作用域鏈)被確定。讓我們詳細考慮這一時刻。

函式啟用

正如在定義中說到的,進入上下文建立AO/VO之後,上下文的Scope屬性(變數查詢的一個作用域鏈)作如下定義:

Scope = AO|VO + [[Scope]]
複製程式碼

上面程式碼的意思是:活動物件是作用域陣列的第一個物件,即新增到作用域的前端。

Scope = [AO].concat([[Scope]]);
複製程式碼

這個特點對於標示符解析的處理來說很重要

標示符解析是一個處理過程,用來確定一個變數(或函式宣告)屬於哪個變數物件。

這個演算法的返回值中,我們總有一個引用型別,它的base元件是相應的變數物件(或若未找到則為null),屬性名元件是向上查詢的標示符的名稱。識別符號解析過程包含與變數名對應屬性的查詢,即作用域中變數物件的連續查詢,從最深的上下文開始,繞過作用域鏈直到最上層。這樣一來,在向上查詢中,一個上下文中的區域性變數較之於父作用域的變數擁有較高的優先順序。萬一兩個變數有相同的名稱但來自不同的作用域,那麼第一個被發現的是在最深作用域中.

我們用一個稍微複雜的例子描述上面講到的這些。

var x = 10;
 
function foo() {
  var y = 20;
 
  function bar() {
    var z = 30;
    alert(x +  y + z);
  }
 
  bar();
}
 
foo(); // 60
複製程式碼

對此,我們有如下的變數/活動物件,函式的的[[scope]]屬性以及上下文的作用域鏈:
全域性上下文的變數物件是:

globalContext.VO === Global = {
  x: 10
  foo: <reference to function>
};
複製程式碼

在“foo”建立時,“foo”的[[scope]]屬性是:

foo.[[Scope]] = [
  globalContext.VO
];
複製程式碼

在“foo”啟用時(進入上下文),“foo”上下文的活動物件是:

fooContext.AO = {
  y: 20,
  bar: <reference to function>
};
複製程式碼

“foo”上下文的作用域鏈為:

fooContext.Scope = fooContext.AO + foo.[[Scope]] // i.e.:
 
fooContext.Scope = [
  fooContext.AO,
  globalContext.VO
];
複製程式碼

內部函式“bar”建立時,其[[scope]]為:

bar.[[Scope]] = [
  fooContext.AO,
  globalContext.VO
];
複製程式碼

在“bar”啟用時,“bar”上下文的活動物件為:

barContext.AO = {
  z: 30
};
複製程式碼

“bar”上下文的作用域鏈為:

barContext.Scope = [
  barContext.AO,
  fooContext.AO,
  globalContext.VO
];
複製程式碼

對“x”、“y”、“z”的識別符號解析如下:

- "x"
-- barContext.AO // not found
-- fooContext.AO // not found
-- globalContext.VO // found - 10

- "y"
-- barContext.AO // not found
-- fooContext.AO // found - 20

- "z"
-- barContext.AO // found - 30
複製程式碼

作用域特徵

閉包

在ECMAScript中,閉包與函式的[[scope]]直接相關,正如我們提到的那樣,[[scope]]在函式建立時被儲存,與函式共存亡。實際上,閉包是函式程式碼和其[[scope]]的結合。因此,作為其物件之一,[[Scope]]包括在函式內建立的詞法作用域(父變數物件)。當函式進一步啟用時,在變數物件的這個詞法鏈(靜態的儲存於建立時)中,來自較高作用域的變數將被搜尋。

var x = 10;
 
function foo() {
  alert(x);
}
 
(function () {
  var x = 20;
  foo(); // 10, but not 20
})();
複製程式碼

我們再次看到,在識別符號解析過程中,使用函式建立時定義的詞法作用域--變數解析為10,而不是30。此外,這個例子也清晰的表明,一個函式(這個例子中為從函式“foo”返回的匿名函式)的[[scope]]持續存在,即使是在函式建立的作用域已經完成之後。

通過建構函式建立的函式的[[scope]]

在上面的例子中,我們看到,在函式建立時獲得函式的[[scope]]屬性,通過該屬性訪問到所有父上下文的變數。但是,這個規則有一個重要的例外,它涉及到通過函式建構函式建立的函式。

var x = 10;
 
function foo() {
 
  var y = 20;
 
  function barFD() { // 函式宣告
    alert(x);
    alert(y);
  }
 
  var barFE = function () { // 函式表示式
    alert(x);
    alert(y);
  };
 
  var barFn = Function(`alert(x); alert(y);`);
 
  barFD(); // 10, 20
  barFE(); // 10, 20
  barFn(); // 10, "y" is not defined
 
}
 
foo();
複製程式碼

我們看到,通過函式建構函式(Function constructor)建立的函式“bar”,是不能訪問變數“y”的。但這並不意味著函式“barFn”沒有[[scope]]屬性(否則它不能訪問到變數“x”)。問題在於通過函建構函式建立的函式的[[scope]]屬性總是唯一的全域性物件。考慮到這一點,如通過這種函式建立除全域性之外的最上層的上下文閉包是不可能的。

二維作用域鏈查詢

在作用域鏈中查詢最重要的一點是變數物件的屬性(如果有的話)須考慮其中--源於ECMAScript 的原型特性。如果一個屬性在物件中沒有直接找到,查詢將在原型鏈中繼續。即常說的二維鏈查詢。(1)作用域鏈環節;(2)每個作用域鏈--深入到原型鏈環節。如果在Object.prototype 中定義了屬性,我們能看到這種效果。

function foo() {
  alert(x);
}
 
Object.prototype.x = 10;
 
foo(); // 10
複製程式碼

活動物件沒有原型,我們可以在下面的例子中看到:

function foo() {
 
  var x = 20;
 
  function bar() {
    alert(x);
  }
 
  bar();
}
 
Object.prototype.x = 10;
 
foo(); // 20
複製程式碼

全域性和eval上下文中的作用域鏈

這裡不一定很有趣,但必須要提示一下。全域性上下文的作用域鏈僅包含全域性物件。程式碼eval的上下文與當前的呼叫上下文(calling context)擁有同樣的作用域鏈。

globalContext.Scope = [
  Global
];
 
evalContext.Scope === callingContext.Scope;
複製程式碼

程式碼執行時對作用域鏈的影響

在ECMAScript 中,在程式碼執行階段有兩個宣告能修改作用域鏈。這就是with宣告和catch語句。它們新增到作用域鏈的最前端,物件須在這些宣告中出現的識別符號中查詢。如果發生其中的一個,作用域鏈簡要的作如下修改:

Scope = withObject|catchObject + AO|VO + [[Scope]]
複製程式碼

在這個例子中新增物件,物件是它的引數(這樣,沒有字首,這個物件的屬性變得可以訪問)。

var foo = {x: 10, y: 20};
 
with (foo) {
  alert(x); // 10
  alert(y); // 20
}
複製程式碼

作用域鏈修改成這樣:

Scope = foo + AO|VO + [[Scope]]
複製程式碼

我們再次看到,通過with語句,物件中識別符號的解析新增到作用域鏈的最前端:

var x = 10, y = 10;
 
with ({x: 20}) {
 
  var x = 30, y = 30;
 
  alert(x); // 30
  alert(y); // 30
}
 
alert(x); // 10
alert(y); // 30
複製程式碼

在進入上下文時發生了什麼?識別符號“x”和“y”已被新增到變數物件中。此外,在程式碼執行階段作如下修改:

  • x = 10, y = 10;
  • 物件{x:20}新增到作用域的前端;
  • 在with內部,遇到了var宣告,當然什麼也沒建立,因為在進入上下文時,所有變數已被解析新增;
  • 在第二步中,僅修改變數“x”,實際上物件中的“x”現在被解析,並新增到作用域鏈的最前端,“x”為20,變為30;
  • 同樣也有變數物件“y”的修改,被解析後其值也相應的由10變為30;
  • 此外,在with宣告完成後,它的特定物件從作用域鏈中移除(已改變的變數“x”--30也從那個物件中移除),即作用域鏈的結構恢復到with得到加強以前的狀態。
  • 在最後兩個alert中,當前變數物件的“x”保持同一,“y”的值現在等於30,在with宣告執行中已發生改變

同樣,catch語句的異常引數變得可以訪問,它建立了只有一個屬性的新物件--異常引數名。圖示看起來像這樣:

try {
  ...
} catch (ex) {
  alert(ex);
}
複製程式碼

作用域鏈修改為:

var catchObject = {
  ex: <exception object>
};
 
Scope = catchObject + AO|VO + [[Scope]]
複製程式碼

結論

在這個階段,我們幾乎考慮了與執行上下文相關的所有常用概念,以及與它們相關的細節。按照計劃--函式物件的詳細分析:函式型別(函式宣告,函式表示式)和閉包。順便說一下,工作之餘隨手總結只為和大家一起分享實屬不易,如有雷同、純屬抄襲,吼吼歡迎大家一起討論技術。如果覺得對您有幫助請給小編點下小心心。

轉自 深入理解JavaScript系列 希望對大家有所幫助

相關文章