1. 程式人生 > >JavaScript 詞法作用域

JavaScript 詞法作用域

詞法作用域

【前提知識】:

【作用域定義】:作用域被定義為一套用來管理引擎如何在當前作用域以及巢狀的子作用域中根據識別符號名稱進行變數查詢的規則。

【最重要的特徵】:定義過程發生在程式碼的書寫階段(假設沒有使用 eval() 或 with)。

作用域共有兩種主要的工作模型。

  • 詞法作用域:最為普遍的,被大多數程式語言所採用。
  • 動態作用域:仍有一些程式語言在使用,比如 Bash 指令碼、Perl 中的一些模式等。

詞法階段

【基礎】:大部分標準語言編譯器的第一個工作階段叫作詞法化。詞法化的過程會對原始碼中的字元進行檢查,如果是有狀態的解析過程,還會賦予單詞語義。

簡單地說,詞法作用域就是定義在詞法階段的作用域。換句話說,詞法作用域是由你在寫程式碼時將變數和塊作用域寫在哪裡來決定的,因此當詞法分析器處理程式碼時會保持作用域不變(大部分情況下是這樣的)。

【示例】:

function foo(a) {
    var b = a * 2;
    
    function bar(c) {
        console.log(a, b, c);
    }
    
    bar(b * 3);
}

foo(2); // 2, 4, 12

【解釋】:作用域由其對應的作用域塊帶啊名寫在哪裡決定,它們是逐級包含的。

  1. 全域性作用域:其中只有一個識別符號,foo。
  2. foo 所建立的作用域:其中有三個識別符號,a、bar、b。
  3. bar 所建立的作用域:其中只有一個識別符號,c。

【注意】:作用域是嚴格包含的。換句話說,沒有任何函式(定義)可以同時出現在兩個外部(父級)函式中。

查詢

作用域的結構和互相之間的位置關係給引擎提供了足夠的位置資訊,引擎用這些資訊來查詢識別符號的位置。

【遮蔽效應】:作用域查詢會在找到第一個匹配的識別符號時停止。因此如果在多層的巢狀作用域中定義同名的識別符號,內部的識別符號會“遮蔽”外部的識別符號

【注意】:

  1. 無論函式在哪裡被呼叫,也無論它如何被呼叫,它的詞法作用域都只由函式被宣告時所處的位置決定。
  2. 全域性變數會自動成為全域性物件的屬性,因此可以不直接通過全域性變數的詞法名稱,而是間接地通過對全域性物件屬性的引用來對其進行訪問。通過這種技術可以訪問那些被同名變數所遮蔽的全域性變數。但並非全域性變數被遮蔽了就無論如何都無法被訪問到。
  3. 詞法作用域查詢只會查詢一級識別符號,比如 a、b 和 c。如果程式碼中引用了 foo.bar.baz,詞法作用域查詢只會試圖查詢 foo 識別符號,找到這個變數後,物件屬性訪問規則會分別接管對 bar 和 baz 屬性的訪問。

欺騙詞法

【問】:如果詞法作用域完全由寫程式碼期間函式所宣告的位置來定義,怎樣才能在執行時來“修改”(也可以說欺騙)詞法作用域呢?

【答】:JavaScript 中有兩種機制來實現這個目的。社群普遍認為在程式碼中使用這兩種機制並不是什麼好主意。但是關於它們的爭論通常會忽略掉最重要的點:欺騙詞法作用域會導致效能下降

eval

eval() 函式可以接受一個字串作為引數,並將其中的內容視為好像在書寫時就存在於程式中這個位置的程式碼。換句話說,可以在你寫的程式碼中用程式生成程式碼並執行,就好像程式碼本身就是寫在那個位置的一樣。

【示例】:

function foo(str, a) {
    eval(str); // 欺騙!
    console.log(a, b);
}

var b = 2;

foo("var b = 3;", 1); // 1, 3

【解釋】:eval() 呼叫中的 var b = 3; 這段程式碼會被當作本來就在那裡一樣來處理。由於那段程式碼聲明瞭一個新的變數 b,因此它對已經存在的 foo() 的詞法作用域進行了修改。

