面試率 90% 的JS事件回圈Event Loop,看這篇就夠了!! !

事件回圈(Event Loop)大家應該并不陌生,它是前端極其重要的基礎知識,在平時的討論或者面試中也是一個非常高頻的話題,
理解 JavaScript 的事件回圈往往伴隨著宏任務和微任務、JavaScript 單執行緒執行程序及瀏覽器異步機制等相關問題,而瀏覽器和 NodeJS 中的事件回圈實作也是有很大差別,熟悉事件回圈,了解瀏覽器運行機制將對我們理解 JavaScript 的執行程序和排查運行問題有很大幫助,
一、瀏覽器 JS 異步執行的原理
JS 是單執行緒的,也就是同一個時刻只能做一件事情,那么請你思考:為什么瀏覽器可以同時執行異步任務呢?
因為瀏覽器是多執行緒的,當 JS 需要執行異步任務時,瀏覽器會另外啟動一個執行緒去執行該任務,也就是說,“JS 是單執行緒的”指的是執行 JS 代碼的執行緒只有一個,是瀏覽器提供的 JS 引擎執行緒(主執行緒),瀏覽器中還有定時器執行緒和 HTTP 請求執行緒等,這些執行緒主要不是來跑 JS 代碼的,
比如主執行緒中需要發一個 AJAX 請求,就把這個任務交給另一個瀏覽器執行緒(HTTP 請求執行緒)去真正發送請求,待請求回來了,再將 callback 里需要執行的 JS 回呼交給 JS 引擎執行緒去執行,即瀏覽器才是真正執行發送請求這個任務的角色,而 JS 只是負責執行最后的回呼處理,所以這里的異步不是 JS 自身實作的,其實是瀏覽器為其提供的能力,

以 Chrome 為例,瀏覽器不僅有多個執行緒,還有多個行程,如渲染行程、GPU 行程和插件行程等,而每個 tab 標簽頁都是一個獨立的渲染行程,所以一個 tab 例外崩潰后,其他 tab 基本不會被影響,作為前端開發者,主要重點關注其渲染行程,渲染行程下包含了 JS 引擎執行緒、HTTP 請求執行緒和定時器執行緒等,這些執行緒為 JS 在瀏覽器中完成異步任務提供了基礎,

二、事件驅動淺析
瀏覽器異步任務的執行原理背后其實是一套事件驅動的機制,事件觸發、任務選擇和任務執行都是由事件驅動機制來完成的,NodeJS 和瀏覽器的設計都是基于事件驅動的,簡而言之就是由特定的事件來觸發特定的任務,這里的事件可以是用戶的操作觸發的,如 click 事件;也可以是程式自動觸發的,比如瀏覽器中定時器執行緒在計時結束后會觸發定時器事件,而本文的主題內容事件回圈其實就是在事件驅動模式中來管理和執行事件的一套流程,
以一個簡單場景為例,假設游戲界面上有一個移動按鈕和人物模型,每次點擊右移后,人物模型的位置需要重新渲染,右移1像素,根據渲染時機的不同我們可以用不同的方式來實作,

實作方式一:事件驅動,點擊按鈕后,修改坐標 positionX 時,立即觸發界面渲染的事件,觸發重新渲染,
實作方式二:狀態驅動或資料驅動,點擊按鈕后,只修改坐標 positionX,不觸發界面渲染,在此之前會啟動一個定時器 setInterval,或者利用 requestAnimationFrame 來不斷地檢測 positionX 是否有變化,如果有變化,則立即重新渲染,
瀏覽器中的點擊事件處理也是典型的基于事件驅動,在事件驅動中,當有事件觸發后,被觸發的事件會按順序暫時存在一個佇列中,待 JS 的同步任務執行完成后,會從這個佇列中取出要處理的事件并進行處理,那么具體什么時候取任務、優先取哪些任務,這就由事件回圈流程來控制了,
三、瀏覽器中的事件回圈
執行堆疊與任務佇列
JS 在決議一段代碼時,會將同步代碼按順序排在某個地方,即執行堆疊,然后依次執行里面的函式,當遇到異步任務時就交給其他執行緒處理,待當前執行堆疊所有同步代碼執行完成后,會從一個佇列中去取出已完成的異步任務的回呼加入執行堆疊繼續執行,遇到異步任務時又交給其他執行緒,.....,如此回圈往復,而其他異步任務完成后,將回呼放入任務佇列中待執行堆疊來取出執行,
JS 按順序執行執行堆疊中的方法,每次執行一個方法時,會為這個方法生成獨有的執行環境(背景關系 context),待這個方法執行完成后,銷毀當前的執行環境,并從堆疊中彈出此方法(即消費完成),然后繼續下一個方法,

