JavaScript 語言核心(13)在 Scope chain 查找變數



你的變數 var 了嗎? 中談過變數範圍,當時說過,使用 var 宣告的變數,作用範圍基本上是在當時環境,(非嚴格模式下)不使用 var 直接指定值而建立的變數,是全域物件上的一個特性,也就是俗稱的全域範圍。單就使用 JavaScript 而言,這就足以了解使用變數時,有無使用 var 的差別。
Closure 與一級函式 中則以置閒變數觀念,從語法層面說明 Closure 的意義與作用,也可應付絕大多數的情況。
事實上,JavaScript 在查找變數時,會循著 Scope chain 逐一查找,Scope chain 可說明為何 JavaScript 沒有區塊範圍,也是 JavaScript 中閉包的實現機制。
就結論而言,你可沿著 Scope chain 來查找變數,也就是看看函式自身的 context 物件上是否有該特性,如果沒有就往外頭的 context 物件看看有沒有該特性。
如果要稍微深入一下 JavaScript 的 Scope chain,首先得了解一些名詞。首先是 Lexical Scope,這代表著 JavaScript 原始碼的物理位置(Physical placement)。例如:
var x = 10;
function outer() {
    var y = 20;
    function inner() {
        var z = 30;
    }
}
func();
在結構上,函式 inner 在原始碼中的位置是被 outer 包裹,而 outer 則是被 Global context 包裹,這樣的結構在原始碼寫下後就不變了,是屬於靜態的結構部份。每段 JavaScript 程式碼都會執行在一個 Execution context 中,對應 Lexical Scope 就是 Execution context。
像是上例中,x 變數定義是在 Global execution context 中,而每個函式在呼叫時都會建立新的 Function execution context,有個物件用來代表 Execution context,而區域變數則是 context 物件上的特性。
在 Rhino 直譯器中,可以使用函式上「非標準」的 __parent__ ,取得 Lexical Scope 建立時,該函式外部函式的 context 物件。
以上例來說,outer.__parent__ 是包裹 outer 函式的 Global execution context,對頂層函式而言也就是全域物件,而 inner.__parent__ 可取得 outer 的 Function execution context,如果要取得包裹著 outer 的 context 物件,則可以使用 inner.__parent__.__parent__,此時取得的就是 Global execution context,也就是全域物件。
當你試圖在 inner 中使用某變數,它會看看 inner 的 context 物件上是否有該特性,如果有就使用,沒有就查找 inner.__parent__,也就是外部函式 outer 的 context 物件上是否有該特性,若沒有就繼續查找 inner.__parent__.__parent__ ...
Node.js 不提供非標準方式來取得函式運行時包裹該函式的 context 物件,若想驗證一下 Scope chain,你可以試著取得 Rhino 直譯器,並依以下進行驗證,從中理解 Scope chain 的運作。例如:
js> var x = 10;
> function outer() { > var y = 20; > function inner() { > var z = 30; > } > return inner; > } js> var f = outer(); js> outer.__parent__ == this; true js> f.__parent__.__parent__ == this; true js> f.__parent__.y; 20 js> f.__parent__.__parent__.x; 10 js>
JavaScript 的變數,其實就是 context 物件上的特性。以上例而言,你可以透過 inner.__parent__ 參考至 outer 的 context 物件,而 outer 中宣告的 y,其實就是 context 物件上的特性,而 f.__parent__.__parent__ 取得包裹 outer 的 context 物件,也就是全域物件。
查找變數就是依序在 context 物件上尋找特性,可以說明以下這個例子:
js> function func() {
  >     print(m);
  >     var m = 10;
  >     print(m);
  > }
js> func();
undefined
10
js>
逐步來看的話,就是這樣:
function func() {
    print(m);         // func 的 context 物件上沒有 m 特性(也就是 undefined)
    var m = 10;       // func 的 context 物件有 m 特性且值為 10
    print(m);         // func 的 context 物件有 m 特性且值為 10
}
若使用非標準的 __parent__,你也可以這麼取得相同結果:
js> function func() {
  >     function inner() {}
  >     print(inner.__parent__.m);
  >     var m = 10;
  >     print(inner.__parent__.m);
  > }
js> func();
undefined
10
js>
JavaScript 在查找變數時,會先在目前 context 物件上找,若找不到指定名稱,則會到外層 context 物件上找,若找不到,就再到更外層 context 物件找,直到全域物件為止,這樣的順序形成變數查找的 Scope chain。
再來看區域變數覆蓋全域變數的例子:
var x = 10;
function func() {
    var x = 20;
    print(x);
}
以 Scope chain 來解釋的話,在 func 函式中查找 x 時,是先查找 func 的 context 物件上有無 x 特性,因此對應的是 20 的值:
js> var x = 10;
js> function func() {
  >     function inner() {}
  >     var x = 20;
  >     print(inner.__parent__.x);
  > }
js> func();
20
js>
再來看 Closure 的例子:
function doSome() {
    var x = 10;
    function f(y) {
        return x + y;
    }
    return f;
}
在內部的 f 函式中 context 物件上找有無 x 特性時,並沒有找到,於是在包裹 fdoSome 呼叫物件上查找有無 x,也就是查找 f.__parent__ 上有無 x,此時找到了。
如果你了解 Scope chain,結合非標準 __parent__,你也可以這麼玩:
js> function func() {
  >     function inner() {}
  >     inner.__parent__.y = 30;
  >     print(y);
  > }
js> func();
30
js>
即使 func 中並沒有使用 var 宣告 y,但仍可以顯示 y 的值。
可以這麼說,在 JavaScript 中,所有的變數,都是某個物件上的特性。
附帶一提的是,自行使用 new 建立的 Function,如果有查找變數,一定查找全域物件中的特性。例如:
js> var x = 10;
js> function func() {
  >     var x = 20;
  >     var f = new Function('return x;');
  >     print(f.__parent__.x);
  >     return f();
  > };
js> func();
10
10
js>