作用域
作用域是一套存儲(chǔ)、訪問變量的規(guī)則。這套規(guī)則用來管理引擎如何在當(dāng)前作用域以及嵌套的子作用域中根據(jù)標(biāo)識(shí)符名稱進(jìn)行變量查找。如果查找的目的是對(duì)變量進(jìn)行賦值,那么就會(huì)使用 LHS 查詢;如果目的是獲取變量的值,就會(huì)使用 RHS 查詢。
當(dāng)變量出現(xiàn)在賦值操作的左側(cè)時(shí)進(jìn)行 LHS 查詢,出現(xiàn)在右側(cè)時(shí)進(jìn)行 RHS 查詢。
賦值操作符會(huì)導(dǎo)致 LHS 查詢。=操作符或調(diào)用函數(shù)時(shí)傳入?yún)?shù)的操作都會(huì)導(dǎo)致關(guān)聯(lián)作用域的賦值操作(即 LHS 查詢)。
編譯原理
傳統(tǒng)編程語言的編譯過程:
graph TD
A[分詞/詞法分析] -->B(解析/語法分析)
B --> C[代碼生成]
- 分詞/詞法分析:將由字符組成的字符串分解成(對(duì)編程語言來說)有意義的代碼塊,這些代碼塊被稱為詞法單元(token)。
- 解析/語法分析:將詞法單元流(數(shù)組)轉(zhuǎn)換成一個(gè)由元素逐級(jí)嵌套所組成的代表了程序語法結(jié)構(gòu)的樹。這個(gè)樹被稱為“抽象語法樹”(Abstract Syntax Tree,AST)。
- 代碼生成:將 AST 轉(zhuǎn)換為可執(zhí)行代碼。
JavaScript 引擎的特點(diǎn):
- JavaScript 不是提前編譯的,編譯結(jié)果也不能在分布式系統(tǒng)中進(jìn)行移植。
- 在語法分析和代碼生成階段有特定的步驟來對(duì)運(yùn)行性能進(jìn)行優(yōu)化,包括對(duì)冗余元素進(jìn)行優(yōu)化等。
- JavaScript 引擎不會(huì)有大量的(像其他語言編譯器那么多的)時(shí)間用來進(jìn)行優(yōu)化,因?yàn)榕c其他語言不同,JavaScript 的編譯過程不是發(fā)生在構(gòu)建之前的。大部分情況下編譯發(fā)生在代碼執(zhí)行前的幾微秒(甚至更短!)的時(shí)間內(nèi)。
簡(jiǎn)單地說,任何 JavaScript 代碼在執(zhí)行前都要進(jìn)行編譯。
理解作用域
引擎:從頭到尾負(fù)責(zé)整個(gè) JavaScript 程序的編譯及執(zhí)行過程。
編譯器:負(fù)責(zé)語法分析及代碼生成等。
作用域:負(fù)責(zé)收集并維護(hù)由所有聲明的標(biāo)識(shí)符(變量)組成的一系列查詢,并實(shí)施一套非常嚴(yán)格的規(guī)則,確定當(dāng)前執(zhí)行的代碼對(duì)這些標(biāo)識(shí)符的訪問權(quán)限。
/* 變量的賦值操作:
* 編譯器首先會(huì)在當(dāng)前作用域中聲明一個(gè)變量(如果之前沒有聲明過);
* 在運(yùn)行時(shí),引擎會(huì)在作用域中查找該變量,如果能夠找到就會(huì)對(duì)它賦值。
*/
var a = 2;
編譯器首先會(huì)將這段程序分解成詞法單元,然后將詞法單元解析成一個(gè)樹結(jié)構(gòu)。接下來,編譯器執(zhí)行代碼生成流程:
-
var a聲明變量:「編譯器」會(huì)詢問「作用域」是否已經(jīng)有一個(gè)該名稱的變量存在于同一個(gè)作用域的集合中。如果是,編譯器會(huì)忽略該聲明,繼續(xù)進(jìn)行編譯;否則它會(huì)要求作用域在當(dāng)前作用域的集合中聲明一個(gè)新的變量,并命名為a。 -
a = 2賦值:「編譯器」會(huì)為「引擎」生成運(yùn)行時(shí)所需的代碼。「引擎」運(yùn)行時(shí)會(huì)首先詢問「作用域」,在當(dāng)前的作用域集合中是否存在一個(gè)叫作a的變量。如果是,引擎就會(huì)使用這個(gè)變量;如果否,引擎會(huì)繼續(xù)查找該變量。
編譯器查詢?cè)恚篖HS 和 RHS 查詢
當(dāng)變量出現(xiàn)在賦值操作的左側(cè)時(shí)進(jìn)行 LHS 查詢,出現(xiàn)在右側(cè)時(shí)進(jìn)行 RHS 查詢。
LHS 查詢:找到變量的容器本身,從而可以對(duì)其賦值。
RHS 查詢:查詢某個(gè)變量的值;
// 對(duì) a 的引用是一個(gè) RHS 引用
// 因?yàn)檫@里 a 并沒有賦予任何值。相應(yīng)地,需要查找并取得 a 的值,這樣才能將值傳遞給 console.log(..)。
console.log(a);
// 對(duì) a 的引用則是 LHS 引用
// 因?yàn)閷?shí)際上我們并不關(guān)心當(dāng)前的值是什么,只是想要為 = 2 這個(gè)賦值操作找到一個(gè)目標(biāo)。
a = 2;
既有 RHS 引用,又有 LHS 引用的情況:
function foo(a) {
console.log('object :', a)
}
/*
* 首先,foo(...) 函數(shù)調(diào)用需要對(duì) foo 進(jìn)行 RHS 引用,即找到 foo 的值。
* 隱式的 a=2 操作:首先對(duì) a 進(jìn)行 LHS 查詢,再對(duì) a 進(jìn)行 RHS 引用對(duì) a 進(jìn)行賦值。
*/
foo(2)
作用域嵌套
當(dāng)一個(gè)塊或函數(shù)嵌套在另一個(gè)塊或函數(shù)中時(shí),就發(fā)生了作用域的嵌套。
因此,在當(dāng)前作用域中無法找到某個(gè)變量時(shí),引擎就會(huì)在外層嵌套的作用域中繼續(xù)查找,直到找到該變量,或抵達(dá)最外層的作用域(也就是全局作用域)為止。
把作用域鏈比喻成一個(gè)建筑