可見,在事件驅動的模式下,至少包含了一個執行回圈來檢測任務佇列是否有新的任務,通過不斷回圈去取出異步回呼來執行,這個程序就是事件回圈,而每一次回圈就是一個事件周期或稱為一次 tick,
宏任務和微任務
任務佇列不只一個,根據任務的種類不同,可以分為微任務(micro task)佇列和宏任務(macro task)佇列,
事件回圈的程序中,執行堆疊在同步代碼執行完成后,優先檢查微任務佇列是否有任務需要執行,如果沒有,再去宏任務佇列檢查是否有任務執行,如此往復,微任務一般在當前回圈就會優先執行,而宏任務會等到下一次回圈,因此,微任務一般比宏任務先執行,并且微任務佇列只有一個,宏任務佇列可能有多個,另外我們常見的點擊和鍵盤等事件也屬于宏任務,
下面我們看一下常見宏任務和常見微任務:

console.log('同步代碼1');
setTimeout(() => {
console.log('setTimeout')
}, 0)
new Promise((resolve) => {
console.log('同步代碼2')
resolve()
}).then(() => {
console.log('promise.then')
})
console.log('同步代碼3');
// 最終輸出"同步代碼1"、"同步代碼2"、"同步代碼3"、"promise.then"、"setTimeout"
上面的代碼將按如下順序輸出為:"同步代碼1"、"同步代碼2"、"同步代碼3"、"promise.then"、"setTimeout",具體分析如下,
(1)setTimeout 回呼和 promise.then 都是異步執行的,將在所有同步代碼之后執行;
順便提一下,在瀏覽器中 setTimeout 的延時設定為 0 的話,會默認為 4ms,NodeJS 為 1ms,
(2)雖然 promise.then 寫在后面,但是執行順序卻比 setTimeout 優先,因為它是微任務;
(3)new Promise 是同步執行的,promise.then 里面的回呼才是異步的,

也有人這樣去理解:微任務是在當前事件回圈的尾部去執行;宏任務是在下一次事件回圈的開始去執行,我們來看看微任務和宏任務的本質區別是什么,
我們已經知道,JS 遇到異步任務時會將此任務交給其他執行緒去處理,自己的主執行緒繼續往后執行同步任務,比如 setTimeout 的計時會由瀏覽器的定時器執行緒來處理,待計時結束,就將定時器回呼任務放入任務佇列等待主執行緒來取出執行,前面我們提到,因為 JS 是單執行緒執行的,所以要執行異步任務,就需要瀏覽器其他執行緒來輔助,即多執行緒是 JS 異步任務的一個明顯特征,
我們再來分析下 promise.then(微任務)的處理,當執行到 promise.then 時,V8 引擎不會將異步任務交給瀏覽器其他執行緒,而是將回呼存在自己的一個佇列中,待當前執行堆疊執行完成后,立馬去執行 promise.then 存放的佇列,promise.then 微任務沒有多執行緒參與,甚至從某些角度說,微任務都不能完全算是異步,它只是將書寫時的代碼修改了執行順序而已,
setTimeout 有“定時等待”這個任務,需要定時器執行緒執行;ajax 請求有“發送請求”這個任務,需要 HTTP 執行緒執行,而 promise.then 它沒有任何異步任務需要其他執行緒執行,它只有回呼,即使有,也只是內部嵌套的另一個宏任務,
簡單小結一下微任務和宏任務的本質區別
宏任務特征:有明確的異步任務需要執行和回呼;需要其他異步執行緒支持,
微任務特征:沒有明確的異步任務需要執行,只有回呼;不需要其他異步執行緒支持,
定時器誤差
事件回圈中,總是先執行同步代碼后,才會去任務佇列中取出異步回呼來執行,當執行 setTimeout 時,瀏覽器啟動新的執行緒去計時,計時結束后觸發定時器事件將回呼存入宏任務佇列,等待 JS 主執行緒來取出執行,如果這時主執行緒還在執行同步任務的程序中,那么此時的宏任務就只有先掛起,這就造成了計時器不準確的問題,同步代碼耗時越長,計時器的誤差就越大,不僅同步代碼,由于微任務會優先執行,所以微任務也會影響計時,假設同步代碼中有一個死回圈或者微任務中遞回不斷在啟動其他微任務,那么宏任務里面的代碼可能永遠得不到執行,所以主執行緒代碼的執行效率提升是一件很重要的事情,

