對于任何編程語言來說个唧,都有一個很基礎(chǔ)但也很重要的概念:變量的管理江解;它包括變量的聲明,變量的賦值徙歼,變量的存儲犁河,變量的查找鳖枕,變量的更改,變量的銷毀等桨螺。而從另外一個角度來看這一系列問題就可以理解為:這個變量存在哪兒宾符?存活多久?怎樣才能找到它灭翔?在JS中魏烫,解決這些問題的基礎(chǔ)就是作用域,同時了解作用域也是學(xué)習(xí)閉包的基礎(chǔ)
1. 需要理解的概念
在闡述作用域的概念之前肝箱,首先需要了解的是哄褒,在面對一段程序的時候,JS內(nèi)部是如何進(jìn)行處理的煌张,有一個流傳很廣的說法是JS是解釋型語言呐赡,而非編譯型語言,其實JS程序的執(zhí)行也是需要編譯的骏融,只是其不是預(yù)編譯的链嘀,而是在程序段執(zhí)行之前進(jìn)行的臨時編譯,其編譯過程分為下面幾步:
- 分詞/語法分析绎谦,如
var a = 2;
就會被分為var
,a
,=
,2
等標(biāo)記 - 解析管闷,將上一步得出的所有標(biāo)記轉(zhuǎn)換為一個元素樹,其實可以看做是該段程序的語法結(jié)構(gòu)窃肠;這個元素樹統(tǒng)稱為"AST"(abstract syntax tree)
- 生成可執(zhí)行碼,即將上一步代碼塊對應(yīng)的AST轉(zhuǎn)換為機(jī)器可執(zhí)行的指令
上面三部過程需要涉及到三個重要的角色:
- 引擎刷允,負(fù)責(zé)JS代碼的編譯與執(zhí)行
- 編譯器冤留,引擎的好朋友;主要為引擎做一些準(zhǔn)備工作树灶,如解析纤怒,生成可執(zhí)行碼
- 作用域,引擎的另一個好朋友天通;主要負(fù)責(zé)管理程序?qū)?yīng)的元素(變量泊窘,方法等),同時定義一套規(guī)則像寒,該規(guī)則約束當(dāng)前程序可以訪問哪些元素
當(dāng)面對代碼段var a = 2;
的時候烘豹,編譯器會執(zhí)行下列步驟:
- 編譯器詢問作用域是否已經(jīng)存在一個叫a的變量,若存在诺祸,則進(jìn)入下一步携悯,若不存在,則通知作用域創(chuàng)建一個叫a的變量
- 編譯器為引擎生成可執(zhí)行碼筷笨,然后引擎詢問當(dāng)前作用域是否在可以訪問的a變量憔鬼,若存在龟劲,則用之,否則轴或,引擎將前往別處尋找(嵌套作用域)
- 如果引擎最終找到了變量a昌跌,則將2賦值給變量a,否則引擎將報錯
2. 作用域中變量的聲明與賦值
2.1 Hoisting
JS在面對一個變量的聲明與賦值的時候照雁,會首先在編譯期對變量聲明進(jìn)行處理蚕愤,然后在執(zhí)行期對變量進(jìn)行賦值;而在編譯器進(jìn)行代碼編譯的時候囊榜,會將變量或方法的聲明由其代碼申明處提至語義作用域(語義作用域?qū)⒃诤罄m(xù)章節(jié)中做詳細(xì)解釋)的頂部审胸,這個過程就稱為Hoisting或變量提升,Hoisting也是作用域中變量聲明與賦值的核心和難點卸勺,首先看下面兩段代碼:
a = 2;
var a;
console.log( a );
console.log( a );
var a = 2;
經(jīng)過編譯器編譯后砂沛,上面第一段代碼會被轉(zhuǎn)換為:
var a;
a = 2;
console.log( a );//2
而第二段代碼將被轉(zhuǎn)換為:
var a;
console.log( a );
a = 2;//undefined
可以發(fā)現(xiàn),由于Hoisting的存在曙求,在一個語義作用域內(nèi)碍庵,只要存在變量聲明,無論該聲明語句處于什么位置悟狱,都會在執(zhí)行前被提至語義作用域的頂部静浴,需要注意的是只有聲明會被Hoisting
,而賦值不會做任何處理挤渐,維持原順序苹享;同時Hoisting
只會在當(dāng)前語義作用域中起效
方法的聲明也一樣會在編譯期執(zhí)行Hoisting
,如:
foo();
function foo() {
console.log( a ); // undefined
var a = 2;
}
將會在編譯期是轉(zhuǎn)換為:
function foo() {
var a;
console.log( a ); // undefined
a = 2;
}
foo();
又如:
foo();
var foo = function bar() {
// ...
};
將會在編譯期是轉(zhuǎn)換為:
var foo;
foo();
foo = function bar() {
// ...
};
又又如:
foo();
bar();
var foo = function bar() {
// ...
};
將會在編譯期是轉(zhuǎn)換為:
var foo;
foo(); // TypeError
bar(); // ReferenceError
foo = function() {
var bar = ...self...
// ...
}
初學(xué)JS的人經(jīng)常會很奇怪為什么JS代碼中浴麻,經(jīng)常會出現(xiàn)對某個變量或方法的使用出現(xiàn)在其聲明的前面得问,而JS引擎照樣可以正常的執(zhí)行,不會報錯软免,這些要?dú)w功于Hoisting
2.2 變量Hoisting與方法Hoisting的優(yōu)先級
如果在語義作用域中同時存在變量Hoisting和方法Hoisting宫纬,JS也規(guī)定了它們的優(yōu)先級:
方法Hoisting優(yōu)先級 > 變量Hoisting優(yōu)先級
如:
foo();
var foo;
function foo() {
console.log( 1 );
}
foo = function() {
console.log( 2 );
};
將會在編譯期是轉(zhuǎn)換為:
function foo() {
console.log( 1 );
}
foo(); // 1
foo = function() {
console.log( 2 );
};
在這段代碼中,有兩個同名的聲明膏萧,變量foo與方法foo漓骚,首先,方法foo將會被Hoisting榛泛,同時后續(xù)的變量foo的聲明將會被忽略(因為JS引擎已經(jīng)找到了變量foo蝌蹂,那么它就不會重新去聲明一個同名變量)
在代碼塊里定義的方法也將被Hoisting
foo(); // "b"
var a = true;
if (a) {
function foo() { console.log( "a" ); }
}
else {
function foo() { console.log( "b" ); }
}
將會在編譯期是轉(zhuǎn)換為:
function foo() { console.log( "a" ); }
function foo() { console.log( "b" ); }
foo(); // "b"
var a = true;
if (a) {
}
else {
}
3. 作用域中變量的找尋機(jī)制
JS引擎在編譯包含有變量a
的代碼時,會在作用域中找尋變量a
挟鸠,總體來說有兩種找尋方式叉信,分別為:
- LHS:Left-hand Side
- RHS:Right-hand Side
這里的side指的是assignment operation,即通過賦值操作區(qū)分是LHS還是RHS艘希,如果變量在賦值操作的左邊硼身,則是LHS硅急;而RHS卻不能簡單定義為變量在assignment operation的右邊,應(yīng)該理解為非LHS的即為RHS佳遂,從變量找尋與賦值的角度來說营袜,LHS指的是找尋變量本身,而RHS指的是獲取變量的值丑罪,如:
-
var a = 1;
荚板,變量在賦值操作符"="的左邊,所以屬于LHS -
console.log( a );
吩屹,變量不在賦值操作符的左邊跪另,而是直接獲取變量的值,所以屬于RHS
這里之所以要介紹著兩種變量找尋方式煤搜,是因為這兩種變量找尋方式在作用域中會有不同的表現(xiàn)免绿,如:在RHS模式下,如果找到了對應(yīng)變量擦盾,則返回該變量嘲驾,反之未找到對應(yīng)變量,會彈出ReferenceError
迹卢;而在LHS模式下辽故,如果未找到對應(yīng)變量,則根據(jù)不同情況作出不同反應(yīng)腐碱,如果是“Strict Mode”下誊垢,則會彈出ReferenceError
,而非“Strict Mode”則在當(dāng)前作用域下自動創(chuàng)建該變量
4. JS中的作用域(語義作用域)
說了這么多症见,如何識別JS中的作用域呢彤枢?首先從大的層面了解一下作用域的分類,一般來說作用域可分為兩種:
- 語義作用域筒饰,即在"分詞/語法分析"定義的作用域,或者說在代碼編寫階段就已經(jīng)決定了作用域的結(jié)構(gòu)范圍壁晒,JS使用的就是語義作用域
- 動態(tài)作用域瓷们,Bash腳本,Perl中依然使用的是動態(tài)作用域秒咐,本文不予討論
而在JS中谬晕,根據(jù)代碼形式,作用域也可以分為兩種:
- 函數(shù)作用域携取,顧名思義攒钳,函數(shù)作用域就是通過定義一個JS的function而生成的作用域,在JS中所謂的語義作用域指的就是函數(shù)作用域雷滋,這一點一定要記清楚
- 塊級作用域不撑,而塊級作用域則是通過定義一個JS的代碼塊生成的作用域文兢,塊級作用域的典型示例:
-
{}
,即單獨(dú)的代碼塊 -
for(;;) {}
焕檬,即for循環(huán)代碼塊 -
if() {}
姆坚,即if判斷代碼塊
-
塊級作用域其實只是形式上的作用域,它并是嚴(yán)格意義上的語義作用域实愚,所以會出現(xiàn)代碼塊里的變量聲明直接被Hoisting其外部語義作用域(函數(shù)作用域)頂部的情況
那么除開寫法上的不同兼呵,函數(shù)作用域和塊級作用域主要有什么區(qū)別呢?其實它們最重要的區(qū)別在于函數(shù)作用域可以進(jìn)行有效的變量隔離腊敲,即在函數(shù)作用域里定義的變量不會影響其嵌套作用域击喂,這在模塊化開發(fā)里尤其有用,它可以保證在A模塊定義的變量不會影響與B模塊的同名變量碰辅,更不會污染global作用域懂昂,典型的函數(shù)作用域示例:
- 方法定義與調(diào)用
function foo(a) {
var b = a * 2;
function bar(c) {
console.log( a, b, c );
}
bar(b * 3);
}
foo( 2 ); // 2 4 12
- IIFE(Invoking Function Expressions Immediately)
var a = 2;
(function foo(){
var a = 3;
console.log( a ); // 3
})();
console.log( a ); // 2
需要注意的是IIFE的方法不能在外部語義scope里再次調(diào)用,如:
(function foo() {
a = 2;
console.log("a is " + a);
})();
foo();//ReferenceError
看下列示例乎赴,并思考這段代碼中包含有幾個函數(shù)(語義)作用域:
function foo(a) {
var b = a * 2;
function bar(c) {
console.log( a, b, c );
}
bar(b * 3);
}
foo( 2 ); // 2 4 12
這段代碼有三個函數(shù)作用域:
- 作用域1:全局作用域忍法,只定義了一個變量
foo
- 作用域2:
foo
方法內(nèi)的作用域,定義了三個變量榕吼,b
,a
和bar
- 作用域3:方法
bar
內(nèi)的作用域饿序,定義了一個變量c
其中,作用域1是作用域2的嵌套作用域羹蚣,而2又是3的嵌套作用域原探,如在作用域3中需要使用變量a
的值,但是此時在自己的作用域中并未找到變量a
顽素,那么就會到其上一級嵌套作用域咽弦,也就是作用域2中找尋變量a
,以此類推胁出;同時語義作用域只與方法的定義位置有關(guān)型型,與其調(diào)用位置毫無關(guān)系(所以也叫[語義]作用域) ;另外全蝶,在根據(jù)語義作用域進(jìn)行變量找尋的時候闹蒜,只適用于單獨(dú)變量的情況,如a
,b
等抑淫,而對于通過對象屬性找尋變量的情況绷落,如foo.bar.baz
就不是根據(jù)語義作用域進(jìn)行變量的找尋,而是通過對象屬性訪問規(guī)則找尋其對應(yīng)變量
上面已經(jīng)說過始苇,塊級作用域其實只是相當(dāng)于形式上的作用域砌烁,沒有任何變量隔離效果,如下面代碼:
function foo() {
function bar(a) {
i = 3; // 就是for循環(huán)中創(chuàng)建的變量i
console.log( a + i );
}
for (var i=0; i<10; i++) {// i屬于foo方法所創(chuàng)造的作用域
bar( i * 2 ); // 死循環(huán)
}
}
foo();
即在塊級作用域中定義的變量實際上還是屬于其對應(yīng)的語義作用域內(nèi)催式,或者說離它最近的函數(shù)作用域函喉,這一點很容易造成錯誤
function foo() {
var i = 1;
for (var i=0; i<10; i++) {// 由于在foo方法創(chuàng)造的作用域中避归,變量i已經(jīng)存在,所以此時for循環(huán)中的i其實就是
//上面的"var i = 1;"創(chuàng)建的i
//do something
}
console.log("now i is " + i);//10
}
foo();
到了ES6函似,可以通過let
與const
實現(xiàn)塊級作用域的變量隔離槐脏,即通過let
在塊級作用域中聲明變量,該變量將只會存在于該塊級作用域中
function foo() {
var i = 1;
for (let i=0; i<10; i++) {
//do something
}
console.log("now i is " + i);//1
}
foo();
雖然說塊級作用域并沒有變量隔離的效果撇寞,但是使用得當(dāng)顿天,塊級作用域也能發(fā)揮意想不到的用處,如:加快垃圾清理蔑担,來看下面代碼
function process(data) {
// do process
}
var someReallyBigData = { .. };
process( someReallyBigData );
var btn = document.getElementById( "my_button" );
btn.addEventListener( "click", function click(evt){// 閉包的存在
console.log("button clicked");
}, /*capturingPhase=*/false );
可以發(fā)現(xiàn)在click事件對應(yīng)的方法中牌废,someReallyBigData
完全無用,可以將其回收掉啤握,以減輕內(nèi)存負(fù)擔(dān)鸟缕,但由于有閉包的存在,JS并不會馬上對其進(jìn)行回收排抬,那么此時可以采用下列寫法
function process(data) {
// do process
}
// block scope定義的任何數(shù)據(jù)都可以在scope結(jié)束后清理掉
{
let someReallyBigData = { .. };
process( someReallyBigData );
}
var btn = document.getElementById( "my_button" );
btn.addEventListener( "click", function click(evt){
console.log("button clicked");
}, /*capturingPhase=*/false );
這段代碼里懂从,將大量臨時數(shù)據(jù)的處理放置于外部語義作用域的塊級作用域中,它不會受到閉包的影響蹲蒲,在執(zhí)行完成后會被JS的垃圾回收機(jī)制及時清理