這個(gè)建筑代表程序中的嵌套作用域鏈。第一層樓代表當(dāng)前的執(zhí)行作用域,也就是你所處的位置。建筑的頂層代表全局作用域。
LHS 和 RHS 引用都會(huì)在當(dāng)前樓層進(jìn)行查找,如果沒有找到,就會(huì)坐電梯前往上一層樓,如果還是沒有找到就繼續(xù)向上,以此類推。一旦抵達(dá)頂層(全局作用域),可能找到了你所需的變量,也可能沒找到,但無論如何查找過程都將停止。
異常
在變量還沒有聲明(在任何作用域中都無法找到該變量)的情況下,LHS 和 RHS 查詢的行為是不一樣的。
非嚴(yán)格模式下:
- 如果 RHS 查詢?cè)谒星短椎淖饔糜蛑斜閷げ坏剿璧淖兞浚婢蜁?huì)拋出
ReferenceError異常。 - 當(dāng)引擎執(zhí)行 LHS 查詢時(shí),如果在頂層(全局作用域)中也無法找到目標(biāo)變量,全局作用域中就會(huì)創(chuàng)建一個(gè)具有該名稱的變量,并將其返還給引擎,前提是程序運(yùn)行在非“嚴(yán)格模式”下。
嚴(yán)格模式下:
-
嚴(yán)格模式禁止自動(dòng)或隱式地創(chuàng)建全局變量。因此,在嚴(yán)格模式中 LHS 查詢失敗時(shí),并不會(huì)創(chuàng)建并返回一個(gè)全局變量,引擎會(huì)拋出同 RHS 查詢失敗時(shí)類似的
ReferenceError異常。 - 如果 RHS 查詢找到了一個(gè)變量,但是你嘗試對(duì)這個(gè)變量的值進(jìn)行不合理的操作,那么引擎會(huì)拋出另外一種類型的異常,叫作
TypeError。 -
ReferenceError同作用域判別失敗相關(guān),而TypeError則代表作用域判別成功了,但是對(duì)結(jié)果的操作是非法或不合理的。
詞法作用域
作用域的兩種工作模型:
- 詞法作用域;
- 動(dòng)態(tài)作用域。
JavaScript 中的作用域就是詞法作用域。
1.詞法階段
詞法作用域就是定義在詞法階段的作用域。詞法作用域意味著作用域是由書寫代碼時(shí)函數(shù)聲明的位置來決定的。編譯的詞法分析階段基本能夠知道全部標(biāo)識(shí)符在哪里以及是如何聲明的,從而能夠預(yù)測(cè)在執(zhí)行過程中如何對(duì)它們進(jìn)行查找。
該示例中有三個(gè)逐級(jí)嵌套的作用域。為了幫助理解,可以將它們想象成幾個(gè)逐級(jí)包含的氣泡:

