雖然作用域相關知識是 JavaScript 的基礎, 但要徹底理解必須要從原理入手. 從面試角度來講, 詞法/動態作用域、作用域(鏈)、變數/函式提升、閉包、垃圾回收 實屬一類題目, 打通這幾個概念並熟練掌握, 面試基本就不用擔心這一塊了. 這篇文章是對《JavaScript 高階程式設計 (第三版)》第四章, 同樣也是 《你不知道的 JavaScript (上卷)》第一部分的學習和總結.
編譯原理
對於大部分程式語言, 編譯大致有三個步驟.
-
分詞/詞法分析 (Tokenizing/Lexing)
此過程將原始碼分解成
詞法單元 (token)
, 如程式碼const firstName = 'Yancey'
會被分解成const
,firstName
,=
,'Yancey'
, 空格是否會被當成詞法單元, 取決於空格對這門語言的意義. 這裡推薦一個網站 Parser 可以用來解析 JavaScript 的原始碼. 對於這個例子, 分詞結構如下.[ { type: 'Keyword', value: 'const', }, { type: 'Identifier', value: 'firstName', }, { type: 'Punctuator', value: '=', }, { type: 'String', value: "'Yancey'", }, ]; 複製程式碼
-
解析/語法分析 (Parsing)
這個過程將詞法單元流轉換成一棵 抽象語法樹 (Abstract Syntax Tree, AST). 語法分析會根據 ECMAScript 的標準來解析成 AST, 比如你寫了
const new = 'Yancey'
, 就會報錯 Uncaught SyntaxError: Unexpected token new.對於上面那個例子, 生成的 AST 如下圖所示, 其中
Identifier
代表著變數名,Literal
代表著變數的值. -
程式碼生成
這個階段就是將 AST 轉換為可執行程式碼, 像 V8 引擎會將 JavaScript 字串編譯成二進位制程式碼(建立變數、分配記憶體、將一個值儲存到變數裡...)
除上面三個階段之外, JavaScript 引擎還對 語法分析、程式碼生成、編譯過程 進行一些優化, 這一塊估計得看 v8 原始碼了, 先留個坑. 有個庫叫做 Acorn, 用來解析 JavaScript 程式碼, 像 webpack、eslint 都有用到, 有時間可以玩一玩.
詞法作用域和動態作用域
作用域有兩種模型, 一種是 詞法作用域(Lexical Scope), 另一種是 動態作用域 (Dynamic Scope).
詞法作用域是定義在詞法階段的作用域, 換句話說就是你寫程式碼時將變數和塊作用域寫在哪裡決定的. JavaScript 可以通過 eval
和 with
來改變詞法作用域, 但這兩種會導致引擎無法在編譯時對作用域查詢進行優化, 因此不要使用它們.
而動態作用域是在執行時定義的, 最典型的就是 this 了.
作用域
不管是編譯階段還是執行時, 都離不開 引擎, 編譯器, 作用域.
-
引擎用來負責 JavaScript 程式的編譯和執行.
-
編譯器負責語法分析、程式碼生成等工作.
-
作用域用來收集並維護所有變數訪問規則.
以程式碼 const firstName = 'Yancey'
為例, 首先編譯器遇到 const firstName
, 會詢問 作用域 是否已經有一個同名變數在當前作用域集合, 如果有編譯器則忽略該宣告, 否則它會在當前作用域的集合中宣告一個新的變數並命名為 firstName
.
接著編譯器會為引擎生成執行時所需的程式碼, 用於處理 firstName = 'Yancey'
這個賦值操作. 引擎會先詢問作用域, 在當前作用域集合中是否有個變數叫 firstName
. 如果有, 引擎就會使用這個變數, 否則繼續往上查詢.
引擎在作用域中查詢元素時有兩種方式:LHS
和 RHS
. 一般來講, LHS
是賦值階段的查詢, 而 RHS
就是純粹查詢某個變數.
看下面這個例子.
function foo(a) {
var b = a;
return a + b;
}
var c = foo(2);
複製程式碼
-
var c = foo(2);
引擎會在作用域裡找是否有foo
這個函式, 這是一次 RHS 查詢, 找到之後將其賦值給變數c
, 這是一次 LHS 查詢. -
function foo(a) {
這裡將實參2
賦值給形參a
, 所以這是一次 LHS 查詢. -
var b = a;
這裡要先找到變數a
, 所以這是一次 RHS 查詢. 接著將變數a
賦值給b
, 這是一次 LHS 查詢. -
return a + b;
查詢a
和b
, 所以是兩次 RHS 查詢.
全域性作用域
以瀏覽器環境為例:
-
最外層函式和在最外層函式外面定義的變數擁有全域性作用域
-
所有末定義直接賦值的變數自動宣告為擁有全域性作用域
-
所有 window 物件的屬性擁有全域性作用域
const a = 1; // 全域性變數
// 全域性函式
function foo() {
b = 2; // 未定義卻賦初值被認為是全域性變數
const name = 'yancey'; // 區域性變數
// 區域性函式
function bar() {
console.log(name);
}
}
window.navigator; // window 物件的屬性擁有全域性作用域
複製程式碼
全域性作用域的缺點很明顯, 就是會汙染全域性名稱空間, 因此很多庫的原始碼都會使用 (function(){....})()
. 此外, 模組化 (ES6、commonjs 等等) 的廣泛使用也為防止汙染全域性名稱空間提供了更好的解決方案.
函式作用域
函式作用域指屬於這個函式的全部變數都可以在整個函式範圍內使用及複用.
function foo() {
const name = 'Yancey';
function sayName() {
console.log(`Hello, ${name}`);
}
sayName();
}
foo(); // 'Hello, Yancey'
console.log(name); // 外部無法訪問到內部變數
sayName(); // 外部無法訪問到內部函式
複製程式碼
值得注意的是, if、switch、while、for 這些條件語句或者迴圈語句不會建立新的作用域, 雖然它也有一對 {}
包裹. 能不能訪問的到內部變數取決於宣告方式(var 還是 let/const)
if (true) {
var name = 'yancey';
const age = 18;
}
console.log(name); // 'yancey'
console.log(age); // 報錯
複製程式碼
塊級作用域
我們知道 let 和 const 的出現改變了 JavaScript 沒有塊級作用域的情況(具體可以看高程三的第 76 頁, 那個時候還沒有塊級作用域的概念). 關於 let 和 const 不去細說, 這兩個再不懂的話... 不過後面會介紹到臨時死區的概念.
此外, try/catch
的 catch
分句也會建立一個塊級作用域, 看下面一個例子:
try {
noThisFunction(); // 創造一個異常
} catch (e) {
console.log(e); // 可以捕獲到異常
}
console.log(e); // 報錯, 外部無法拿到 e
複製程式碼
提升
在 ES6 之前的"蠻荒時代", 變數提升在面試中經常被問到, 而 let 和 const 的出現解決了變數提升問題. 但函式提升一直是存在的, 這裡我們從原理入手來分析一下提升.
變數提升
我們回憶一下關於編譯器的內容, 引擎會在解釋 JavaScript 程式碼之前首先對其進行編譯, 編譯階段的一部分工作就是找到所有的宣告, 並且使用合適的作用域將它們串聯起來. 換句話說, 變數和函式在內的所有宣告都會在程式碼執行前被處理.
因此, 對於程式碼 var i = 2;
而言, JavaScript 實際上會將這句程式碼看作 var i;
和 i = 2
, 其中第一個是在編譯階段, 第二個賦值操作會原地等待執行階段. 換句話說, 這個過程將會把變數和函式宣告放到其作用域的頂部, 這個過程就叫做提升.
可能你會有疑問, 為什麼 let 和 const 不存在變數提升呢?這是因為在編譯階段, 當遇到變數宣告時, 編譯器要麼將它提升至作用域頂部(var 宣告), 要麼將它放到 臨時死區(temporal dead zone, TDZ), 也就是用 let 或 const 宣告的變數. 訪問 TDZ 中的變數會觸發執行時的錯誤, 只有執行過變數宣告語句後, 變數才會從 TDZ 中移出, 這時才可訪問.
下面這個例子你能不能全部答對.
typeof null; // 'object'
typeof []; // 'object'
typeof someStr; // 'undefined'
typeof str; // Uncaught ReferenceError: str is not defined
const str = 'Yancey';
複製程式碼
第一個, 因為 null
根本上是一個指標, 所以會返回 'object'
. 深層次一點, 不同的物件在底層都表示為二進位制, 在 Javascript 中二進位制前三位都為 0 的會被判斷為 Object 型別, null 的二進位制全為 0, 自然前三位也是 0, 所以執行 typeof 時會返回 'object'
.
第二個想強調的是, typeof 判斷一個引用型別的變數, 拿到的都是 'object'
, 因此該操作符無法正確辨別具體的型別, 如 Array 還是 RegExp.
第三個, 當 typeof 一個 未宣告 的變數, 不會報錯, 而是返回 'undefined'
第四個, str
先是存在於 TDZ, 上面說到訪問 TDZ 中的變數會觸發執行時的錯誤, 所以這段程式碼直接報錯.
函式提升
函式宣告和變數宣告都會被提升, 但值得注意的是, 函式首先被提升, 然後才是變數.
test();
function test() {
foo();
bar();
var foo = function() {
console.log("this won't run!");
};
function bar() {
console.log('this will run!');
}
}
複製程式碼
上面的程式碼會變成下面的形式: 內部的 bar
函式會被提升到頂部, 所以可以被執行到;接下來變數 foo
會被提升到頂部, 但變數無法執行, 因此執行 foo()
會報錯.
function test() {
var foo;
function bar() {
console.log('this will run!');
}
foo();
bar();
foo = function() {
console.log("this won't run!");
};
}
test();
複製程式碼
閉包
閉包是指那些能夠訪問獨立(自由)變數的函式(變數在本地使用, 但定義在一個封閉的作用域中). 換句話說, 這些函式可以「記憶」它被建立時候的環境. -- MDN
閉包是有權訪問另一個函式作用域的函式. -- 《JavaScript 高階程式設計(第 3 版)》
函式物件可以通過作用域鏈相互關聯起來, 函式體內部的變數都可以儲存在函式作用域內, 這種特性在電腦科學文獻中稱為閉包. -- 《JavaScript 權威指南(第 6 版)》
當函式可以記住並訪問所在的詞法作用域時, 就產生了閉包, 即使函式是在當前詞法作用域之外執行. -- 《你不知道的 JavaScript(上卷)》
似乎最後一個解釋更容易理解, 所以我們從"記住並訪問"來學習閉包.
何為"記住"
在 JavaScript 中, 如果函式被呼叫過了, 並且以後不會被用到, 那麼垃圾回收機制(後面會說到)就會銷燬由函式建立的作用域. 我們知道, 引用型別的變數只是一個指標, 並不會把真正的值拷貝給變數, 而是把物件所在的位置傳遞給變數. 因此, 當函式被傳遞到一個還未銷燬的作用域的某個變數時, 由於變數存在, 所以函式會存在, 又因為函式的存在依賴於函式所在的詞法作用域, 所以函式所在的詞法作用域也會存在, 這樣一來, 就"記住"了該詞法作用域.
看下面這個例子. 在執行 apple
函式時, 將 output
的引用作為引數傳遞給了 fruit
函式的 arg
, 因此在 fruit
函式執行期間, arg
是存在的, 所以 output
也是存在的, 而 output
依賴的 apple
函式產生的區域性作用域也是存在. 這也就是 output
函式"記住"了 apple
函式作用域的原因.
function apple() {
var count = 0;
function output() {
console.log(count);
}
fruit(output);
}
function fruit(arg) {
console.log('fruit');
}
apple(); // fruit
複製程式碼
"記住" 並 "訪問"
但上面的例子並不是完整的"閉包", 因為只是"記住"了作用域, 但沒有去"訪問"這個作用域. 我們稍微改造一下上面這個例子, 在 fruit
函式中執行 arg
函式, 實際就是執行 output
, 並且還訪問了 apple
函式中的 count
變數.
function apple() {
var count = 0;
function output() {
console.log(count);
}
fruit(output);
}
function fruit(arg) {
arg(); // 這就是閉包!
}
apple(); // 0
複製程式碼
迴圈和閉包
下面是一道經典的面試題. 我們希望程式碼輸出 0 ~ 4, 每秒一次, 每次一個. 但實際上, 這段程式碼在執行時會以每秒一次的頻率輸出五次 5.
for (var i = 0; i < 5; i++) {
setTimeout(function timer() {
console.log(i);
}, i * 1000);
}
複製程式碼
因為 setTimeout 是非同步執行的, 1000 毫秒後向任務佇列裡新增一個任務, 只有主執行緒上的任務全部執行完畢才會執行任務佇列裡的任務, 所以當主執行緒 for 迴圈執行完之後 i 的值為 5, 而用這個時候再去任務佇列中執行任務, 因此 i 全部為 5. 又因為在 for 迴圈中使用 var
宣告的 i
是在全域性作用域中, 因此 timer
函式中列印出來的 i
自然是都是 5.
我們可以通過在迭代內使用 IIFE 來給每個迭代都生成一個新的作用域, 使得延遲函式的回撥可以將新的作用域封閉在每個迭代內部, 每個迭代中都會含有一個具有正確值的變數供我們訪問. 程式碼如下所示.
for (var i = 0; i < 5; i++) {
(function(j) {
setTimeout(function timer() {
console.log(j);
}, j * 1000);
})(i);
}
複製程式碼
當然最好的方式是使用 let 宣告 i, 這時候變數 i 就能作用於這個迴圈塊, 每個迭代都會使用上一個迭代結束的值來初始化這個變數.
for (let i = 0; i < 5; i++) {
setTimeout(function timer() {
console.log(i);
}, i * 1000);
}
複製程式碼
垃圾回收
上面提到, 函式被呼叫過了, 並且以後不會被用到, 那麼垃圾回收機制就會銷燬由函式建立的作用域. JavaScript 有兩種垃圾回收機制, 即 標記清除 和 引用計數, 對於現代瀏覽器, 絕大多數都會採用 標記清除.
標記清除
垃圾收集器在執行的時候會給儲存在記憶體中的所有變數加上標記, 然後它會去掉環境中變數以及被環境中的變數引用的變數的標記. 而在此之後再被加上標記的變數將被視為準備刪除的變數, 原因是環境中的變數已經無法訪問到這些變數了. 最後, 垃圾收集器完成記憶體清除工作, 銷燬那些帶標記的值並且回收它們所佔用的記憶體空間.
引用計數
引用計數是跟蹤記錄每個值被引用的次數. 當宣告瞭一個變數並將一個引用型別值賦給該變數時, 這個值得引用次數就是 1;相反, 如果包含對這個值引用的變數又取得了另外一個值, 則這個值得引用次數減 1;下次執行垃圾回收器時就可以釋放那些引用次數為 0 的值所佔用的記憶體. 缺點:迴圈引用會導致引用次數永遠不為 0.
總結
Q: 什麼是作用域?
A: 作用域是根據名稱查詢變數的一套規則.
Q: 什麼是作用域鏈?
A: 當一個塊或函式巢狀在另一個塊或另一個函式中時, 就發生了作用域巢狀. 因此, 在當前作用域下找不到某個變數時, 會往外層巢狀的作用域繼續查詢, 直到找到該變數或抵達全域性作用域, 如果在全域性作用域中還沒找到就會報錯. 這種逐級向上查詢的模式就是作用域鏈.
Q: 什麼是閉包?
A: 當函式可以記住並訪問所在的詞法作用域時, 就產生了閉包, 即使函式是在當前詞法作用域之外執行.
最後
導致這篇文章寫這麼長的根本原因就是 面試 該死的 var
關鍵字! 它就是一個設計錯誤!不要去用它!
以一道筆試題收尾:寫一個函式, 第一次呼叫返回 0, 之後每次呼叫返回比之前大 1. 這道題不難, 主要是在考察閉包和立即執行函式. 我寫的答案如下, 如果你有更好的方案請在評論區分享.
const add = (() => {
let num = 0;
return () => num++;
})();
複製程式碼
參考
《JavaScript 高階程式設計 (第三版)》 —— Nicholas C. Zakas
《深入理解 ES6》 —— Nicholas C. Zakas
《你不知道的 JavaScript (上卷)》—— Kyle Simpson