JavaScript?中的作用域與閉包
前言:
前幾天面試中,面試官拋出一道題,問(wèn)我輸出結(jié)果是啥:
var arr = [] for (var i = 0; i < 3; i++) { arr[i] = function() { console.log(i); } } arr[0]() arr[1]() arr[2]()
無(wú)知的我脫口而出:“三個(gè)2”,面試官眉頭一皺:“你再仔細(xì)看看”。哦豁,大事不妙,趕緊仔細(xì)看看,不對(duì)是三個(gè)3。面試官點(diǎn)了點(diǎn)頭,心想應(yīng)該是答對(duì)了。接著面試官又問(wèn)我,怎么修改呢。心想這不就是閉包嗎,var改let嗎。接著面試官又問(wèn)我其他方法呢,我說(shuō)用立即執(zhí)行函數(shù)。結(jié)果到了手寫(xiě)的時(shí)候突然懵了,背的八股文忘了,然后就尷尬了。。。
看來(lái)只背背八股文還是不行,所以今天針對(duì)這個(gè)問(wèn)題,仔細(xì)學(xué)習(xí)了一下前因后果。
一、JavaScript 是一門(mén)編譯語(yǔ)言
通常 JavaScript 被歸類于“解釋性語(yǔ)言”或“腳本語(yǔ)言”等,作為開(kāi)發(fā)Web 頁(yè)面的腳本語(yǔ)言而出名。但是事實(shí)上,它是一門(mén)編譯語(yǔ)言。
MDN對(duì)JavaScript的定義如下:
JavaScript (JS) 是一種具有函數(shù)優(yōu)先的輕量級(jí),解釋型或即時(shí)編譯型的編程語(yǔ)言。
JavaScript 是一種基于原型編程、多范式的動(dòng)態(tài)腳本語(yǔ)言,并且支持面向?qū)ο?、命令式和聲明式(如函?shù)式編程)風(fēng)格。
—— MDN
1.1 傳統(tǒng)編譯語(yǔ)言的編譯步驟
(1)分詞/詞法分析(Tokenizing/Lexing)將由字符組成的字符串分解成(對(duì)編程語(yǔ)言來(lái)說(shuō))有意義的代碼塊,這些代碼塊被稱為詞法單元(token)。
(2)解析/語(yǔ)法分析(Prsing)將詞法單元流(數(shù)組)轉(zhuǎn)換成一個(gè)由元素逐級(jí)嵌套所組成的代表了程序語(yǔ)法結(jié)構(gòu)的樹(shù)。這個(gè)樹(shù)被稱為“抽象語(yǔ)法樹(shù)”(Abstract Syntax Tree,AST)。
(3)代碼生成將 AST 轉(zhuǎn)換為可執(zhí)行代碼。這個(gè)過(guò)程與語(yǔ)言、目標(biāo)平臺(tái)等息息相關(guān);例如window下C語(yǔ)言編譯最終得到.exe文件。
1.2 JavaScript 與傳統(tǒng)編譯語(yǔ)言的區(qū)別
(1)JavaScript 與傳統(tǒng)編譯語(yǔ)言不同,它不是提前編譯的,編譯結(jié)果也不能在分布式系統(tǒng)中移植。 (2)JavaScript引擎負(fù)責(zé)整個(gè)JavaScript程序的編譯及執(zhí)行過(guò)程,編譯器負(fù)責(zé)語(yǔ)法分析及代碼生成等,相對(duì)于傳統(tǒng)編譯語(yǔ)言的編譯器更加復(fù)雜(例如:在語(yǔ)法分析和代碼生成階段有特定的步驟來(lái)對(duì)運(yùn)行性能進(jìn)行優(yōu)化)
(3)大部分情況下JavaScript 編譯發(fā)生在代碼執(zhí)行前的幾微秒(甚至更短)時(shí)間內(nèi)。
二、作用域(Scope)
作用域:負(fù)責(zé)收集并維護(hù)由所有標(biāo)識(shí)符(變量)組成的一系列查詢,并實(shí)施一套非常嚴(yán)格的規(guī)則,確定當(dāng)前執(zhí)行的代碼對(duì)這些標(biāo)識(shí)符的訪問(wèn)權(quán)限。—— 《你不知道的JavaScript 上卷》
在了解什么是作用域前,首先來(lái)看看var a = 2;
是如何進(jìn)行處理的。可能大部分和我一樣認(rèn)為這就是一句聲明,但是JavaScript認(rèn)為這里面又兩個(gè)完全不同的聲明,一個(gè)由編譯器在編譯時(shí)處理,另一個(gè)由引擎在運(yùn)行時(shí)處理。
- 首先編譯器會(huì)將這段程序分解成詞法單元,然后將詞法單元解析成一個(gè)樹(shù)結(jié)構(gòu)
- 緊接進(jìn)行代碼生成,編譯器會(huì)進(jìn)行如下處理:
- 遇到 var a,編譯器會(huì)詢問(wèn)作用域是否已經(jīng)有一個(gè)該名稱的變量存在于同一個(gè)作用域的集合中。如果是,編譯器會(huì)忽略該聲明,繼續(xù)進(jìn)行編譯;否則它會(huì)要求作用域在當(dāng)前作用域的集合中聲明一個(gè)新的變量,并命名為 a。
- 接下來(lái)編譯器會(huì)為引擎生成運(yùn)行時(shí)所需的代碼,這些代碼被用來(lái)處理 a = 2 這個(gè)賦值操作。引擎運(yùn)行時(shí)會(huì)首先詢問(wèn)作用域,在當(dāng)前的作用域集合中是否存在一個(gè)叫作 a 的變量。如果是,引擎就會(huì)使用這個(gè)變量;如果否,引擎會(huì)繼續(xù)查找該變量(即在作用域鏈上查找)如果引擎最終找到了 a 變量,就會(huì)將 2 賦值給它。否則引擎就會(huì)舉手示意并拋出一個(gè)異常!
2.1 LHS查詢 和 RHS查詢
如上例子,編譯器為引擎生成了為引擎生成了運(yùn)行時(shí)所需的代碼后,引擎執(zhí)行它時(shí),是如何查找變量a的呢?這里就要引入LHS查詢和RHS查詢兩個(gè)術(shù)語(yǔ)了。
(1)LHS 查詢:試圖找到變量的容器本身,從而可以對(duì)其賦值
(2)RHS 查詢:查找某個(gè)變量的值
以下面程序?yàn)槔?,?duì)LHS 和 RHS 做更深一步的解釋:
function foo(a) { console.log( a ); // 2 } foo( 2 );
- 首先:foo() 函數(shù)的調(diào)用,需要對(duì)foo進(jìn)行RHS查詢,即查找 foo 的值
- 緊接著執(zhí)行foo(2)時(shí),這里傳遞參數(shù)時(shí),隱式進(jìn)行了 a = 2,那么這里需要對(duì) a 進(jìn)行LHS查詢,找到a后再將2賦值給a。
- 進(jìn)入foo函數(shù)內(nèi)部,然后對(duì)console進(jìn)行RHS查詢,然后對(duì)a進(jìn)行RHS查詢,傳遞進(jìn)log()。
再看個(gè)例子:
function foo(a) { var b = a; return a + b; } var c = foo( 2 );
其中有:
3次 LHS 查詢
- c = ..
- a = 2
- b = ..
4次 RHS 查詢
- foo(..)
- = a
- a ..
- .. b
2.2 作用域嵌套
作用域簡(jiǎn)單來(lái)說(shuō)就是根據(jù)名稱查找變量的一套規(guī)則,但實(shí)際情況中,上述的查詢的可能不僅限于一個(gè)作用域。
當(dāng)一個(gè)塊或函數(shù)嵌套在另一個(gè)塊或函數(shù)中時(shí),就發(fā)生了作用域的嵌套。
因此,在當(dāng)前作用域中無(wú)法找到某個(gè)變量時(shí),引擎就會(huì)在外層嵌套的作用域中繼續(xù)查找,直到找到該變量,或抵達(dá)最外層的作用域(即:全局作用域)為止。
function foo(a) { console.log( a + b ); } var b = 2; foo( 2 ); // 4
例如上述代碼中,對(duì)b進(jìn)行RHS查詢時(shí),無(wú)法在當(dāng)前函數(shù)foo的作用域中完成,需要向上一級(jí)作用域查找,即在全局作用域中完成了。
LHS查詢和RHS查詢都會(huì)在當(dāng)前執(zhí)行的作用域開(kāi)始查找,如果沒(méi)有找到,則會(huì)向上一級(jí)查找,直到查找成功或者達(dá)到全局作用域。達(dá)到全局作用域,無(wú)論是否找到,都會(huì)停止查詢過(guò)程。
2.3 ReferenceError 和 TypeError
若在任何作用域中都無(wú)法查找到變量,那么引擎就會(huì)拋出異常。但是針對(duì)LHS查詢失敗和RHS查詢失敗拋出的異常是不同的。
(1)ReferenceError
console.log(a);
上述代碼在執(zhí)行時(shí),會(huì)拋出 ReferenceError 。這是因?yàn)樵趯?duì) a 進(jìn)行RHS查詢時(shí),是無(wú)法查找到改變量的。這是因?yàn)樽兞?a ”未聲明“,不存在于任何作用域中。
所以,RHS 查詢在所有嵌套的作用域中遍尋不到所需的變量,引擎就會(huì)拋出 ReferenceError異常。
相比之下,LHS查詢在所有嵌套的作用域中查詢不到目標(biāo)變量時(shí),全局作用域會(huì)創(chuàng)建一個(gè)具有該名稱的變量,并將其返還給引擎。但是,如果是在”嚴(yán)格模式“下,引擎也會(huì)拋出 ReferenceError.
// 嚴(yán)格模式下 "use strict" a = 2; // ReferenceError // 非嚴(yán)格模式下 a = 2 // 執(zhí)行成功
即:
- RHS查詢失敗時(shí):引擎會(huì)拋出 ReferenceError
- LHS查詢失敗時(shí):
- 嚴(yán)格模式: 引擎會(huì)拋出 ReferenceError;
- 非嚴(yán)格模式:全局作用域會(huì)創(chuàng)建一個(gè)具有該名稱的變量,并將其返還給引擎
(2)TypeError
如果 RHS 查詢找到了一個(gè)變量,但是你嘗試對(duì)這個(gè)變量的值進(jìn)行不合理的操作,比如試圖對(duì)一個(gè)非函數(shù)類型的值進(jìn)行函數(shù)調(diào)用,或著引用 null 或 undefined 類型的值中的屬性,那么引擎會(huì)拋出另外一種類型的異常,叫作 TypeError。
// 對(duì)非函數(shù)類型的值進(jìn)行調(diào)用 let a = 0; a(); // 引用undefined類型的值的屬性 let b; b.name;
(3)ReferenceError 和 TypeError 的區(qū)別
ReferenceError 表示RHS查詢失敗,或嚴(yán)格模式下的LHS查詢失敗
TypeError 則代表RHS查詢成功了,但是對(duì)結(jié)果的操作是非法或不合理的。
小結(jié)
作用域是一套規(guī)則,用于確定在何處以及如何查找變量(標(biāo)識(shí)符)。如果查找的目的是對(duì)變量進(jìn)行賦值,那么就會(huì)使用 LHS 查詢;如果目的是獲取變量的值,就會(huì)使用 RHS 查詢。
LHS 和 RHS 查詢都會(huì)在當(dāng)前執(zhí)行作用域中開(kāi)始,如果沒(méi)找到,就會(huì)向上級(jí)作用域繼續(xù)查找目標(biāo)標(biāo)識(shí)符,這樣每次上升一級(jí)作用域,最后抵達(dá)全局作用域(頂層),無(wú)論找到或沒(méi)找到都將停止。
不成功的 RHS 引用會(huì)導(dǎo)致拋出 ReferenceError 異常。不成功的 LHS 引用會(huì)導(dǎo)致自動(dòng)隱式地創(chuàng)建一個(gè)全局變量(非嚴(yán)格模式下),該變量使用 LHS 引用的目標(biāo)作為標(biāo)識(shí)符,或者拋出 ReferenceError 異常(嚴(yán)格模式下)。
三、詞法作用域
第二節(jié)中提到作用域可以定義為一套規(guī)則,但是這套規(guī)則又是如何去定義的呢?
作用域主要有兩種主要的工作模型:詞法作用域 和 動(dòng)態(tài)作用域,其中 JavaScript 采用的是詞法作用域
3.1 詞法階段
如第一節(jié)中介紹的,大部分標(biāo)準(zhǔn)語(yǔ)言編譯器的第一個(gè)工作階段叫作詞法分析。即對(duì)源代碼中的字符進(jìn)行檢查,識(shí)別出每個(gè)單詞。
簡(jiǎn)單來(lái)說(shuō),詞法作用域就是定義在詞法階段的作用域。即由代碼中變量的書(shū)寫(xiě)位置來(lái)決定的。
3.2 詞法作用域 查找規(guī)則
(1)作用域查找是找從運(yùn)行時(shí)所處的最內(nèi)部作用域開(kāi)始,逐級(jí)向外,直到遇見(jiàn)第一個(gè)匹配的標(biāo)識(shí)符為止。
(2)遮蔽效應(yīng):在多層嵌套的作用域中可以定義同名的標(biāo)識(shí)符,但是內(nèi)部的標(biāo)識(shí)符會(huì)”遮蔽“外部的標(biāo)識(shí)符。
(3)全局變量會(huì)自動(dòng)成為全局對(duì)象的屬性。所以可以通過(guò)全局對(duì)象的引用來(lái)間接訪問(wèn)全局變量。
// a是全局變量 var a = 1; // 瀏覽器中全局對(duì)象一般為window console.log(window.a) // 1
所以,當(dāng)全局變量在內(nèi)部作用域被同名變量“遮蔽”時(shí),可通過(guò)該方法訪問(wèn)到全局變量,例如:
// a是全局變量 var a = 1; funcion foo() { let a = 2; console.log(a); // 2 console.log(window.a); // 1 }
但是,對(duì)于非全局變量來(lái)說(shuō),如果被遮蔽了,就無(wú)法訪問(wèn)到。
(4)無(wú)論函數(shù)何時(shí)、何處以及如何被調(diào)用,它的詞法作用域都只由被聲明時(shí)所處的位置決定。(即與代碼中書(shū)寫(xiě)的位置保持一致)
(5)詞法作用域的查找只會(huì)查找\color{red}{一級(jí)標(biāo)識(shí)符}一級(jí)標(biāo)識(shí)符。
例如:針對(duì)foo.a.b
,詞法作用域只會(huì)試圖查找 foo 標(biāo)識(shí)符,找到 foo 這個(gè)變量后,對(duì)象屬性訪問(wèn)規(guī)則會(huì)接管對(duì) a 和 b 屬性的訪問(wèn)。
這也解釋了前面當(dāng)引擎遇到console.log();
時(shí),只會(huì)對(duì) console 進(jìn)行一次RHS查詢,不會(huì)接著對(duì) log 進(jìn)行RHS查詢。
3.3 欺騙詞法 —— eval、with
3.2中說(shuō)到,詞法作用域是由書(shū)寫(xiě)代碼期間函數(shù)所聲明的位置來(lái)定義。但是JavaScript中有兩個(gè)機(jī)制會(huì)在運(yùn)行時(shí)“修改”詞法作用域——eval、with。但是很多地方都建議不使用這兩種機(jī)制,因?yàn)槠垓_詞法作用域會(huì)導(dǎo)致性能下降。
(1)eval
eval()
是全局對(duì)象的一個(gè)函數(shù)屬性。eval()
的參數(shù)是一個(gè)字符串。如果字符串表示的是表達(dá)式,eval()
會(huì)對(duì)表達(dá)式進(jìn)行求值。如果參數(shù)表示一個(gè)或多個(gè) JavaScript 語(yǔ)句,那么eval()
就會(huì)執(zhí)行這些語(yǔ)句。 —— MDN
換個(gè)說(shuō)法,eval可以在書(shū)寫(xiě)的代碼中用程序生成代碼并運(yùn)行,就好像代碼是寫(xiě)在那個(gè)位置一樣。
在執(zhí)行 eval(..) 之后的代碼時(shí),引擎并不“知道”或“在意”前面的代碼是以動(dòng)態(tài)形式插入進(jìn)來(lái),并對(duì)詞法作用域的環(huán)境進(jìn)行修改的。引擎只會(huì)如往常地進(jìn)行詞法作用域查找。
function foo(str, a) { eval( str ); // 欺騙! console.log( a, b ); } var b = 2; foo( "var b = 3;", 1 ); // 1, 3 foo( "", 1 ); // 1, 2
從上述代碼可以看出,書(shū)寫(xiě)的代碼中 foo 函數(shù)的詞法作用域并沒(méi)有聲明變量 b。但是eval(..) 調(diào)用中的 var b = 3;
這段代碼會(huì)被當(dāng)作本來(lái)就在那里一樣來(lái)處理。因此對(duì)foo函數(shù)的詞法作用域進(jìn)行了修改,在foo函數(shù)內(nèi)部創(chuàng)建了一個(gè)變量b,遮蔽了全局變量b,所以輸出 1, 3。
eval(..) 可以在運(yùn)行期修改書(shū)寫(xiě)期的詞法作用域。但個(gè)人覺(jué)得其實(shí)并沒(méi)有破壞詞法作用域的查找規(guī)則,即把 eval() 的參數(shù)在eval書(shū)寫(xiě)的位置替換eval()。然后再按詞法作用域規(guī)則去查找。
\color{red}{注意:}注意:在嚴(yán)格模式下,eval()在運(yùn)行時(shí)有自己的詞法作用域,所以其中的聲明無(wú)法修改所在的作用域。
function foo(str, a) { "use strict" eval( str ); // 欺騙! console.log( a, b ); } var b = 2; foo( "var b = 3;console.log(b)", 1 ); //3 1, 2
從上述代碼可以看出,嚴(yán)格模式下,在eval()函數(shù)內(nèi)部輸出b,值為3,但是在eval()函數(shù)外部輸出b,值為2.
不推薦使用與 eval() 以及與 eval() 類似的函數(shù)setTimeout(..) 和setInterval(..) 的第一個(gè)參數(shù)可以是字符串,字符串的內(nèi)容可以被解釋為一段動(dòng)態(tài)生成的函數(shù)代碼。這些功能已經(jīng)過(guò)時(shí)且并不被提倡。(目前一般是傳遞回調(diào)函數(shù))
new Function(..) 最后一個(gè)參數(shù)可以接受代碼字符串,并將其轉(zhuǎn)化為動(dòng)態(tài)生成的函數(shù)(前面的參數(shù)是這個(gè)新生成的函數(shù)的形參)。這種構(gòu)建函數(shù)的語(yǔ)法比eval(..) 略微安全一些,但也要盡量避免使用。
(2)with
'with'語(yǔ)句將某個(gè)對(duì)象添加到作用域鏈的頂部,如果在statement中有某個(gè)未使用命名空間的變量,跟作用域鏈中的某個(gè)屬性同名,則這個(gè)變量將指向這個(gè)屬性值。如果沒(méi)有同名的屬性,則將拋出ReferenceError異常。—— MDN
with (expression) { statement }
換種說(shuō)法,with 可以將一個(gè)沒(méi)有或有多個(gè)屬性的對(duì)象處理為一個(gè)完全隔離的全新的詞法作用域,因此這個(gè)對(duì)象的屬性也會(huì)被處理為定義在這個(gè)作用域中的詞法標(biāo)識(shí)符。
var c = 3; let obj = { a: 1, b: 2 } with(obj) { console.log(a); // 1 var b = 5; console.log(b); // 5 console.log(c); // 3 console.log(d); // ReferenceError }
對(duì)于上述代碼,我們可以這樣理解,with 語(yǔ)句創(chuàng)建了一個(gè)全新的詞法作用域,并把 obj 放在該詞法作用域的頂層(若把該詞法作用域類比為全局作用域,那么obj就是一個(gè)全局對(duì)象)。在該全新的作用域中,obj的所有屬性都可以直接訪問(wèn)。console.log(a)
輸出1:當(dāng)前詞法作用域未聲明變量a,所以向上一級(jí)查找,obj中包含屬性a,所以輸出1console.log(b)
輸出5:當(dāng)前詞法作用域中聲明了變量b,該變量b”遮蔽“了obj中的屬性b,所以輸出5console.log(c)
輸出3:當(dāng)前詞法作用域未定義變量c,obj中也沒(méi)有屬性c,則繼續(xù)向全局作用域查找,所以輸出3console.log(d)
拋出異常ReferenceError:因?yàn)樵诋?dāng)前詞法作用域以及其嵌套的所有詞法作用域中都未聲明變量d,RHS查詢失敗,所以拋出ReferenceError
\color{red}{注意:}注意:在 ECMAScript 5嚴(yán)格模式下,with標(biāo)簽已經(jīng)被禁用。
(3)為什么不推薦使用 eval() 和 with
1. eval() 和 with 對(duì)性能的影響JavaScript 引擎會(huì)在編譯階段進(jìn)行數(shù)項(xiàng)的性能優(yōu)化。其中有些優(yōu)化依賴于能夠根據(jù)代碼的詞法進(jìn)行靜態(tài)分析,并預(yù)先確定所有變量和函數(shù)的定義位置,才能在執(zhí)行過(guò)程中快速找到標(biāo)識(shí)符。
但是當(dāng)引擎在代碼中遇見(jiàn)了 eval() 或者 with,無(wú)法直到eval()中的字符串參數(shù)如何對(duì)作用域進(jìn)行修改,也不知道 with 用來(lái)創(chuàng)建新詞法作用域的對(duì)象的內(nèi)容到底是什么。因?yàn)閑val() 和 with 是在運(yùn)行時(shí)修改或創(chuàng)建新的詞法作用域,所以這會(huì)影響引擎在編譯階段的性能優(yōu)化,會(huì)導(dǎo)致程序運(yùn)行變慢。
2. 嚴(yán)格模式下嚴(yán)格模式下,eval()在運(yùn)行時(shí)有自己的詞法作用域,而with則被禁用了。
3. eval() 函數(shù)不安全如果你用 eval()
運(yùn)行的字符串代碼被惡意方(不懷好意的人)修改,您最終可能會(huì)在您的網(wǎng)頁(yè)/擴(kuò)展程序的權(quán)限下,在用戶計(jì)算機(jī)上運(yùn)行惡意代碼
4. with的弊端
with
使用'with'可以減少不必要的指針路徑解析運(yùn)算。(但是很多情況下,也可以不使用with語(yǔ)句,而是使用一個(gè)臨時(shí)變量來(lái)保存指針,來(lái)達(dá)到同樣的效果)with
語(yǔ)句使得程序在查找變量值時(shí),都是先在指定的對(duì)象中查找。所以那些本來(lái)不是這個(gè)對(duì)象的屬性的變量,查找起來(lái)將會(huì)很慢with
語(yǔ)句使得代碼不易閱讀,同時(shí)使得JavaScript編譯器難以在作用域鏈上查找某個(gè)變量,難以決定應(yīng)該在哪個(gè)對(duì)象上來(lái)取值
四、函數(shù)作用域和塊作用域
第三節(jié)中指出,詞法作用域是書(shū)寫(xiě)代碼時(shí)的位置來(lái)決定的。但是這些詞法作用域時(shí)基于什么的位置來(lái)確定的呢?JavaScript中主要具有函數(shù)作用域和塊作用域兩種。
4.1 函數(shù)作用域
簡(jiǎn)單來(lái)說(shuō),函數(shù)作用域就是指,屬于這個(gè)函數(shù)的全部變量都可以在整個(gè)函數(shù)范圍內(nèi)使用及復(fù)用(在嵌套的作用域中也可以使用)。但是外部作用域無(wú)法訪問(wèn)函數(shù)內(nèi)部的任何內(nèi)容。
4.2 塊作用域
塊作用域指的是變量和函數(shù)不僅可以屬于所處的作用域,也可以屬于某個(gè)代碼塊(通常指 { .. } 內(nèi)部) (1)用 with 從對(duì)象中創(chuàng)建出的塊作用域僅在 with 聲明中而非外部作用域中有效。
(2)JavaScript 的 ES3 規(guī)范中規(guī)定 try/catch 的 catch 分句會(huì)創(chuàng)建一個(gè)塊作用域,其中聲明的變量?jī)H在 catch 內(nèi)部有效。
(3)let 關(guān)鍵字可以將變量綁定到所在的任意作用域中(通常是 { .. } 內(nèi)部)。換句話說(shuō),let為其聲明的變量隱式地了所在的塊作用域。
for 循環(huán)頭部的 let 不僅將 i 綁定到了 for 循環(huán)的塊中,事實(shí)上它將其重新綁定到了循環(huán)的每一個(gè)迭代中,確保使用上一個(gè)循環(huán)迭代結(jié)束時(shí)的值重新進(jìn)行賦值
(4)const 同樣可以用來(lái)創(chuàng)建塊作用域變量,但其值是固定的(常量)。之后任何試圖修改值的操作都會(huì)引起錯(cuò)誤
五、函數(shù)提升和變量提升
在介紹閉包之前,我還要啰嗦幾句,以便后續(xù)更好解釋例題。
5.1 變量聲明提升
對(duì)于一段JavaScript代碼。我們可能會(huì)認(rèn)為時(shí)從上到下一行一行地去執(zhí)行的,但實(shí)際上并不完全是這樣的。
console.log(a); // 1 var a = 1;
如果程序是從上到下執(zhí)行的話,那么第一行代碼應(yīng)該會(huì)拋出ReferenceError,因?yàn)椴](méi)有在這之前并沒(méi)有聲明變量a。但實(shí)際上會(huì)輸出 undefined ,這是為啥呢?
這要從編譯開(kāi)始說(shuō)起了,引擎在解釋JavaScript代碼前會(huì)先對(duì)其進(jìn)行編譯,編譯階段中的一部分工作就是找到所有的聲明,并用合適的作用域?qū)⑺鼈冴P(guān)聯(lián)起來(lái)。所以,包括變量和函數(shù)在內(nèi)的所有聲明都會(huì)在任何代碼被執(zhí)行前首先被處理。
所以上述代碼實(shí)際的執(zhí)行順序是:
var a; console.log(a); a = 2;
\color{red}{注意}注意:
- 只有聲明本身會(huì)被提升,而賦值或其他運(yùn)行邏輯會(huì)留在原地。所以上述代碼
var a = 2
中只有var a
提升了。 - ES6中新加入的let 和 const 關(guān)鍵字聲明變量時(shí),并不會(huì)進(jìn)行變量提升。
5.2 函數(shù)聲明提升
除了變量聲明會(huì)提升,函數(shù)聲明也會(huì)提升。
foo(); function foo() { console.log( 1 ); // 1 }
如上代碼,實(shí)際的執(zhí)行順序如下:
function foo() { console.log( 1 ); // 1 } foo();
此外,需要注意的是,只有函數(shù)聲明會(huì)提升,函數(shù)表達(dá)式并不會(huì)提升。
foo(); // TypeError bar(); // ReferenceError var foo = function bar() { // ... };
5.3 聲明提升注意點(diǎn)
函數(shù)聲明先提升,然后再變量聲明提升
foo(); // 1 var foo; function foo() { console.log( 1 ); } foo = function() { console.log( 2 );
一個(gè)普通塊內(nèi)部的函數(shù)聲明通常會(huì)被提升到所在作用域的頂部
foo(); // "b" var a = true; if (a) { function foo() { console.log("a"); } } else { function foo() { console.log("b"); } }
var 聲明的是函數(shù)作用域,所以在一個(gè)普通塊內(nèi)部,var的變量聲明也會(huì)提升
console.log(a) // undefined if(false) { var a = 1; } console.log(a) // ReferenceError function f() { var a = 1; }
六、閉包
介紹完前面的知識(shí)后,終于可以引出主角閉包了,首先看看MDN中對(duì)閉包的定義:
一個(gè)函數(shù)和對(duì)其周圍狀態(tài)(lexical environment,詞法環(huán)境)的引用捆綁在一起(或者說(shuō)函數(shù)被引用包圍),這樣的組合就是閉包(closure)。也就是說(shuō),閉包讓你可以在一個(gè)內(nèi)層函數(shù)中訪問(wèn)到其外層函數(shù)的作用域。 —— MDN
好像有點(diǎn)晦澀難懂,再來(lái)看看《你不知道的JavaScript上卷》中對(duì)閉包的定義:
當(dāng)函數(shù)可以記住并訪問(wèn)所在的詞法作用域時(shí),就產(chǎn)生了閉包,即使函數(shù)是在當(dāng)前詞法作用域之外執(zhí)行。 —— 《你不知道的JavaScript上卷》
還是先來(lái)看兩段代碼吧:
function foo() { var a = 2; function bar() { console.log( a ); // 2 } bar(); } foo();
基于詞法作用域的查找規(guī)則,函數(shù)bar()可以訪問(wèn)外部作用域中的變量a。這是閉包嗎?反正我之前認(rèn)為這就是。但是確切來(lái)說(shuō),這并不是閉包。
function foo() { var a = 2; function bar() { console.log( a ); } return bar; } var baz = foo(); baz();
這段代碼就很清晰地展示了閉包,當(dāng)foo()執(zhí)行完畢后,通常會(huì)銷毀foo的內(nèi)部作用域,但是閉包阻止了這一行為。bar()它擁有涵蓋 foo() 內(nèi)部作用域的閉包,使得該作用域能夠一直存活,以供 bar() 在之后任何時(shí)間進(jìn)行引用。
這個(gè)函數(shù)在定義時(shí)的詞法作用域以外的地方被調(diào)用。閉包使得函數(shù)可以繼續(xù)訪問(wèn)定義時(shí)的詞法作用域。
所以,無(wú)論通過(guò)何種手段將內(nèi)部函數(shù)傳遞到所在的詞法作用域以外,它都會(huì)持有對(duì)原始定義作用域的引用,無(wú)論在何處執(zhí)行這個(gè)函數(shù)都會(huì)使用閉包。
第一段代碼中,bar() 就是在其詞法作用域內(nèi)執(zhí)行的,所以嚴(yán)格來(lái)說(shuō)并不能稱為閉包,因?yàn)椴⒉恍枰?ldquo;記住”詞法作用域。
6.1 例題
既然了解了什么閉包,我們來(lái)看看文章開(kāi)頭的面試題:
var arr = [] for (var i = 0; i < 3; i++) { arr[i] = function() { console.log(i); } } arr[0]() // 3 arr[1]() // 3 arr[2]() // 3
三個(gè)函數(shù)調(diào)用的結(jié)果都是3,為什么會(huì)這樣呢?
首先看for循環(huán)中的var i = 0;
,其中聲明的變量 i 是全局作用域的一個(gè)變量,所以在執(zhí)行arr[0]() 、arr[1]()、arr[2]()
的時(shí)候,在作用域鏈上查找變量 i 時(shí),最終找到都是全局作用域中的同一個(gè)變量 i。因?yàn)榻?jīng)歷了三次循環(huán),所以 i 的值變成了3。故調(diào)用三個(gè)函數(shù)輸出的值都是3。
那么,怎么去改進(jìn)使得程序由正確的輸出呢?
1. for循環(huán)中使用 let 聲明i
前面提到,for 循環(huán)頭部的 let 將 i 綁定到了 for 循環(huán)的塊中,指出變量 i 在循環(huán)過(guò)程中不止被聲明一次,每次迭代都會(huì)聲明。隨后的每個(gè)迭代都會(huì)使用上一個(gè)迭代結(jié)束時(shí)的值來(lái)初始化這個(gè)變量。
var arr = [] for (let i = 0; i < 3; i++) { arr[i] = function() { console.log(i); } } arr[0]() // 0 arr[1]() // 1 arr[2]() // 2
2. 立即執(zhí)行函數(shù)(IIFE)
var arr = [] for (var i = 0; i < 3; i++) { (function IIFE(i) { arr[i] = function() { console.log(i); } })(i) } arr[0]() // 0 arr[1]() // 1 arr[2]() // 2
此外這里再分析一種錯(cuò)誤的寫(xiě)法:
var arr = [] for (var i = 0; i < 3; i++) { var j = i; arr[i] = function() { console.log(j); } } arr[0]() // 2 arr[1]() // 2 arr[2]() // 2
這也是我改進(jìn)的最初答案,想著使用一個(gè)變量記錄當(dāng)前的i值不就行了。但是結(jié)果并不像我想的那樣,翻閱書(shū)籍后,發(fā)現(xiàn)var聲明的作用域是函數(shù)作用域,所以在for循環(huán)塊中的var j = i
也會(huì)聲明提升。相當(dāng)于j也是一個(gè)全局變量了。最后三個(gè)函數(shù)中查找到的j也相同。
到此這篇關(guān)于JavaScript 中的作用域與閉包的文章就介紹到這了,更多相關(guān)JS作用域與閉包內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
JavaScript實(shí)現(xiàn)倒計(jì)時(shí)跳轉(zhuǎn)頁(yè)面功能【實(shí)用】
本文分享了JavaScript實(shí)現(xiàn)倒計(jì)時(shí)跳轉(zhuǎn)頁(yè)面功能的具體實(shí)例代碼,頁(yè)面代碼簡(jiǎn)單,直接拷貝就能運(yùn)行,頁(yè)面可以自己美化下哦。需要的朋友一起來(lái)看下吧2016-12-12基于JavaScript編寫(xiě)8086匯編指令查詢工具
匯編語(yǔ)言還是在大學(xué)的時(shí)候?qū)W的,匯編語(yǔ)言有個(gè)特點(diǎn)是語(yǔ)句短、條數(shù)多,很難可以把全部指令都背熟。本文就來(lái)用JavaScript編寫(xiě)一個(gè)8086匯編指令查詢工具,希望對(duì)大家有所幫助2023-02-02javascript一個(gè)無(wú)懈可擊的實(shí)例化XMLHttpRequest的方法
由于IE新舊版本以及與其他瀏覽器在ajax技術(shù)上的不同,往往需要對(duì)不同的瀏覽器做不同的處理,除了笨拙的瀏覽器嗅探技術(shù),大約也就是對(duì)象檢測(cè)技術(shù)可用了。2010-10-10再談javascript 動(dòng)態(tài)添加樣式規(guī)則 W3C校檢
Ruby's Louvre blog都是一些精品內(nèi)容,下面這個(gè)是介紹javascript 動(dòng)態(tài)添加樣式規(guī)則,而且最后的函數(shù),可以讓你的css通過(guò)w3c的驗(yàn)證。2009-12-12JS限制Textarea文本域字符個(gè)數(shù)的具體實(shí)現(xiàn)
這篇文章介紹了JS限制Textarea文本域字符個(gè)數(shù)的具體實(shí)現(xiàn),有需要的朋友可以參考一下2013-08-08