【JS】深入理解事件循環,這一篇就夠了!(必看)

最近在看關於js的事件循環機制,(很多公司必問的面試題)看了幾篇文章後準備總結出來分享給大家

衆所周知,JavaScript 是一門單線程語言,雖然在 html5 中提出了 Web-Worker ,但這並未改變 JavaScript 是單線程這一核心,,可是瀏覽器又能很好的處理異步請求,那麼到底是爲什麼呢?

瀏覽器執行線程

在解釋事件循環之前首先先解釋一下瀏覽器的執行線程:
瀏覽器是多進程的,瀏覽器每一個 tab 標籤都代表一個獨立的進程,其中瀏覽器渲染進程(瀏覽器內核)屬於瀏覽器多進程中的一種,主要負責頁面渲染,腳本執行,事件處理等
其包含的線程有:GUI 渲染線程(負責渲染頁面,解析 HTML,CSS 構成 DOM 樹)、JS 引擎線程、事件觸發線程、定時器觸發線程、http 請求線程等主要線程

關於執行中的線程:

主線程:也就是 js 引擎執行的線程,這個線程只有一個,頁面渲染、函數處理都在這個主線程上執行。
工作線程:也稱幕後線程,這個線程可能存在於瀏覽器或js引擎內,與主線程是分開的,處理文件讀取、網絡請求等異步事件。

任務隊列( Event Queue )

所有的任務可以分爲同步任務和異步任務,同步任務,顧名思義,就是立即執行的任務,同步任務一般會直接進入到主線程中執行;而異步任務,就是異步執行的任務,比如ajax網絡請求,setTimeout 定時函數等都屬於異步任務,異步任務會通過任務隊列的機制(先進先出的機制)來進行協調。具體的可以用下面的圖來大致說明一下:

同步和異步任務分別進入不同的執行環境,同步的進入主線程,即主執行棧,異步的進入任務隊列。主線程內的任務執行完畢爲空,會去任務隊列讀取對應的任務,推入主線程執行。 上述過程的不斷重複就是我們說的 Event Loop (事件循環)。

在事件循環中,每進行一次循環操作稱爲tick,通過閱讀規範可知,每一次 tick 的任務處理模型是比較複雜的,其關鍵的步驟可以總結如下:

1.在此次 tick 中選擇最先進入隊列的任務( oldest task ),如果有則執行(一次)
2.檢查是否存在 Microtasks ,如果存在則不停地執行,直至清空Microtask Queue
3.更新 render
4.主線程重複執行上述步驟

可以用一張圖來說明下流程:



這裏相信有人會想問,什麼是 microtasks ?規範中規定,task分爲兩大類, 分別是 Macro Task (宏任務)和 Micro Task(微任務), 並且每個宏任務結束後, 都要清空所有的微任務,這裏的 Macro Task也是我們常說的 task ,有些文章並沒有對其做區分,後面文章中所提及的task皆看做宏任務( macro task)。

宏任務主要包含:script( 整體代碼)、setTimeout、setInterval、I/O、UI 交互事件、setImmediate(Node.js 環境)
微任務主要包含:Promise、MutaionObserver、process.nextTick(Node.js 環境)

setTimeout/Promise 等API便是任務源,而進入任務隊列的是由他們指定的具體執行任務。來自不同任務源的任務會進入到不同的任務隊列。其中 setTimeout 與 setInterval 是同源的。

舉例

掌握概念之後,我們來做一個例子強化一下:

console.log('script start');

setTimeout(function() {
  console.log('setTimeout');
}, 0);

Promise.resolve().then(function() {
  console.log('promise1');
}).then(function() {
  console.log('promise2');
});

console.log('script end');

整體 script 作爲第一個宏任務進入主線程,遇到 console.log,輸出 script start
遇到 setTimeout,其回調函數被分發到宏任務 Event Queue 中
遇到 Promise,其 then函數被分到到微任務 Event Queue 中,記爲 then1,之後又遇到了 then 函數,將其分到微任務 Event Queue 中,記爲 then2
遇到 console.log,輸出 script end

至此,Event Queue 中存在三個任務:宏任務:setTimeout 微任務:then1、then2

執行微任務,首先執行then1,輸出 promise1, 然後執行 then2,輸出 promise2,這樣就清空了所有微任務
執行 setTimeout 任務,輸出 setTimeout 至此,輸出的順序是:script start, script end, promise1, promise2, setTimeout

再來一個題目,來做個練習:

console.log('script start');

setTimeout(function() {
  console.log('timeout1');
}, 10);

new Promise(resolve => {
    console.log('promise1');
    resolve();
    setTimeout(() => console.log('timeout2'), 10);
}).then(function() {
    console.log('then1')
})

console.log('script end');

這個題目就稍微有點複雜了,我們再分析下:

首先,事件循環從宏任務 (macrotask) 隊列開始,最初始,宏任務隊列中,只有一個 scrip t(整體代碼)任務;當遇到任務源 (task source) 時,則會先分發任務到對應的任務隊列中去。所以,就和上面例子類似,首先遇到了console.log,輸出 script start; 接着往下走,遇到 setTimeout 任務源,將其分發到任務隊列中去,記爲 timeout1; 接着遇到 promise,new promise 中的代碼立即執行,輸出 promise1, 然後執行 resolve ,遇到 setTimeout ,將其分發到任務隊列中去,記爲 timemout2, 將其 then 分發到微任務隊列中去,記爲 then1; 接着遇到 console.log 代碼,直接輸出 script end 接着檢查微任務隊列,發現有個 then1 微任務,執行,輸出then1 再檢查微任務隊列,發現已經清空,則開始檢查宏任務隊列,執行 timeout1,輸出 timeout1; 接着執行 timeout2,輸出 timeout2 至此,所有的都隊列都已清空,執行完畢。其輸出的順序依次是:script start, promise1, script end, then1, timeout1, timeout2

用流程圖看更清晰:


總結

有個小 tip:從規範來看,microtask 優先於 task 執行,所以如果有需要優先執行的邏輯,放入microtask 隊列會比 task 更早的被執行。

最後的最後,記住,JavaScript 是一門單線程語言,異步操作都是放到事件循環隊列裏面,等待主執行棧來執行的,並沒有專門的異步執行線程。。

最後補充兩張景點的圖



參考文獻:
https://www.cnblogs.com/cangqinglang/p/8963557.html
https://juejin.im/post/5da742936fb9a04e223333ff
https://www.cnblogs.com/yugege/p/9598265.html
https://juejin.im/post/5c2ec3b66fb9a049eb3c1012#heading-3

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