一個很簡單的場景就是我們界面上有一個時鐘精確到秒,每秒更新一次時間,你會發現有時候秒數會直接跳過 2 秒間隔,就是這個原因,
視圖更新渲染
微任務佇列執行完成后,也就是一次事件回圈結束后,瀏覽器會執行視圖渲染,當然這里會有瀏覽器的優化,可能會合并多次回圈的結果做一次視圖重繪,因此視圖更新是在事件回圈之后,所以并不是每一次操作 Dom 都一定會立馬重繪視圖,視圖重繪之前會先執行 requestAnimationFrame 回呼,那么對于 requestAnimationFrame 是微任務還是宏任務是有爭議的,在這里看來,它應該既不屬于微任務,也不屬于宏任務,
四、NodeJS 中的事件回圈
JS 引擎本身不實作事件回圈機制,這是由它的宿主實作的,瀏覽器中的事件回圈主要是由瀏覽器來實作,而在 NodeJS 中也有自己的事件回圈實作,NodeJS 中也是回圈 + 任務佇列的流程以及微任務優先于宏任務,大致表現和瀏覽器是一致的,不過它與瀏覽器中也有一些差異,并且新增了一些任務型別和任務階段,接下來我們介紹下NodeJS 中的事件回圈流程,
五、NodeJS 中的異步方法
因為都是基于V8 引擎,瀏覽器中包含的異步方式在 NodeJS 中也是一樣的,另外 NodeJS 中還有一些其他常見異步形式,
- 檔案 I/O:異步加載本地檔案,
- setImmediate():與 setTimeout 設定 0ms 類似,在某些同步任務完成后立馬執行,
- process.nextTick():在某些同步任務完成后立馬執行,
- server.close、socket.on(‘close’,…)等:關倍訓呼,
想象一下,如果上面的形式和 setTimeout、promise 等同時存在,如何分析出代碼的執行順序呢?只要我們理解了 NodeJS 的事件回圈機制,也就清楚了,
六、事件回圈模型
NodeJS 的跨平臺能力和事件回圈機制都是基于 Libuv 庫實作的,你不用關心這個庫的具體內容,我們只需要知道 Libuv 庫是事件驅動的,并且封裝和統一了不同平臺的 API 實作,
NodeJS 中 V8 引擎將 JS 代碼決議后呼叫 Node API,然后 Node API 將任務交給 Libuv 去分配,最后再將執行結果回傳給 V8 引擎,在 Libux 中實作了一套事件回圈流程來管理這些任務的執行,所以 NodeJS 的事件回圈主要是在 Libuv 中完成的,

事件回圈各階段
在 NodeJS 中 JS 的執行,我們主要需要關心的程序分為以下幾個階段,下面每個階段都有自己單獨的任務佇列,當執行到對應階段時,就判斷當前階段的任務佇列是否有需要處理的任務,
-
timers 階段:執行所有 setTimeout() 和 setInterval() 的回呼,
-
pending callbacks 階段:某些系統操作的回呼,如 TCP 鏈接錯誤,除 timers、close、setImmediate 的其他大部分回呼在此階段執行,
-
poll 階段:輪詢等待新的鏈接和請求等事件,執行 I/O 回呼等,V8 引擎將 JS 代碼決議并傳入 Libuv 引擎后首先進入此階段,如果此階段任務佇列已經執行完了,則進入 check 階段執行 setImmediate 回呼(如果有 setImmediate),或等待新的任務進來(如果沒有 setImmediate),在等待新的任務時,如果有 timers計時到期,則會直接進入 timers 階段,此階段可能會阻塞等待,
-
check 階段:setImmediate 回呼函式執行,
-
close callbacks 階段:關倍訓呼執行,如 socket.on(‘close’, …),