【注意】:在嚴格模式的程式中,eval() 在執行時有其自己的詞法作用域,意味著其中的宣告無法修改所在的作用域。

function foo(str) {
    "use strict";
    eval(str);
    console.log(a); // ReferenceError: a is not defined
}

foo("var a = 2");

【其他】:

  1. setTimeout() 和 setInterval() 的第一個引數可以是字串,字串的內容可以被解釋為一段動態生成的函式程式碼。
  2. new Function() 最後一個引數可以接受程式碼字串,並將其轉化為動態生成的函式(前面的引數是這個新生成的函式的形參)。

這些功能都不提倡使用。因為它們所帶來的好處無法抵消效能上的損失。

with

with 通常被當作可以不需要重複引用物件本身,就可以重複引用該物件中多個屬性的快捷方式。

【示例】:

var obj = {
    a: 1,
    b: 2,
    c: 3
};

// 單調乏味的重複 "obj"
obj.a = 2;
obj.b = 3;
obj.c = 4;

// 簡單的快捷方式
with(obj) {
    a = 3;
    b = 4;
    c = 5;
}

【示例】:

function foo(obj) {
    with(obj) {
        a = 2;
    }
}

var o1 = {
    a: 3
};

var o2 = {
    b: 3
};

foo(o1);
console.log(o1.a); // 2

foo(o2);
console.log(o2.a); // undefined
console.log(a); //2

【解釋】:with 可以將一個沒有或有多個屬性的物件處理為一個完全隔離的詞法作用域,因此這個物件的屬性也會被處理為定義在這個作用域中的詞法識別符號。也就是說,當我們傳遞 o2 給 with 時,o2 會被處理為一個作用域,其中並沒有 a 識別符號,因此進行了正常的 LHS 識別符號查詢。又因為執行在非嚴格模式下,所以就在全域性作用域中自動建立了一個全域性變數。

  • eval:修改所處的詞法作用域。
  • with:建立全新的詞法作用域。

效能

JavaScript 引擎會在編譯階段進行數項的效能優化。其中有些優化依賴於能夠根據程式碼的詞法進行靜態分析,並預先確定所有變數和函式的定義位置,才能在執行過程中快速找到識別符號。

但如果引擎在程式碼中發現了 eval() 或 with,它只能簡單地假設關於識別符號位置的判斷都是無效的,因為無法在詞法分析階段明確知道 eval() 會接收到什麼程式碼,這些程式碼會如何對作用域進行修改,也無法知道傳遞給 with 用來建立新詞法作用域的物件的內容到底是什麼。

最悲觀的情況是出現了 eval() 或 with,所有的優化可能都是無意義的,因此最簡單的做法就是完全不做任何優化。如果程式碼中大量使用 eval() 或 with,那麼執行起來一定會變得非常。無論引擎多聰明,試圖將這些悲觀情況的副作用限制在最小範圍內,也無法避免如果沒有這些優化,程式碼會執行得更慢這個事實

【理解】:你是圖書管理員,需要把一大堆書按照它們所屬的科目放到相應的書櫃中。這個時候,老師告訴你之後還有一堆書要搬過來,但是這些書屬於什麼科目、書名什麼的暫時還不知道。在該情形中,圖書管理員和引擎都面對同樣的問題。

小結

  1. 詞法作用域意味著作用域是由書寫程式碼時函式宣告的位置來決定的。編譯的詞法分析階段基本能夠知道全部識別符號在哪裡以及是如何宣告的,從而能夠預測在執行過程中如何對它們進行查詢。
  2. JavaScript 中有兩個機制可以“欺騙”詞法作用域:eval() 和 with。前者可以對一段包含一個或多個宣告的“程式碼”字串進行演算,並藉此來修改已經存在的詞法作用域(在執行時)。後者本質上是通過將一個物件的引用當作作用域來處理,將物件的屬性當作作用域中的識別符號來處理,從而建立了一個新的詞法作用域(同樣是在執行時)。
  3. 這兩個機制的副作用是引擎無法在編譯時對作用域查詢進行優化,因為引擎只能謹慎地認為這樣的優化是無效的。使用這其中任何一個機制都將導致程式碼執行變慢。不要使用它們