1. 兩種作用域
“作用域”我們知道是一套規則,用來管理引擎如何在當前作用域以及嵌套的子作用域中根據標識符名稱進行變量查找。
作用域有兩種主要工作模型:詞法作用域和動態作用域。
大多數語言採用的都是詞法作用域,少數語言採用動態作用域(例如 Bash 腳本),這裏我們主要討論詞法作用域。
2. 詞法
大部分標準語言編譯器的第一個工作階段叫作詞法化。
簡單地說,詞法作用域是由你在寫代碼時將變量和函數(塊)作用域寫在哪裏來決定的。當然,也會有一些方法來動態修改作用域,後邊我會介紹。
舉個例子:
var a = 2;
function foo1 () {
console.log(a);
}
function foo2 () {
var a = 10;
foo1();
}
foo2();
這裏輸出結果是多少呢?
注意,這裏結果打印的是 2。
可能會有一些同學認爲是 10,那就是沒有搞清楚詞法作用域的概念。
前邊介紹了,詞法作用域只取決於代碼書寫時的位置,那麼在這個例子中,函數 foo1 定義時的位置決定了它的作用域,通過下圖理解:
詞法作用域
foo1 和 foo2 都是分別定義在全局作用域中的函數,它們是並列的,所以在 foo1 的作用域鏈中並不包含 foo2 的作用域,雖然在 foo2 中調用了 foo1,但是 foo1 對變量 a 進行 RHS 查詢時,在自己的作用域沒有找到,引擎會去 foo1 的上級作用域(也就是全局作用域)中查找,而並不會去 foo2 的作用域中查找,最終在全局作用域中找到 a 的值爲 2。
總結來說,無論函數在哪裏被調用,也無論它如何被調用,它的詞法作用域都只由函數被聲明時所處的位置決定。
3. 欺騙詞法
JavaScript 中有 3 種方式可以用來“欺騙詞法”,動態改變作用域。
第一種: eval
JavaScript 中 eval(...) 函數可以接受一個字符串作爲參數,並將其中的內容視爲好像在書寫時就存在於程序中這個位置的代碼。
在執行 eval(...) 之後的代碼時,引擎並不知道或在意前面的代碼是以動態形式插入進來並對詞法作用域環境進行修改的,引擎只會像往常一樣正常進行詞法作用域的查找。
舉個例子:
function foo (str) {
eval(str); // "欺騙"詞法
console.log(a);
}
var a = 2;
foo("var a = 10;");
如大家所想,輸出結果爲 10。
因爲 eval("var a = 10;") 在 foo 的作用域中新創建了一個同名變量 a,引擎在 foo 作用域中對 a 進行 RHS 查詢,找到了新定義的 a,值爲 10,所以不再向上查找全局作用域中的 a,所以導致輸出結果爲 10,這就是 eval(...) 的作用。
在嚴格模式下,eval(...) 在運行時有自己的詞法作用域,意味着其中的聲明無法修改所在的作用域。
'use strict';
function foo (str) {
eval(str); // eval() 有自己的作用域,所以並不會修改 foo 的詞法作用域
console.log(a);
}
var a = 2;
foo("var a = 10;");
這裏輸出結果爲 2。
JavaScript 中還有一些功能和 eval(...) 類似的函數,例如 setTimeout(...) 和 setInterval(...) 的第一個參數可以是一個字符串,字符串的內容可以解釋爲一段動態生成的代碼。這些功能已經過時並且不被提倡,最好不要使用它們。new Function(...) 函數的最後一個參數也可以接受代碼字符串,並將其轉化爲動態生成的函數,也儘量避免使用。
在程序中動態生成代碼的使用場景非常罕見,因爲它所帶來的好處無法抵消性能上的損失。
第二種: with
with 通常被當做重複引用同一個對象中的多個屬性的快捷方式,可以不需要重複引用對象本身。
舉個例子:
var obj = {
a: 2,
b: 3
};
with (obj) {
console.log(a); // 2
console.log(b); // 3
c = 4;
};
console.log(c); // 4, c 被泄露到全局作用域上
如上所示,我們對 c 進行 LHS 查詢,因爲在 with 引入的新作用域中沒有找到 c,所以向上一級作用域(這裏是全局作用域)查找,也沒有找到,在非嚴格模式下,在全局對象中新建了一個屬性 c 並賦值爲 4。
with 可以將一個沒有或有多個屬性的對象處理爲一個完全隔離的詞法作用域,因此這個對象的屬性也會被處理爲定義在這個作用域中的詞法標識符。
儘管 with 塊可以將一個對象處理爲詞法作用域,但是這個塊內部正常的 var 聲明並不會限制在這個塊作用域中,而是被添加到 with 所處的函數作用域中。
嚴格模式下,with 被完全禁止使用。
'use strict';
var obj = {
a: 2,
b: 3
};
with (obj) {
console.log(a);
console.log(b);
c = 4;
};
console.log(c);
嚴格模式下禁止使用with
第三種: try...catch
try...catch 可以測試代碼中的錯誤。try 部分包含需要運行的代碼,而 catch 部分包含錯誤發生時運行的代碼。
舉個例子:
try {
foo();
} catch (err) {
console.log(err);
var a = 2;
// 打印出 "ReferenceError: foo is not defined at <anonymous>:2:4"
}
console.log(a); // 2
當 try 中的代碼出現錯誤時,就會進入 catch 塊,此時會把異常對象添加到作用域鏈的最前端,類似於 with 一樣,catch 中定義的局部變量也都會添加到包含 try...catch 的函數作用域(或全局作用域)中。
4. 性能
JavaScript 引擎會在編譯階段進行數項性能優化。其中有些優化依賴於能夠根據代碼的詞法進行靜態分析,並預先確定所有變量和函數定義的位置,才能在執行過程中快速找到標識符。
但如果引擎在代碼中發現了 eval(...)、with 和 try...catch ,它只能簡單的假設關於標識符位置的判斷都是無效的,因爲無法在詞法分析階段明確知道 eval(...) 會接受到什麼代碼,這些代碼會如何對作用域進行修改,也無法知道傳遞給 with 用來創建新詞法作用域的對象的內容到底是什麼。
最悲觀的情況是如果出現了這些動態添加作用域的代碼,所有的優化可能都是無意義的,因此最簡單的做法就是完全不進行任何優化。
如果代碼中大量使用 eval(...) 和 with,那麼運行起來一定會變得非常緩慢。
5. 結論
很多時候我們對代碼的分析出錯,就是源於對詞法作用域的忽略,所以讓我們重新審視代碼,繼續努力!