一道面試題引發的“血案”

大雄沒了哆啦A夢發表於2018-09-26

es6之前,js的作用域只有兩種,全域性作用域和函式作用域,沒有像C和java那樣的塊級作用域,於是對於學了C或者java這類語言的然後學習js的同學來說,會遇到很多坑。js的這個特性導致了程式碼的可閱讀性、維護性和容錯性都不太好。因此es6可以用let來申明變數,這種方式申明的變數是隻能在塊作用域裡訪問,不能跨塊訪問,也不能跨函式訪問。那麼我們在使用let的時候,真的就完全知道它怎麼用了嗎?

引子

看到這樣的一個面試題

for(let i = (setTimeout(()=>console.log(i), 2333) , 0); i < 2; i++) {
  
}
複製程式碼

大家猜猜2333毫秒後輸出的結果是什麼?這裡就是“血案”現場了
A類同學:2 ×
B類同學: 0 √

我想A類同學佔了大多數,包括我在內

一道面試題引發的“血案”

前期知識點

非同步

js中的非同步包含以下幾種:
1、定時器
2、事件處理函式
3、Promise
4、回撥函式
js非同步的存在是因為,js是單執行緒的,如果一些任務需要處理時間比較耗時,那麼下面的任務就會一直等這個任務執行完成才能繼續,比如一些IO任務,這樣就會導致執行效率低效,所以js的設計者意識到了這點,設計了非同步執行任務,主執行緒不必等待非同步任務完成才執行下去,這樣我們就可以把一些耗時的任務設計成非同步任務,將其掛起,讓主執行緒處理完一些比較重要的任務(ui渲染等)後回頭再來執行掛起的非同步任務。

作用域鏈

js存在兩種型別的作用域,全域性作用域和函式作用域。js執行的時候,會建立一個執行上下文(context),並將該執行上下文中的所有變數、函式和函式引數放入一個物件中AO/VO(變數物件|活動物件),並且會儲存父級的AO/VO到[[scope]]屬性當中。然後在查詢變數的時候,會從當前的AO/VO中查詢變數,如果沒有,就往[[scope]]屬性父級VO/AO查詢變數,一直到全域性的VO中,這樣就形成了一個scope chain(作用域鏈)。通俗點來講,作用域鏈就是js在執行的時候用於搜尋變數所在的一條鏈子,所有變數的獲取變數會順著這條鏈子往上查詢,在本作用域內找不到變數的申明,就會往上一級的作用域中查詢,直到在全域性作用域中還找不到,就找不到該變數了。看下面的例子。

var outer = 1;
function func1() {
    var inner1 = 2;
    function func2() {
        var inner2 = 3
        console.log(inner2, inner1, outer); // 3 2 1
    }
    func2()
}

func1();

複製程式碼

一道面試題引發的“血案”
1、首先獲取inner2,在func2的作用域中(活動物件)找到了inner2的申明,找到了,並且是3;
2、接著獲取inner1,發現func2的作用域中沒有inner1的申明,那麼往建立func2的作用域中查詢,即func1中查詢inner1的申明,並且為2;
3、接著獲取outer,在func2中的作用域中找不到,往作用域鏈的上一級找,func1中也沒有outer的申明,那麼就繼續往上一級找,在全域性作用域中找到了outer,所以是1。

接著我們講下閉包,所謂閉包用一句話來說就是,函式中的函式,並且裡面的函式引用了外面的函式的變數。我們瞭解了作用域鏈,那麼我們就知道,函式內部是可以訪問函式外部的變數的,所以,如果我們在函式中的函式中有訪問函式外部變數,且該內部函式被返回的時候就形成了閉包。看下面例子:

function func() {
    var name = 'liming';
    var sayName = function() {
        console.log(name)
    }
    return sayName;
}

var sayName = func();
sayName(); // 輸出liming
複製程式碼

如上面,就是閉包的一個例子,總結開來有兩個特點:
1、外部函式包含內部函式,且內部函式訪問的外部函式的變數
2、返回內部函式給外部呼叫

閉包有個缺陷就是容易導致記憶體洩漏,普通函式呼叫完後,js引擎就會銷燬函式裡面的變數,但是閉包的話就不會釋放了,所以需要注意點。

解析

選答案A的同學