- 包含著整個(gè)全局作用域,其中只有一個(gè)標(biāo)識(shí)符:foo。
- 包含著 foo 所創(chuàng)建的作用域,其中有三個(gè)標(biāo)識(shí)符:a、bar 和 b。
- 包含著 bar 所創(chuàng)建的作用域,其中只有一個(gè)標(biāo)識(shí)符:c。
2.查找
作用域氣泡的結(jié)構(gòu)和互相之間的位置關(guān)系給引擎提供了足夠的位置信息,引擎用這些信息來查找標(biāo)識(shí)符的位置。
- 作用域查找始終從運(yùn)行時(shí)所處的最內(nèi)部作用域開始,逐級(jí)向外或者說向上進(jìn)行查找,直到遇見第一個(gè)匹配的標(biāo)識(shí)符為止。
- 作用域查找會(huì)在找到第一個(gè)匹配的標(biāo)識(shí)符時(shí)停止。在多層的嵌套作用域中可以定義同名的標(biāo)識(shí)符,這叫作“遮蔽效應(yīng)”(內(nèi)部的標(biāo)識(shí)符“遮蔽”了外部的標(biāo)識(shí)符)。
- 詞法作用域查找只會(huì)查找一級(jí)標(biāo)識(shí)符。
欺騙詞法
?? 欺騙詞法作用域會(huì)導(dǎo)致性能下降,因?yàn)橐鏌o法在編譯時(shí)對(duì)作用域查找進(jìn)行優(yōu)化,故不推薦使用。
eval() 不推薦使用
eval()JavaScript 中的 eval(...) 函數(shù)可以接受一個(gè)字符串為參數(shù),并將其中的內(nèi)容視為好像在書寫時(shí)就存在于程序中這個(gè)位置的代碼。換句話說,可以在你寫的代碼中用程序生成代碼并運(yùn)行,就好像代碼是寫在那個(gè)位置的一樣。
JavaScript 中的 eval(...) 函數(shù)可以對(duì)一段包含一個(gè)或多個(gè)聲明的“代碼”字符串進(jìn)行演算,并借此來修改已經(jīng)存在的詞法作用域(在運(yùn)行時(shí))。
默認(rèn)情況下,如果 eval(..) 中所執(zhí)行的代碼包含有一個(gè)或多個(gè)聲明(無論是變量還是函數(shù)),就會(huì)對(duì) eval(..) 所處的詞法作用域進(jìn)行修改。
function foo(str, a) {
// 這段代碼實(shí)際上在 foo(..) 內(nèi)部創(chuàng)建了一個(gè)變量 b,并遮蔽了外部(全局)作用域中的同名變量。
eval( str ); // 欺騙!
console.log( a, b );
}
var b = 2;
foo( "var b = 3;", 1 ); // 1 3
在程序中動(dòng)態(tài)生成代碼的使用場(chǎng)景非常罕見,因?yàn)樗鶐淼暮锰師o法抵消性能上的損失。
with 不推薦使用
withwith 通常被當(dāng)作重復(fù)引用同一個(gè)對(duì)象中的多個(gè)屬性的快捷方式,可以不需要重復(fù)引用對(duì)象本身。
with 可以將一個(gè)沒有或有多個(gè)屬性的對(duì)象處理為一個(gè)完全隔離的詞法作用域,因此這個(gè)對(duì)象的屬性也會(huì)被處理為定義在這個(gè)作用域中的詞法標(biāo)識(shí)符。
with 本質(zhì)上是通過將一個(gè)對(duì)象的引用當(dāng)作作用域來處理,將對(duì)象的屬性當(dāng)作作用域中的標(biāo)識(shí)符來處理,從而創(chuàng)建了一個(gè)新的詞法作用域(同樣是在運(yùn)行時(shí))。
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
// o2 的作用域、foo(..) 的作用域和全局作用域中都沒有找到標(biāo)識(shí)符 a,因此當(dāng) a=2 執(zhí)行 時(shí),自動(dòng)創(chuàng)建了一個(gè)全局變量(因?yàn)槭欠菄?yán)格模式)。
console.log( a ); // 2 -- Oops, leaked global!
函數(shù)作用域和塊作用域
函數(shù)是 JavaScript 中最常見的作用域單元,除此之外,JavaScript 中也有幾個(gè)叫做「塊作用域」的特性(如let、const)。
函數(shù)中的作用域
函數(shù)作用域:屬于這個(gè)函數(shù)的全部變量都可以在整個(gè)函數(shù)的范圍內(nèi)使用及復(fù)用(事實(shí)上在嵌套的作用域中也可以使用)。
隱藏內(nèi)部實(shí)現(xiàn)
由「函數(shù)作用域」這一規(guī)則所引發(fā)的啟示:可以把變量和函數(shù)包裹在一個(gè)函數(shù)的作用域中,然后用這個(gè)作用域來“隱藏”它們。
最小特權(quán)原則/最小授權(quán)或最小暴露原則:在軟件設(shè)計(jì)中,應(yīng)該最小限度地暴露必要內(nèi)容,而將其他內(nèi)容都“隱藏”起來,比如某個(gè)模塊或?qū)ο蟮?API 設(shè)計(jì)。
規(guī)避沖突
“隱藏”作用域中的變量和函數(shù)可以避免同名標(biāo)識(shí)符之間的沖突,兩個(gè)標(biāo)識(shí)符可能具有相同的名字但用途卻不一樣,無意間可能造成命名沖突。沖突會(huì)導(dǎo)致變量的值被意外覆蓋。
- 全局命名空間;
- 模塊機(jī)制:通過依賴管理器的機(jī)制將庫(kù)的標(biāo)識(shí)符顯式地導(dǎo)入到另外一個(gè)特定的作用域中。
函數(shù)作用域
在任意代碼片段外部添加包裝函數(shù),可以將內(nèi)部的變量和函數(shù)定義“隱藏”起來,外部作用域無法訪問包裝函數(shù)內(nèi)部的任何內(nèi)容。
var a = 2;
// 函數(shù)表達(dá)式語法
// (function foo(){ .. })作為函數(shù)表達(dá)式意味著foo只能在..所代表的位置中被訪問,外部作用域則不行。
// foo 被綁定在函數(shù)表達(dá)式自身的函數(shù)中
// foo 變量名被隱藏在自身中意味著不會(huì)非必要地污染外部作用域
(function foo () {
var a = 3;
console.log(a); // 3
})();
console.log(a); // 2
匿名和具名
匿名函數(shù)表達(dá)式:
// function().. 沒有名稱標(biāo)識(shí)符
setTimeout( function(){
console.log("I waited 1 second!");
}, 1000 );
函數(shù)表達(dá)式可以匿名,而函數(shù)聲明則不可以省略函數(shù)名。
匿名函數(shù)的缺點(diǎn):
- 調(diào)試?yán)щy,匿名函數(shù)在棧追蹤中不會(huì)顯示出有意義的函數(shù)名。
- 無法引用自身,如果沒有函數(shù)名,當(dāng)函數(shù)需要引用自身時(shí)只能使用已經(jīng)過期的
arguments.callee引用。 - 匿名函數(shù)沒有函數(shù)名,降低了代碼的可讀性/可理解性。一個(gè)描述性的名稱可以讓代碼不言自明。
行內(nèi)函數(shù)表達(dá)式:
setTimeout( function timeoutHandler(){ // <-- Look, I have a name!
console.log( "I waited 1 second!" );
}, 1000 );
通過「行內(nèi)函數(shù)表達(dá)式」的方式可以給函數(shù)表達(dá)式指定一個(gè)函數(shù)名,可以有效解決匿名函數(shù)的缺點(diǎn)。
??始終給函數(shù)表達(dá)式命名是一個(gè)最佳實(shí)踐。
立即執(zhí)行函數(shù)表達(dá)式(IIFE)
var a = 2;
// (function foo(){ .. })()
// 第一個(gè) ( ) 將函數(shù)變成了一個(gè)函數(shù)表達(dá)式,
// 第二個(gè) ( ) 表示立即執(zhí)行該函數(shù)。
(function foo () {
var a = 3;
console.log(a); // 3
})();
console.log(a); // 2
相較于傳統(tǒng)的 IIFE(Immediately Invoked Function Expression,立即執(zhí)行函數(shù)表達(dá)式) 形式,很多人都更喜歡另一個(gè)改進(jìn)的形式:(function(){ .. }())。兩者功能一致。
IIFE 的常見用法:
- 配合匿名函數(shù)表達(dá)式使用。
- 把匿名函數(shù)表達(dá)式當(dāng)作函數(shù)調(diào)用并傳遞參數(shù)。
var a = 2;
// 將 window 對(duì)象的引用傳遞進(jìn)去,但將參數(shù)命名為 global
(function IIFE (global) {
var a = 3;
console.log(a); // 3
console.log(global.a); // 2
})(window);
console.log(a); // 2
IIFE 的常見用法:3.解決 undefined 標(biāo)識(shí)符的默認(rèn)值被錯(cuò)誤覆蓋導(dǎo)致的異常(不常見)。
IIFE 的常見用法:4.倒置代碼的運(yùn)行順序,UMD 項(xiàng)目中被廣泛使用。
塊作用域
塊作用域:變量和函數(shù)不僅可以屬于所處的作用域,也可以屬于某個(gè)代碼塊(通常指 { .. } 內(nèi)部)。
塊作用域的用處:變量的聲明應(yīng)該距離使用的地方越近越好,并最大限度地本地化。
with
用 with 從對(duì)象中創(chuàng)建出的作用域僅在 with 聲明中而非外部作用域中有效。
try/catch
JavaScript 的 ES3 規(guī)范中規(guī)定 try/catch 的 catch 分句會(huì)創(chuàng)建一個(gè)塊作用域,其中聲明的變量?jī)H在 catch 內(nèi)部有效。
try {
undefined(); // 執(zhí)行一個(gè)非法操作來強(qiáng)制制造一個(gè)異常
}
catch (err) {
console.log(err); // 能夠正常執(zhí)行!
}
console.log(err); // ReferenceError: `err` not found
let
let 關(guān)鍵字可以自動(dòng)將變量綁定到所在的任意作用域中。
var foo = true;
if (foo) {
let bar = foo * 2;
bar = something(bar);
// 可以訪問 bar 變量
console.log(bar);
}
// {...} 外部無法訪問 bar 變量
console.log(bar); // ReferenceError 引用錯(cuò)誤
使用 let 進(jìn)行的聲明不會(huì)在塊作用域中進(jìn)行提升:
{
console.log( bar ); // ReferenceError!
let bar = 2;
}
const
ES6 引入了 const,用來創(chuàng)建塊作用域變量,但其值是固定的 (常量)。之后任何試圖修改值的操作都會(huì)引起錯(cuò)誤。
提升
- 變量/函數(shù)聲明提升:所有的聲明(變量和函數(shù))都會(huì)被“移動(dòng)”到各自作用域的最頂端。只有聲明本身會(huì)被提升,而賦值或其他運(yùn)行邏輯會(huì)留在原地。
- 函數(shù)會(huì)首先被提升,然后才是變量。
- 函數(shù)聲明會(huì)被提升,但是函數(shù)表達(dá)式卻不會(huì)被提升。
- 使用
let、const進(jìn)行聲明的變量不會(huì)在塊作用域中進(jìn)行提升。
作用域閉包
當(dāng)函數(shù)可以記住并訪問所在的詞法作用域,即使函數(shù)是在當(dāng)前詞法作用域之外執(zhí)行,這時(shí)就產(chǎn)生了閉包。
function foo () {
var a = 2;
// 函數(shù) bar() 的詞法作用域能夠訪問 foo() 的內(nèi)部作用域。
function bar () {
console.log(a);
}
// 將 bar 所引用的函數(shù)對(duì)象本身當(dāng)作返回值(把一個(gè)內(nèi)部函數(shù)當(dāng)作值返回)
return bar;
}
// 閉包:bar() 依然持有對(duì)它的作用域的引用。
// foo() 函數(shù)執(zhí)行之后,foo() 的整個(gè)內(nèi)部作用域并沒有被銷毀,因?yàn)?bar() 函數(shù)仍然引用著 foo() 的內(nèi)部作用域
var baz = foo();
// bar() 在自己定義的詞法作用域以外的地方執(zhí)行了
// 閉包使得 bar() 函數(shù)仍然可以繼續(xù)訪問它被定義時(shí)的詞法作用域
baz(); // 2 -- 朋友,這就是閉包的效果。
循環(huán)和閉包
使用 var 關(guān)鍵字:
// 盡管循環(huán)中的五個(gè)函數(shù)是在各個(gè)迭代中分別定義的,但是它們都被封閉在一個(gè)共享的全局作用域中,因此實(shí)際上只有一個(gè) i。
// for 循環(huán)外面也可以訪問變量 i
for (var i = 1; i <= 5; i++) {
// 延遲函數(shù)的回調(diào)會(huì)在循環(huán)結(jié)束時(shí)才執(zhí)行,而函數(shù)終止的條件是當(dāng) i = 6 時(shí)。
setTimeout(() => {
console.log(i);
}, i * 1000);
}
/*
6
6
6
6
6
*/
使用 let 關(guān)鍵字:
// 變量在循環(huán)過程中不止被聲明一次,每次迭代都會(huì)聲明。
// 每個(gè)迭代都會(huì)使用上一個(gè)迭代結(jié)束時(shí)的值來初始化這個(gè)變量。
for (let index = 0; index <= 5; index++) {
setTimeout(() => {
console.log(index)
}, index);
}
/*
0
1
2
3
4
5
*/
模塊
模塊的底層原理就是閉包。

