雖然做用域相關知識是 JavaScript 的基礎, 但要完全理解必需要從原理入手. 從面試角度來說, 詞法/動態做用域、做用域(鏈)、變量/函數提高、閉包、垃圾回收 實屬一類題目, 打通這幾個概念並熟練掌握, 面試基本就不用擔憂這一塊了. 這篇文章是對《JavaScript 高級程序設計 (第三版)》第四章, 一樣也是 《你不知道的 JavaScript (上卷)》第一部分的學習和總結.javascript
對於大部分編程語言, 編譯大體有三個步驟.html
分詞/詞法分析 (Tokenizing/Lexing)前端
此過程將源代碼分解成 詞法單元 (token)
, 如代碼 const firstName = 'Yancey'
會被分解成 const
, firstName
, =
, 'Yancey'
, 空格是否會被當成詞法單元, 取決於空格對這門語言的意義. 這裏推薦一個網站 Parser 能夠用來解析 JavaScript 的源代碼. 對於這個例子, 分詞結構以下.java
[
{
type: 'Keyword',
value: 'const',
},
{
type: 'Identifier',
value: 'firstName',
},
{
type: 'Punctuator',
value: '=',
},
{
type: 'String',
value: "'Yancey'",
},
];
複製代碼
解析/語法分析 (Parsing)webpack
這個過程將詞法單元流轉換成一棵 抽象語法樹 (Abstract Syntax Tree, AST). 語法分析會根據 ECMAScript 的標準來解析成 AST, 好比你寫了 const new = 'Yancey'
, 就會報錯 Uncaught SyntaxError: Unexpected token new.git
對於上面那個例子, 生成的 AST 以下圖所示, 其中 Identifier
表明着變量名, Literal
表明着變量的值.github
代碼生成web
這個階段就是將 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(){....})()
. 此外, 模塊化 (ES六、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);
}
複製代碼
若是你 API 看得仔細的話,還能夠寫成下面的形式:
for (var i = 0; i < 5; i++) {
setTimeout(function(j) {
console.log(j);
}, i * 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
歡迎關注個人公衆號:進擊的前端