1. 程式人生 > >把99%的程式設計師烤得外焦裡嫩的JavaScript面試題

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

 

執行這段程式碼,會丟擲如下圖所示的異常:   這說明用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定義。這就要根據執行級程式碼塊的規則了。   1. 定義變數使用var。如果發現塊內有同名函式或類定義,會丟擲重定義異常 2. 未使用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太靈活導致的,這就是要自由而付出的代價:讓某些程式的執行結果難以琢磨!