對於A類同學,答案是錯的,但是可以看出A類同學對js的非同步和閉包比較熟悉。我們知道setTimeout裡面的函式是非同步執行的,屬於js裡面的巨集任務(js的非同步任務分巨集任務和微任務),需要等待js的主執行緒執行完畢且等到設定的時間後才從巨集佇列裡面取出來執行。所以,等到setTimeout的回撥執行的時候,回撥函式要獲取i的值,這個時候回到函式裡面沒有i的定義,那麼js引擎就會往上一級作用域鏈中找i,這個時候就找到上一級作用域中的i,A類同學覺得這個時候迴圈已經結束了(因為for是主執行緒),那麼這個時候的i應該是2了,所以輸出的應該++了兩次的2。這也就是閉包的知識點,js的設計是,內部可以訪問外部,而外部不可以訪問內部,所以在setTimeout中的回撥中,它可以訪問得到外部的i,其實如果把let換成var的話,這個答案就是對的。

關於倒數計時,這裡有個東西多說一句,就是setInterval的倒數計時不是在回撥執行完畢後才開始的。這就會導致一種情況,就是如果回撥函式裡面執行的程式碼時間比倒數計時時間長,那麼下次插入佇列中的回撥就會被取消,也就是倒數計時到了以後,這次回撥不會執行了,所以建議統一使用setTimeout來代替setInterval。

選擇B的同學

選擇B的同學,要不就是剛學習js的(也可能是蒙對☺),要不就是對let知識點很熟悉的。

let關鍵字

let關鍵字申明的變數具有塊級作用域的作用,具有以下特點:
1、不可重複申明同個變數
2、不存在變數提升,所以必須先申明後使用
3、只有塊內可見,不會影響塊外的變數
其實let還有一個特點,就是在for迴圈當中,每輪迴圈都是一個新的值。看下面的的例子:

for(let i = 0; i < 2; i++) {
    setTimeout(() => {
        console.log(i); // 分別輸出0和1
    }, 0)
}

複製程式碼

從這個例子可以看出,let變數在for迴圈中,都會被重新賦值一個新的值,因此上面程式碼中,for迴圈中獲取的i值都是一個新的,並且這個新i的值是上一次迴圈的i的值。類似這樣的虛擬碼:

for(var i = 0; i < 2; i++) {
    var new_i = i; // 新的i,且新的i應該是和真正的i關聯的,比如是new_i_0、new_i_1之類的,這段是虛擬碼,用來說明,評論的同學說,let的i是被挾持了,這個解釋很贊,所以for中的i其實都是被js引擎挾持了的i,不是我們看到的i
    setTimeout(() => {
        console.log(new_i); // 分別輸出0和1
    }, 0)
}
複製程式碼

個人覺得,這個是let的塊級作用域相關,每次迴圈的時候的i都是塊級作用域,只對本次迴圈可見,下次迴圈不可見。

所以,我們以後如果需要再for迴圈中獲取迴圈項的時候,可以不用立即執行函式來實現了,可以改為let了。

回到正題。for迴圈的第一個語句是初始化,這個時候的i就是原本的i,初始化為0,後面的i都是每次迴圈新生成的i,與初始化的i無關,所以到2333毫秒以後,i的值任然為0,因此列印出來的i就是0了。

for(let i = (setTimeout(()=>console.log(i), 2333) , 0); i < 2; i++) {
  
}
複製程式碼

總結

本篇文章通過一個特殊的面試題,引出了js的非同步、作用域鏈、閉包和let的知識點。

非同步包含:
1、定時器
2、事件處理函式
3、Promise
4、部分回撥函式的方式
非同步函式的執行時需要主執行緒空閒的時候執行的,所以我們會把耗時的任務處理為非同步。

作用域鏈:
每個函式執行的時候都會建立一個作用域鏈的物件,它包含了函式內的所有變數以及建立該函式的函式的所有變數,一直到全域性變數,訪問變數的時候就會沿著這條鏈子找。

閉包:
1、外部函式包含內部函式,且內部函式訪問的外部函式的變數
2、返回內部函式給外部呼叫

let:
1、不可重複申明同個變數
2、不存在變數提升,所以必須先申明後使用
3、只有塊內可見,不會影響塊外的變數
還有在for迴圈中,每次迴圈獲取let宣告的變數都是一個新的變數,而不是初始化時候的那個變數。

相關文章