把99%的程式員烤得外焦裡嫩的JavaScript面試題
最近有學員給出一段令人匪夷所思的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,這段代碼的輸出結果好像可以解釋得通,下面再看一段代碼:
function hello() {
a = 99;
function a() {
}
a = 30;
}
hello();
大家可以猜猜,這段代碼會輸出什麼結果呢?10?99?30?,答案是10。也就是說,hello函數壓根就沒有修改全局變量a 值,那麼這是為什麼呢?
根據我們前面的結論,當執行到a = 99時,覆寫變量a的值,然後執行函數a的定義代碼,接下來執行a = 30,将函數a改成了變量a,這個解釋似乎也沒什麼問題,但是,問題就是,與第1段代碼的輸出不一樣。第1段代碼修改了全局變量a的值,第2段代碼沒有修改全局變量a的值,這是為什麼呢?
現在思考3分鐘........
其實吧,别看這道題很簡單,可能有很多程式員都能蒙對答案,反正就這幾種可能,一共就3個數,蒙對的可能性是33.3333%,但如果讓你詳細解釋其中的原因呢?這恐怕沒有多少程式員能清楚地解釋其中的原理,現在就讓我來給出一個天衣無縫的解答:
盡管前面給出的兩段代碼并不複雜,但這裡面隐藏的資訊量相當的大。在正式解答之前,先給出一些知識點:
- 執行級代碼塊和非執行級代碼塊
這裡介紹一下兩種代碼塊的差別:
執行級代碼塊,顧名思義,就是在定義代碼塊的同時就執行了,看下面的代碼:
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;
執行這段代碼,會抛出如下圖的異常:
很明顯,是變量a沒有定義。
(2)如果有局部符号,執行級代碼塊會優先進行作用域提升,而非執行級代碼塊,會優先考慮局部符号,看下面的解釋。
先上代碼:
執行級代碼塊
var a = 100
a = 10;
function a() {
}
a = 20;
console.log(a); // 輸出10
非執行級代碼塊
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呢?這些疑問會在後面一一解開。
- 多遍掃描
什麼叫多遍掃描呢?這裡的掃描指的是對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函數是在使用之後定義的
那麼在什麼情況下需要至少掃描2遍呢?
對于函數、類等文法元素與定義順序無關的語言必須至少掃描2遍。這是因為第1遍需要确定文法元素(函數、類等)的定義,第2遍才是使用這些文法元素。經過測試,JavaScript的代碼是與定義順序無關的,也就是說,下面的代碼可以正常運作:
很顯然,JavaScript解析器至少對代碼掃描了2次。對于動态語言(如JavaScript),通常是一邊掃描一邊執行的(并不像Java這樣的靜态語言,掃描時并不執行,直到生成.class檔案後才通過JVM執行)。一般第1遍負責執行定義代碼(如定義函數、類等),第2遍負責執行其他代碼。現在就讓我們看看JavaScript的這2遍掃描都做了什麼。
先給出結論:JavaScript的第1遍掃描隻處理函數和類定義(當然,還有可能處理其他的定義,但本文隻讨論函數和類),JavaScript的第2遍掃描負責處理其他代碼。但函數和類的處理方式是不同的(見後面的解釋)。
結論是給出了,下面給出支援這個結論的證據:
看下面的代碼:
console.log('hello')
執行這段代碼,會輸出hello。很明顯,hello函數在調用之後定義。由于讀取檔案,是順序進行的,是以如果隻掃描一遍代碼,在調用hello函數時不可能知道hello函數的存在。是以,唯一的解釋就是掃描了2遍。第1遍,先掃描hello函數的定義部分,然後将hello函數的定義儲存到目前作用域的符号表中。第2次掃描,調用hello函數時,就會到目前作用域的符号表查詢是否存在函數hello,如果存在,調用,不存在,則抛出異常。
那麼在第1遍掃描時,處理類和函數的規則是否相同呢?先看下面的代碼:
var h = new hello(); // 抛出異常
class hello {
在運作這段代碼時會抛出如下圖所示的異常。
從這個異常來看,hello類似乎在第1遍掃描中沒處理,将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遍掃描就會按部就班執行其他代碼了,這一點在以後分析,下面先看其他知識點。
- 下面哪段代碼會抛出異常
先來做這道題:
第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段代碼:
在這段代碼中,變量a和函數a都位于頂級作用域中,是以就不存在提升作用域的問題了。當第1遍掃描時,函數a被儲存到符号表中。第2遍掃描時,執行到var a = 99時,會發現函數a已經在目前作用域了,是以在同一個作用域中,後面處理的符号會覆寫前面的同名符号,是以函數a就被變量a覆寫了。是以,會輸出99。
現在來解釋第4段代碼:
var a = 99;
function a() {
}
console.log(a)
第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定義。這就要根據執行級代碼塊的規則了。
- 定義變量使用var。如果發現塊内有同名函數或類定義,會抛出重定義異常
- 未使用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。
現在看另外一段代碼:
var a = 10; // 不處理
function hello() { // 提升到頂級作用域
a = 99; // 不處理
function a() { // 添加到hello函數作用域的符号表中
}
a = 30; // 不處理
}
hello(); // 不處理
console.log(a); // 不處理
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太靈活導緻的,這就是要自由而付出的代價:讓某些程式的執行結果難以琢磨!
原文位址
https://www.cnblogs.com/nokiaguy/p/12867587.html