上面每個階段都會去執行完當前階段的任務佇列,然后繼續執行當前階段的微任務佇列,只有當前階段所有微任務都執行完了,才會進入下個階段,這里也是與瀏覽器中邏輯差異較大的地方,不過瀏覽器不用區分這些階段,也少了很多異步操作型別,所以不用刻意去區分兩者區別,代碼如下所示:
const fs = require('fs');
fs.readFile(__filename, (data) => {
// poll(I/O 回呼) 階段
console.log('readFile')
Promise.resolve().then(() => {
console.error('promise1')
})
Promise.resolve().then(() => {
console.error('promise2')
})
});
setTimeout(() => {
// timers 階段
console.log('timeout');
Promise.resolve().then(() => {
console.error('promise3')
})
Promise.resolve().then(() => {
console.error('promise4')
})
}, 0);
// 下面代碼只是為了同步阻塞1秒鐘,確保上面的異步任務已經準備好了
var startTime = new Date().getTime();
var endTime = startTime;
while(endTime - startTime < 1000) {
endTime = new Date().getTime();
}
// 最終輸出 timeout promise3 promise4 readFile promise1 promise2
另一個與瀏覽器的差異還體現在同一個階段里的不同任務執行,在 timers 階段里面的宏任務、微任務測驗代碼如下所示:
setTimeout(() => {
console.log('timeout1')
Promise.resolve().then(function() {
console.log('promise1')
})
}, 0);
setTimeout(() => {
console.log('timeout2')
Promise.resolve().then(function() {
console.log('promise2')
})
}, 0);
瀏覽器中運行 每次宏任務完成后都會優先處理微任務,輸出“timeout1”、“promise1”、“timeout2”、“promise2”,
NodeJS 中運行 因為輸出 timeout1 時,當前正處于timers階段,所以會先將所有 timer 回呼執行完之后再執行微任務佇列,即輸出“timeout1”、“timeout2”、“promise1”、“promise2”,
上面的差異可以用瀏覽器和 NodeJS 10 對比驗證,是不是感覺有點反程式員?因此 NodeJS 在版本 11 之后,就修改了此處邏輯使其與瀏覽器盡量一致,也就是每個 timer 執行后都先去檢查一下微任務佇列,所以 NodeJS 11 之后的輸出已經和瀏覽器一致了,
nextTick、setImmediate 和 setTimeout
實際專案中我們常用 Promise 或者 setTimeout 來做一些需要延時的任務,比如一些耗時計算或者日志上傳等,目的是不希望它的執行占用主執行緒的時間或者需要依賴整個同步代碼執行完成后的結果,
NodeJS 中的 process.nextTick() 和 setImmediate() 也有類似效果,其中 setImmediate() 我們前面已經講了是在 check 階段執行的,而 process.nextTick() 的執行時機不太一樣,它比 promise.then() 的執行還早,在同步任務之后,其他所有異步任務之前,會優先執行 nextTick,可以想象是把 nextTick 的任務放到了當前回圈的后面,與 promise.then() 類似,但比 promise.then() 更前面,意思就是在當前同步代碼執行完成后,不管其他異步任務,先盡快執行 nextTick,如下面的代碼,因此這里的 nextTick 其實應該更符合“setImmediate”這個命名才對,
setTimeout(() => {
console.log('timeout');
}, 0);
Promise.resolve().then(() => {
console.error('promise')
})
process.nextTick(() => {
console.error('nextTick')
})
// 輸出:nextTick、promise、timeout
接下來我們再來看看 setImmediate 和 setTimeout,它們是屬于不同的執行階段了,分別是 timers 階段和 check 階段,
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('setImmediate');
});
// 輸出:timeout、 setImmediate
分析上面代碼,第一輪回圈后,分別將 setTimeout 和 setImmediate 加入了各自階段的任務佇列,第二輪回圈首先進入timers 階段,執行定時器佇列回呼,然后 pending callbacks和poll 階段沒有任務,因此進入check 階段執行 setImmediate 回呼,所以最后輸出為“timeout”、“setImmediate”,當然這里還有種理論上的極端情況,就是第一輪回圈結束后耗時很短,導致 setTimeout 的計時還沒結束,此時第二輪回圈則會先執行 setImmediate 回呼,
再看這下面一段代碼,它只是把上一段代碼放在了一個 I/O 任務回呼中,它的輸出將與上一段代碼相反,
const fs = require('fs');
fs.readFile(__filename, (data) => {
console.log('readFile');
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('setImmediate');
});
});
// 輸出:readFile、setImmediate、timeout
如上面代碼所示:
第一輪回圈沒有需要執行的異步任務佇列;
第二輪回圈 timers 等階段都沒有任務,只有 poll 階段有 I/O 回呼任務,即輸出“readFile”;
參考前面事件階段的說明,接下來,poll 階段會檢測如果有 setImmediate 的任務佇列則進入 check 階段,否則再進行判斷,如果有定時器任務回呼,則回到 timers 階段,所以應該進入 check 階段執行 setImmediate,輸出“setImmediate”;
然后進入最后的 close callbacks 階段,本次回圈結束;
最后進行第三輪回圈,進入 timers 階段,輸出“timeout”,
所以最終輸出“setImmediate”在“timeout”之前,可見這兩者的執行順序與當前執行的階段有關系,
轉載請註明出處,本文鏈接:https://www.uj5u.com/qita/296972.html
標籤:其他
