最近有學員給出一段使人匪夷所思的JavaScript代碼(聽說是某某大廠面試題),廢話少說,上代碼:程序員
var a = 10; { a = 99; function a() { } a = 30; } console.log(a);
這段代碼運行結果是99,也就是說,a = 99將a的值從新設爲99,而因爲後面使用a定義了一個函數,a = 30實際上是修改的a函數,或者乾脆說,函數a將變量a覆蓋了,因此在a函數的後面再也沒法修改變量a的值了,由於變量a已經不存在了,ok,這段代碼的輸出結果好像能夠解釋得通,下面再看一段代碼:面試
var a = 10; { function hello() { a = 99; function a() { } a = 30; } hello(); } console.log(a);
你們能夠猜猜,這段代碼會輸出什麼結果呢?10?99?30?,答案是10。也就是說,hello函數壓根就沒有修改全局變量a 值,那麼這是爲何呢?編程
根據咱們前面的結論,當執行到a = 99時,覆蓋變量a的值,而後執行函數a的定義代碼,接下來執行a = 30,將函數a改爲了變量a,這個解釋彷佛也沒什麼問題,可是,問題就是,與第1段代碼的輸出不同。第1段代碼修改了全局變量a的值,第2段代碼沒有修改全局變量a的值,這是爲何呢?編程語言
如今思考3分鐘........ide
其實吧,別看這道題很簡單,可能有不少程序員都能蒙對答案,反正就這幾種可能,一共就3個數,蒙對的可能性是33.3333%,但若是讓你詳細解釋其中的緣由呢?這恐怕沒有多少程序員能清楚地解釋其中的原理,如今就讓我來給出一個完美無缺的解答:函數
儘管前面給出的兩段代碼並不複雜,但這裏面隱藏的信息量至關的大。在正式解答以前,先給出一些知識點:測試
1. 執行級代碼塊和非執行級代碼塊3d
這裏介紹一下兩種代碼塊的區別:code
執行級代碼塊,顧名思義,就是在定義代碼塊的同時就執行了,看下面的代碼:blog
{ var a = 1; var b = 2; console.log(a + b); }
這段代碼,在解析的同時就會執行,輸出3。
而非執行級代碼塊,就是在定義時不執行,只有在調用時才執行,很顯然,函數代碼塊屬於非執行級代碼塊,案例以下:
function add() { var a = 1; var b = 2; console.log(a + b); }
若是給執行級代碼塊套上一個函數頭,就成了上面的樣子,若是隻有add函數,函數體是永遠也不會執行的,除非使用下面的代碼調用add函數。
add();
那麼這兩種代碼塊有什麼區別呢?先看他們的區別:
估計剛看到這兩點區別,不少同窗有點懵,下面我就來挨個解釋下。
(1)執行級代碼塊中的變量和函數自動提高做用域
先給出一個例子:
{ var a = 1; var b = 2; function sub() { return a - b } } console.log(a + b); // 輸出3 console.log(sub()); // 輸出-1
在這段代碼中,a和b都使用了var聲明變量,說明這兩個變量是塊的局部變量,那麼爲何在塊外面還能訪問呢?這就是執行級代碼塊的做用域提高。若是在塊外有同名的符號,須要注意以下幾點:
符號只有用var定義的變量和函數能夠被覆蓋,類和用let、const定義的變量不能被覆蓋,會出現重複聲明的異常。代碼以下:
var a = 14; function b() { } { var a = 1; var b = 2; function sub() { return a - b } } console.log(a + b); // 輸出3 console.log(sub()) // 輸出-1
很明顯,全局變量a和全局函數b被塊內部的a和b覆蓋了,因此輸出的結果仍是3和-1。
let a = 14; class b{} { var a = 1; var b = 2; function sub() { return a - b } } console.log(a + b); console.log(sub())
執行這段代碼,會拋出以下圖所示的異常:
這說明用let聲明的變量已經被鎖死在頂層做用域中,不可被其餘做用域的變量替換。若是將let a = 14註釋掉,會拋出以下圖的異常:
這說明類b也被鎖死在頂層做用域中,不可被其餘做用域的變量替換。
相對於可執行級代碼塊,非可執行級代碼塊就不會進行做用域提高,看以下代碼:
function myfun() { var a = 1; var b = 2; } console.log(a + b);
執行這段代碼,會拋出以下圖的異常:
很明顯,是變量a沒有定義。
(2)若是有局部符號,執行級代碼塊會優先進行做用域提高,而非執行級代碼塊,會優先考慮局部符號,看下面的解釋。
先上代碼:
執行級代碼塊
var a = 100 { a = 10; function a() { } a = 20; } console.log(a); // 輸出10
非執行級代碼塊
var a = 100 { function hello() { a = 10; function a() { } a = 20; } hello(); } console.log(a); // 輸出100
這兩段代碼,前面的修改了變量a,輸出10,後面的沒有修改變量a,輸出100,這是爲何呢?
這是因爲執行級代碼塊會優先進行做用域提高,先看第1段代碼,按着規則,會優先用塊中的a覆蓋全局變量a,因此a就變成10了。而後聲明瞭a函數,因此a = 20實際上是覆蓋了局部函數a。其實這個解釋咋一看沒什麼問題,不過仔細推敲,仍是有不少漏洞。例如,既然a = 10優先提高做用域,難道a = 20就不能優先提高做用域嗎?將 a = 10覆蓋,變成20,爲何最後輸出的結果仍是10呢?函數a難道不會提高做用域,將變量a覆蓋嗎?這些疑問會在後面一一解開。
再看第2段代碼,非執行級代碼塊會優先考慮局部變量,因此hello函數中的a會將函數a覆蓋,而不是全局變量a覆蓋,因此hello函數中的兩次對a賦值,都是處理的局部符號a,而不是全局符號a。這個解釋咋一看也沒啥問題,但仔細推敲,也會有一些沒法解釋的。例如,a = 10是在函數a前面的語句,爲啥會考慮在a = 10後面定義的函數a呢?這些疑問會在後面一一解開。
2. 多遍掃描
什麼叫多遍掃描呢?這裏的掃描指的是對JavaScript源代碼進行掃描。由於你要運行JavaScript代碼,確定是要掃描JavaScript文件的全部內容的。不過不一樣類型的編程語言,掃描的次數不一樣。對於動態語言(如JavaScript、Python、PHP等),至少要掃描一遍(這句話當我沒說,確定要至少掃描一遍,不然要執行空氣嗎!),對於靜態編程語言(如Java、C#,C++),至少要掃描2遍,一般是3遍以上。關於靜態語言的分析問題,之後再寫文章描述。這裏主要討論動態語言。
早期的動態語言(如ASP),一般會掃描一遍,但如今不少動態語言(如JavaScript、Python等),都是至少掃描2遍。如今先看看掃描1遍和掃描2遍有啥區別。
先看看在什麼狀況下只須要掃描1遍:
對於函數、類等語法元素與定義順序有關的語言就只須要掃描1遍。那麼什麼是與定義順序有關呢?也就是說,在使用某個函數、類以前必須定義,或者說,函數、類必須在使用前定義。例如,下面的代碼是合法的。
function hello() { } hello()
這是由於hello函數在使用以前就定義了。而下面的代碼在運行時會拋出異常。這是由於在調用hello函數以前沒有定義hello函數。
hello() // hello函數是在使用以後定義的 function hello() { }
那麼在什麼狀況下須要至少掃描2遍呢?
對於函數、類等語法元素與定義順序無關的語言必須至少掃描2遍。這是由於第1遍須要肯定語法元素(函數、類等)的定義,第2遍纔是使用這些語法元素。通過測試,JavaScript的代碼是與定義順序無關的,也就是說,下面的代碼能夠正常運行:
hello() function hello() { }
很顯然,JavaScript解析器至少對代碼掃描了2次。對於動態語言(如JavaScript),一般是一邊掃描一邊執行的(並不像Java這樣的靜態語言,掃描時並不執行,直到生成.class文件後才經過JVM執行)。通常第1遍負責執行定義代碼(如定義函數、類等),第2遍負責執行其餘代碼。如今就讓咱們看看JavaScript的這2遍掃描都作了什麼。
先給出結論:JavaScript的第1遍掃描只處理函數和類定義(固然,還有可能處理其餘的定義,但本文只討論函數和類),JavaScript的第2遍掃描負責處理其餘代碼。但函數和類的處理方式是不一樣的(見後面的解釋)。
結論是給出了,下面給出支持這個結論的證據:
看下面的代碼:
hello() function hello() { console.log('hello') }
執行這段代碼,會輸出hello。很明顯,hello函數在調用以後定義。因爲讀取文件,是順序進行的,因此若是隻掃描一遍代碼,在調用hello函數時不可能知道hello函數的存在。所以,惟一的解釋就是掃描了2遍。第1遍,先掃描hello函數的定義部分,而後將hello函數的定義保存到當前做用域的符號表中。第2次掃描,調用hello函數時,就會到當前做用域的符號表查詢是否存在函數hello,若是存在,調用,不存在,則拋出異常。
那麼在第1遍掃描時,處理類和函數的規則是否相同呢?先看下面的代碼:
var h = new hello(); // 拋出異常 class hello { }
在運行這段代碼時會拋出以下圖所示的異常。
從這個異常來看,hello相似乎在第1遍掃描中沒處理,將hello類的定義放到最前面就能夠了,代碼以下:
class hello { } var h = new hello(); // 正常建立類的實例
如今看下面的代碼:
var p1 = 10 { p1 = 40; class p1{} p1 = 50; }
執行這段代碼,會拋出以下圖的異常:
很明顯,錯誤指向了p1 = 40,而不是class p1{}。假設第1遍掃描沒有處理類p1,那麼的2遍掃描確定是按順序執行的,就算出錯,也應該是class p1{}的位置,那麼爲什麼是p1 = 40的位置呢?元芳你怎麼看!
元芳:惟一的合理解釋就是在第2遍掃描到p1 = 40時,JavaScript解析器已經知道了p1的存在,這就是p1類。那麼p1類確定是在第1遍處理了,只是處理方法與函數不一樣,只是將p1類做爲符號保存到符號表中,在使用p1類時並無檢測當前做用域的符號表,所以,只能在使用類前定義這個類。因爲這個規則限制的比較嚴,因此不排除之後JavaScript升級時支持與位置無關的類定義,但至少如今不行。
這就是在第1遍掃描時函數與類的處理方式。
在第2遍掃描就會循序漸進執行其餘代碼了,這一點在之後分析,下面先看其餘知識點。
3. 下面哪段代碼會拋出異常
先來作這道題:
第1段代碼:
var a = 99; function a() { } console.log(a)
第2段代碼:
{ var a = 99; function a() { } console.log(a) }
第3段代碼:
{ a = 99; function a() { } console.log(a) }
第4段代碼:
function hello() { var a = 99; function a() { } console.log(a) } hello();
如今思考3分鐘......
答案是第2段代碼會拋出以下圖的異常,其餘3段代碼都正常執行,並輸出正確的結果。
那麼這是爲何呢?
先來解釋第1段代碼:
var a = 99; function a() { } console.log(a)
在這段代碼中,變量a和函數a都位於頂級做用域中,因此就不存在提高做用域的問題了。當第1遍掃描時,函數a被保存到符號表中。第2遍掃描時,執行到var a = 99時,會發現函數a已經在當前做用域了,因此在同一個做用域中,後面處理的符號會覆蓋前面的同名符號,因此函數a就被變量a覆蓋了。所以,會輸出99。
如今來解釋第4段代碼:
function hello() { var a = 99; function a() { } console.log(a) } hello();
第1遍掃描,hello函數和a函數都保存到當前做用域的符號表中了(這兩個函數在不一樣的做用域)。第2遍掃描,執行var a = 99時,因爲這是非執行級代碼塊,因此不存在做用域提高的問題。並且變量a用var聲明,就說明這是hello函數的局部變量,而函數a已經在第1遍掃描中得到了,因此在執行到var a = 99時,js解析器已經知道了函數a的存在,因爲變量a和函數a都在同一個做用域,因此能夠覆蓋。所以,這段代碼也輸出99。
接下來看第2段和第3段代碼:
第2段代碼
{ var a = 99; // 拋出異常 function a() { } console.log(a) }
第3段代碼
{ a = 99; // 正常執行 function a() { } console.log(a) }
這兩段代碼的惟一區別是a是否使用了var定義。這就要根據執行級代碼塊的規則了。
估計是JavaScript的規範比較亂,並且Class是後來加的,規則沒定好,原本類和函數應該有一樣的效果的,結果....,這就是js的代碼容易讓人發狂的緣由。在Java、C#中是絕對不會有這種狀況發生的。
好了,該分析的都分析了,如今就來具體分析下本文剛開始的代碼吧。
第1遍掃描:
var a = 10; // 不處理 { a = 99; // 不處理 function a() { // 提高做用域到頂層做用域 } a = 30; // 不處理 } console.log(a); // 不處理
到如今爲止,第1遍掃描結束,獲得的結果只是在頂級做用域中添加了一個函數a。
第2遍掃描:
// 在第2遍掃描時,其實已經發如今第1遍掃描中存在一個頂層的函數a(做用域被提高的),因此這個變量a實際上是覆蓋了第1遍掃描時的a函數 // 因此說,不是函數a覆蓋了變量a,而是變量a覆蓋了函數a。也就是說,當執行到這時,函數a已經被幹掉了,之後再也沒函數a什麼事了 var a = 10; { a = 99; // 提高做用域,將a的值設爲99,在這時尚未局部函數a呢! // 在第2遍掃描時仍然處理,因爲第1遍掃描,只掃描函數,因此是沒有頂級變量a的,所以,會將函數a提高到頂級做用域 // 而第2遍掃描,因爲存在頂級變量a,因此這個函數a會做爲局部函數處理,這是執行級代碼塊的規則 function a() { } a = 30; // 實際上替換的是局部函數a } console.log(a); // 第2遍執行這條語句,輸出99
第2遍掃描結束,執行console.log(a)後會輸出99。
如今看另一段代碼:
第1遍掃描:
var a = 10; // 不處理 { function hello() { // 提高到頂級做用域 a = 99; // 不處理 function a() { // 添加到hello函數做用域的符號表中 } a = 30; // 不處理 } hello(); // 不處理 } console.log(a); // 不處理
第2遍掃描:
var a = 10; // 定義頂層變量a { function hello() { // 提高到頂級做用域 a = 99; // 若是是非執行級代碼塊,會優先考慮局部同名符號,如局部函數a,所以,這裏實際上覆蓋的是函數a,而不是全局變量10 function a() { // 在非執行級代碼塊中,只在第1遍掃描中處理內嵌函數,第2遍掃描不處理,因此這是函數a已經被a=99覆蓋了 } a = 30; // 覆蓋a = 99 在hello函數內部,a的最終值是30 } hello(); // 執行 } console.log(a); // 輸出10
好了,如今你們清楚爲何最開始給出的兩段代碼,一個修改了全局變量a,一個沒修改全局變量a的緣由了吧。就是可執行級代碼塊和非可執行級代碼塊在處理做用域提高問題上的差別形成的。其實這麼多編程語言,只有JavaScript有這些問題,這也是js太靈活致使的,這就是要自由而付出的代價:讓某些程序的執行結果難以琢磨!