首先通過一段代碼進入討論的主題
var r = new Promise(function(resolve, reject){ console.log("a"); resolve() }); setTimeout(()=>console.log("d"), 0) r.then(() => console.log("c")); console.log("b") // a b c d
了解過 Promise 對象的都知道(如果還不了解,可以查看 Promise對象),Promise 新建后會立即執行,所以首先會輸出a,這個沒有問題。setTimeout 和 then 這兩個回調函數會在本輪事件循環結束以后執行,所以第二個輸出的是b,這個也沒有問題,但是回過頭來執行 setTimeout 和 then 方法時,setTimeout 的執行順序明明先於 then 方法且延遲時間為0毫秒,為什么卻后執行呢?是因為HTML5標准中規定setTimeout最小延遲時間不足4毫秒的仍然取值為4毫秒嗎?顯然不是,此處,就算把延遲時間從0改為4000毫秒,依然滯后於then 方法輸出。接下來進入正題
提示:阮一峰老師的文章 《JavaScript 運行機制詳解:再談Event Loop》 是解開本次探討答案的關鍵,建議仔細閱讀
一、為什么Javascript是單線程?
JavaScript的單線程,與它的用途有關。作為瀏覽器腳本語言,JavaScript的主要用途是與用戶互動,以及操作DOM。這決定了它只能是單線程,否則會帶來很復雜的同步問題。比如,假定JavaScript同時有兩個線程,一個線程在某個DOM節點上添加內容,另一個線程刪除了這個節點,這時瀏覽器應該以哪個線程為准?
所以,為了避免復雜性,從一誕生,JavaScript就是單線程,這已經成了這門語言的核心特征,將來也不會改變。
二、任務隊列
單線程就意味着,所有任務需要排隊,前一個任務結束,才會執行后一個任務。如果前一個任務耗時很長,后一個任務就不得不一直等着。
JavaScript語言的設計者意識到,這時主線程完全可以不管IO設備(很慢),掛起處於等待中的任務,先運行排在后面的任務。等到IO設備返回了結果,再回過頭,把掛起的任務繼續執行下去。
所有任務可以分成兩種,一種是同步任務(synchronous),另一種是異步任務(asynchronous)
- 同步任務指的是,在主線程上排隊執行的任務,只有前一個任務執行完畢,才能執行后一個任務;
- 異步任務指的是,不進入主線程、而進入"任務隊列"(task queue)的任務,只有"任務隊列"通知主線程,某個異步任務可以執行了,該任務才會進入主線程執行。
具體來說,異步執行的運行機制如下。(同步執行也是如此,因為它可以被視為沒有異步任務的異步執行。)
1、所有同步任務都在主線程上執行,形成一個執行棧
2、主線程之外,還存在一個 “任務隊列”。只要異步任務有了運行結果,就在 “任務隊列” 中,放置一個事件
3、一旦 “執行棧” 中的所有同步任務執行完畢,系統就會讀取 “任務隊列”,看看里面有哪些事件,於是那些與事件相對應的異步任務結束等待狀態,進入執行棧,開始執行
4、主線程不斷重復第三步操作
只要主線程空了,就會去讀取"任務隊列",這就是JavaScript的運行機制。這個過程會不斷重復
三、事件和回調函數
前面提到過,“任務隊列” 其實是一個事件的隊列,當IO設備完成一項任務時,就在 “任務隊列” 中添加一個事件,主線程讀取 “任務隊列”,就是讀取里面有哪些事件
“任務隊列” 中的事件,除了IO設備的事件以外,還包括一些用戶產生的事件(比如鼠標點擊、頁面滾動等)。只要指定過回調函數,這些事件發生時就會進入 “任務隊列”,等待主線程讀取
而所謂 “回調函數”,就是那些會被主線程掛起來的代碼。異步任務必須指定回調函數,當主線程開始執行異步任務,其實就是執行對應的回調函數
四、事件循環
基於前面的分析,總結一下 “任務隊列” 的特點:
1、“任務隊列” 是一個先進先出的數據結構,排在前面的事件,優先被主線程讀取
2、只要執行棧一清空,最早進入 “任務隊列” 的事件會率先進入主線程
3、如果 “任務隊列” 中存在定時器,主線程會先檢查一下執行時間,某些事件只有到了規定的時間,才能進入主線程
主線程從 “任務隊列” 中讀取事件,這個過程是循環不斷的,所以這種運行機制又稱為事件循環(Event Loop)

