JavaScript同步、非同步、回撥執行順序之經典閉包setTimeout面試題分析

發表於2017-04-04

同步、非同步、回撥?傻傻分不清楚。

大家注意了,教大家一道口訣:

同步優先、非同步靠邊、回撥墊底(讀起來不順)

用公式表達就是:

同步 => 非同步 => 回撥

這口訣有什麼用呢?用來對付面試的。

有一道經典的面試題:

這道題目大家都遇到過了吧,那麼為什麼會輸出這個呢?記住我們的口訣 同步 => 非同步 => 回撥

1、for迴圈和迴圈體外部的console是同步的,所以先執行for迴圈,再執行外部的console.log。(同步優先)

2、for迴圈裡面有一個setTimeout回撥,他是墊底的存在,只能最後執行。(回撥墊底)

那麼,為什麼我們最先輸出的是5呢?

非常好理解,for迴圈先執行,但是不會給setTimeout傳參(回撥墊底),等for迴圈執行完,就會給setTimeout傳參,而外部的console列印出5是因為for迴圈執行完成了。

知乎有大神講解過 80% 應聘者都不及格的 JS 面試題 ,就是以這個例子為開頭的。但是沒有說為什麼setTimeout是輸出5個5。

這裡涉及到JavaScript執行棧和訊息佇列的概念,概念的詳細解釋可以看阮老師的 JavaScript 執行機制詳解:再談Event Loop – 阮一峰的網路日誌,或者看 併發模型與Event Loop

JavaScript同步、非同步、回撥執行順序之經典閉包setTimeout面試題分析

《圖片來自於MDN官方》

我拿這個例子做一下講解,JavaScript單執行緒如何處理回撥呢?JavaScript同步的程式碼是在堆疊中順序執行的,而setTimeout回撥會先放到訊息佇列,for迴圈每執行一次,就會放一個setTimeout到訊息佇列排隊等候,當同步的程式碼執行完了,再去呼叫訊息佇列的回撥方法。

在這個經典例子中,也就是說,先執行for迴圈,按順序放了5個setTimeout回撥到訊息佇列,然後for迴圈結束,下面還有一個同步的console,執行完console之後,堆疊中已經沒有同步的程式碼了,就去訊息佇列找,發現找到了5個setTimeout,注意setTimeout是有順序的。

那麼,setTimeout既然在最後才執行,那麼他輸出的i又是什麼呢?答案就是5。。有人說不是廢話嗎?

現在告訴大家為什麼setTimeout全都是5,JavaScript在把setTimeout放到訊息佇列的過程中,迴圈的i是不會及時儲存進去的,相當於你寫了一個非同步的方法,但是ajax的結果還沒返回,只能等到返回之後才能傳參到非同步函式中。
在這裡也是一樣,for迴圈結束之後,因為i是用var定義的,所以var是全域性變數(這裡沒有函式,如果有就是函式內部的變數),這個時候的i是5,從外部的console輸出結果就可以知道。那麼當執行setTimeout的時候,由於全域性變數的i已經是5了,所以傳入setTimeout中的每個引數都是5。很多人都會以為setTimeout裡面的i是for迴圈過程中的i,這種理解是不對的。

===========================================分割線=========================================

看了上面的解釋,你是不是有點頭暈,沒事,繼續深入講解。

我們給第一個例子加一行程式碼。

來,大家再跟著我一起念一遍:同步 => 非同步 => 回撥 (強化記憶)

這個例子可以很清楚的看到先執行for迴圈,for迴圈裡面的console是同步的,所以先輸出,for迴圈結束後,執行外部的console輸出5,最後再執行setTimeout回撥 55555。。。

=====================================分割線============================================

這麼簡單,不夠帶勁是不是,那麼面試官會問,怎麼解決這個問題?

最簡單的當然是let語法啦。。

咦,有同學問,為什麼外部的i報錯了呢?
又有同學問,你這個口訣在這裡好像不適應啊?

let是ES6語法,ES5中的變數作用域是函式,而let語法的作用域是當前塊,在這裡就是for迴圈體。在這裡,let本質上就是形成了一個閉包。也就是下面這種寫法一樣的意思。如果面試官對你說用下面的這種方式,還有let的方式,你可以嚴肅的告訴他:這就是一個意思!這也就是為什麼有人說let是語法糖。

