把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,這段代碼的輸出結果好像可以解釋得通,下面再看一段代碼:

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分鐘........

其實吧,別看這道題很簡單,可能有很多程序員都能蒙對答案,反正就這幾種可能,一共就3個數,蒙對的可能性是33.3333%,但如果讓你詳細解釋其中的原因呢?這恐怕沒有多少程序員能清楚地解釋其中的原理,現在就讓我來給出一個天衣無縫的解答:

儘管前面給出的兩段代碼並不複雜,但這裏面隱藏的信息量相當的大。在正式解答之前,先給出一些知識點:

1. 執行級代碼塊和非執行級代碼塊

這裏介紹一下兩種代碼塊的區別:

執行級代碼塊,顧名思義,就是在定義代碼塊的同時就執行了,看下面的代碼:

{
      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. 執行級代碼塊中的變量和函數自動提升作用域
  2. 如果有局部符號,執行級代碼塊會優先進行作用域提升,而非執行級代碼塊,會優先考慮局部符號

估計剛看到這兩點區別,很多同學有點懵,下面我就來挨個解釋下。

(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())

執行這段代碼,會拋出如下圖所示的異常:
把99%的程序員烤得外焦裏嫩的JavaScript面試題

這說明用let聲明的變量已經被鎖死在頂層作用域中,不可被其他作用域的變量替換。如果將let a = 14註釋掉,會拋出如下圖的異常:

把99%的程序員烤得外焦裏嫩的JavaScript面試題
這說明類b也被鎖死在頂層作用域中,不可被其他作用域的變量替換。

相對於可執行級代碼塊,非可執行級代碼塊就不會進行作用域提升,看如下代碼:

function myfun()
{
    var a = 1;
    var b = 2;
}
console.log(a + b);

執行這段代碼,會拋出如下圖的異常:
把99%的程序員烤得外焦裏嫩的JavaScript面試題

很明顯,是變量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 {

}

在運行這段代碼時會拋出如下圖所示的異常。
把99%的程序員烤得外焦裏嫩的JavaScript面試題

從這個異常來看,hello類似乎在第1遍掃描中沒處理,將hello類的定義放到最前面就可以了,代碼如下:

class hello {
}
var h = new hello();  // 正常創建類的實例

現在看下面的代碼:

var p1 = 10
{
    p1 = 40;
    class p1{}
    p1 = 50;
}

執行這段代碼,會拋出如下圖的異常:
把99%的程序員烤得外焦裏嫩的JavaScript面試題

很明顯,錯誤指向了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定義。這就要根據執行級代碼塊的規則了。

  1. 定義變量使用var。如果發現塊內有同名函數或類定義,會拋出重定義異常
  2. 未使用var定義變量。遇到同名函數,函數將被永久覆蓋,如果遇到同名類,會拋出如下異常:
    把99%的程序員烤得外焦裏嫩的JavaScript面試題

估計是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太靈活導致的,這就是要自由而付出的代價:讓某些程序的執行結果難以琢磨!

把99%的程序員烤得外焦裏嫩的JavaScript面試題

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章