模塊的兩個(gè)主要特征:
- 為創(chuàng)建內(nèi)部作用域而調(diào)用了一個(gè)包裝函數(shù);
- 包裝函數(shù)的返回值必須至少包括一個(gè)對(duì)內(nèi)部函數(shù)的引用,這樣就會(huì)創(chuàng)建涵蓋整個(gè)包裝函數(shù)內(nèi)部作用域的閉包。
模塊也是普通的函數(shù),因此可以接受參數(shù)。
模塊模式另一個(gè)簡(jiǎn)單但強(qiáng)大的變化用法是,命名將要作為公共 API 返回的對(duì)象:
未來的模塊機(jī)制
ES6 中為模塊增加了一級(jí)語法支持。但通過模塊系統(tǒng)進(jìn)行加載時(shí),ES6 會(huì)將文件當(dāng)作獨(dú)立的模塊來處理。每個(gè)模塊都可以導(dǎo)入其他模塊或特定的 API 成員,同樣也可以導(dǎo)出自己的 API 成員。
ES6 的模塊必須被定義在一個(gè)獨(dú)立的文件中(即一個(gè)文件一個(gè)模塊)。
import 可以將一個(gè)模塊中的一個(gè)或多個(gè) API 導(dǎo)入到當(dāng)前作用域中,并分別綁定在一個(gè)變量上。
module 會(huì)將整個(gè)模塊的 API 導(dǎo)入并綁定到一個(gè)變量上。
export 會(huì)將當(dāng)前模塊的一個(gè)標(biāo)識(shí)符(變量、函數(shù))導(dǎo)出為公共 API。
這些操作可以在模塊定義中根據(jù)需要使用任意多次。
bar.js
function hello(who) {
return "Let me introduce: " + who;
}
export hello;
foo.js
// 僅從 "bar" 模塊導(dǎo)入 hello()
import hello from "bar";
var hungry = "hippo";
function awesome() {
console.log(
hello( hungry ).toUpperCase()
);
}
export awesome;
baz.js
// 導(dǎo)入完整的 "foo" 和 "bar" 模塊
module foo from "foo";
module bar from "bar";
console.log(
bar.hello( "rhino" )
); // Let me introduce: rhino
foo.awesome(); // LET ME INTRODUCE: HIPPO