五、定時器
“任務隊列” 中除了放置異步任務的事件,還可以放置定時事件,即指定某些事件在多少事件后執行
以 setTimeout(fn, delay) 為例,它接受兩個參數,第一個是回調函數,第二個是推遲執行的毫秒數
console.log(1); setTimeout(function(){console.log(2);},1000); console.log(3); // 1 3 2
上面的代碼輸出結果毫無懸念,因為 setTimeout() 將第二行代碼推遲到1秒鍾以后才執行,但是,將延遲時間設為0以后依然輸出同樣的結果。理論上延遲時間為0表示的是不延遲、立即執行
但是基於前面的介紹,JS 引擎在執行這段代碼時,首先把第一行和第三行代碼存入執行棧,把第二行代碼存入 “任務隊列”,只有當執行棧清空以后,主線程才會讀取 “任務隊列”,這里的 0毫秒實際上表示的意思是:執行棧清空以后,主線程立即讀取存放在 “任務隊列” 中的該段代碼,所以輸入的結果是 1 3 2
console.log(1); setTimeout(function(){console.log(2);}, 0); console.log(3); // 1 3 2
六、宏觀任務(MacroTask)和 微觀任務(MicroTask)
在重學前端系列文章中,winter老師也引入了 “宏觀任務” 和 “微觀任務” 的概念
- 宏觀任務:宿主(我們)發起的任務
- 微觀任務:Javascript引擎發起的任務
微觀任務執行順序始終先於宏觀任務,並且每個宏觀任務可以包含多個微觀任務
(此處純屬個人理解:宏觀任務保存在 “任務隊列” 中,微觀任務保存在 執行棧中,事件循環其實也就是不斷執行宏觀任務)
var r = new Promise(function(resolve, reject){ console.log("a"); resolve() }); setTimeout(()=>console.log("d"), 0) r.then(() => console.log("c")); console.log("b")
再回頭來看看開頭的一段代碼,會不會豁然開朗了呢。JS 引擎首先會把Promise對象 和 console.log("b") 兩個微觀任務存入執行棧,把 setTimeout(宏觀任務)存入 “任務隊列”
所以在輸出 a 和 b 以后並不會按照預期那樣立即從 “任務隊列” 中讀取 setTimeout,因為 then方法是微觀任務Promise對象的回調函數,先於 setTimeout 執行
如果對以上內容都沒問題的話,可以再看一段示例代碼
Promise.resolve().then(()=>{ console.log('1') setTimeout(()=>{ console.log('2') },0) }) setTimeout(()=>{ console.log('3') Promise.resolve().then(()=>{ console.log('4') }) },0)
在交流群中看到有的小伙伴還是不太清楚正確的執行順序,基於前面的介紹,大致的分析過程及草圖如下:
1(紅色):JS 引擎會把微觀任務Promise存入執行棧,把宏觀任務setTimeout存入 “任務隊列”
2(綠色):主線程率先運行執行棧中的代碼,依次輸入1,然后把綠框的setTimeout存入 “任務隊列”
3(藍色):執行棧清空以后,會率先讀取 “任務隊列” 中最早存入的setTimeout(紅框的那個),並把這個定時器存入棧中,開始執行。這個定時器中的代碼都是微觀任務,所以可以一次性執行,依次輸出3 和 4
4(紫色):重復第3步的操作,讀取 “任務隊列” 中最后存入的setTimeout(綠框的那個),輸出2
所以最終的輸出結果就是 1 3 4 2

如果把上面代碼中的第二個 setTimeout 延遲時間從0改為3000,結果會稍有不同,按照上面的分析步驟來拆解應該也挺簡單
Promise.resolve().then(()=>{ console.log('1') setTimeout(()=>{ console.log('2') },0) }) setTimeout(()=>{ console.log('3') Promise.resolve().then(()=>{ console.log('4') }) }, 3000) // 1 2 3 4
還有一段在知乎上挺熱鬧的代碼,有人不解為什么不是輸出 1 2 3 4 5,其實按照上面的分析步驟就完全可以解釋這個問題
setTimeout(function(){console.log(4)},0); new Promise(function(resolve){ console.log(1) for( var i=0 ; i<10000 ; i++ ){ i==9999 && resolve() } console.log(2) }).then(function(){ console.log(5) }); console.log(3); // 1 2 3 5 4
另外一個會讓人感到迷惑的地方就是 resolve回調函數內部的那幾行代碼,輸出1以后接着跑1000次循環才調用resolve方法,其實resolve()的意思是把 Promise對象實例的狀態從pending變成 fulfilled(即成功)
成功的回調就是對應的then方法。所以resolve() 后面的 console.log(2) 會先執行,因為 resolve() 回調函數是在本輪事件循環的末尾執行 (關於這部分內容,可以參考
Promise對象 一文)
同理,如果把代碼中的 resolve() 去掉,也就是說 Promise 實例的狀態一直保持在pending,就永遠不會輸出5了
setTimeout(function(){console.log(4)},0); new Promise(function(resolve){ console.log(1) for( var i=0 ; i<10000 ; i++ ){ // i==9999 && resolve() } console.log(2) }).then(function(){ console.log(5) }); console.log(3); // 1 2 3 4