面試官總說閉包、閉包、閉包,什麼是閉包?後面再講。

寫成ES5的形式,你是不是發現就適合我說的口訣了?而用let的時候,你發現看不懂?那是因為你沒有真正瞭解ES6的語法原理。

我們來分析一下,用了let作為變數i的定義之後,for迴圈每執行一次,都會先給setTimeout傳參,準確的說是給loop傳參,loop形成了一個閉包,這樣就執行了5個loop,每個loop傳的引數分別是0,1,2,3,4,然後loop裡面的setTimeout會進入訊息佇列排隊等候。當外部的console執行完畢,因為for迴圈裡的i變成了一個新的變數 _i ,所以在外部的console.log(i)是不存在的。

現在可以解釋閉包的概念了:當內部函式以某一種方式被任何一個外部函式作用域訪問時,一個閉包就產生了。

我知道你又要我解釋這句話了,loop(_i)是外部函式,setTimeout是內部函式,當setTimeout被loop的變數訪問的時候,就形成了一個閉包。(別說你又暈了?)

隨便舉個新的例子。

跟我一起念口訣:同步 => 非同步 => 回撥 (強化記憶)
先執行函式t,然後js就進入了t內部,定義了一個變數,然後執行函式b,進入b內部,然後列印a,這裡都是同步的程式碼,沒什麼異議,那麼這裡怎麼解釋閉包:函式t是外部函式,函式b是內部函式,當函式b被函式t的變數訪問的時候,就形成了閉包。

========================================分割線==============================================

上面主要講了同步和回撥執行順序的問題,接著我就舉一個包含同步、非同步、回撥的例子。

看到這個例子,千萬不要害怕?,先讀一遍口訣:同步 => 非同步 => 回撥 (強化記憶)

1、看同步程式碼:a變數是一個Promise,我們知道Promise是非同步的,是指他的then()和catch()方法,Promise本身還是同步的,所以這裡先執行a變數內部的Promise同步程式碼。(同步優先)

2、Promise內部有4個console,第二個是一個setTimeout回撥(回撥墊底)。所以這裡先輸出1,3,4回撥的方法丟到訊息佇列中排隊等著。

3、接著執行resolve(true),進入then(),then是非同步,下面還有同步沒執行完呢,所以then也滾去訊息佇列排隊等候。(真可憐)(非同步靠邊)
4、b變數也是一個Promise,和a一樣,執行內部的同步程式碼,輸出5,setTimeout滾去訊息佇列排隊等候。

5、最下面同步輸出7。

6、同步的程式碼執行完了,JavaScript就跑去訊息佇列呼叫非同步的程式碼:非同步,出來執行了。這裡只有一個非同步then,所以輸出8。

7、非同步也over,輪到回撥的孩子們:回撥,出來執行了。這裡有2個回撥在排隊,他們的時間都設定為0,所以不受時間影響,只跟排隊先後順序有關。則先輸出a裡面的回撥2,最後輸出b裡面的回撥6。

8、最終輸出結果就是:1、3、4、5、7、8、2、6。

我們還可以稍微做一點修改,把a裡面Promise的 setTimeout(() => console.log(2), 0)改成 setTimeout(() => console.log(2), 2),對,時間改成了2ms,為什麼不改成1試試呢?1ms的話,瀏覽器都還沒有反應過來呢。你改成大於或等於2的數字就能看到2個setTimeout的輸出順序發生了變化。所以回撥函式正常情況下是在訊息佇列順序執行的,但是使用setTimeout的時候,還需要注意時間的大小也會改變它的順序。

====================================分割線==================================================

口訣不一定是萬能的,只能作為一個輔助,更重要的還是要理解JavaScript的執行機制,才能對程式碼執行順序有清晰的路線。

還有async/await等其他非同步的方案,不管是哪種非同步,基本都適用這個口訣,對於新手來說,可以快速讀懂面試官出的js筆試題目。以後再也不用害怕做筆試題啦。

特殊情況下不適應口訣的也很正常,JavaScript博大精深,不是一句話就能概括出來的。

最後,在跟著我念一遍口訣:同步 => 非同步 => 回撥

相關文章