為什麼要了解js
中的事件迴圈
javascript
是一種基於事件的單執行緒、非同步、非阻塞程式語言,我經常在看書或者瀏覽別人部落格的時候看到這種說法,可是之前一直沒有深入理解過,只知道javascript
中經常使用各種回撥函式,比如瀏覽器
端的各種事件回撥(點選事件、滾動事件等)、ajax
請求回撥、setTimeout
回撥以及react16
的核心fiber
中用到的requestAnimationFrame
、promise
回撥、node
端fs
模組非同步讀取檔案內容回撥、process
模組的nextTick
等等。最近有時間瀏覽了各種資料(後面有各種相關資料的連結),終於明白所有的這些內容其實都離不開javascript事件迴圈
,而瀏覽器
端的事件迴圈又與node
端的事件迴圈有較大區別,下面分別介紹下。
瀏覽器 vs node
自從有了node
,javascript
既可以執行在瀏覽器端又可以執行在服務端,以chrome
瀏覽器為例,相同點是都基於v8
引擎,不同的是瀏覽器端實現了頁面渲染、而node端則提供了一些服務端會用到的特性,比如fs
、process
等模組,同時node端為了實現跨平臺,底層使用libuv
來相容linux
、windows
、 macOS
三大作業系統。因此雖然都實現了javascript
的非同步、非阻塞特性,但是卻有有不少不同之處。
瀏覽器端
無論是在瀏覽器
端還是node
端,執行緒入口都是一個javascript
指令碼檔案,整個指令碼檔案從第一行開始到最後執行完成可以看作是一個entry task
,即初始化任務,下圖task
中第一項即為該過程。初始化過程中肯定會註冊不少非同步事件
,比如常見的setTimeout
、onClick
、promise
等,這些非同步事件執行中又有可能註冊更多非同步事件。所有的這些非同步任務都是在事件迴圈
一次次的迴圈中得到執行,而這些非同步任務又可以分為兩大類,即microtask
和task(或macrotask)
。那麼一次事件迴圈中會執行多少個非同步任務?microtask
和task
的執行先後順序是什麼呢?看下圖。
先忽略圖中的紅色部分(渲染過程,後面再介紹),順時針方向即為事件迴圈方向,可以看出每次迴圈會先後執行兩類任務,task
和microtask
,每一類任務都由一個佇列組成,其中task
主要包括如下幾類任務:
- index.js(entry)
setTimeout
setInterval
- 網路I/O
而microtask
主要包括:
promise
MutationObserver
因此microtask
的執行事件結點是在兩次task
執行間隙。前面說了,每類任務都由一個佇列組成,這其實是一種生產者-消費者
模型,事件的註冊過程即為任務生產
過程,任務的執行過程即為事件的消費
過程。那麼每次輪到一類任務執行各個佇列會出隊多少個任務來執行呢?圖中我已經標明,task
佇列每次出隊一項任務來執行,執行完成之後開始執行microtask
;而microtask
則每次都把所有(包括當前microtask
執行過程中新新增的任務)任務執行完成,然後才會繼續執行task
。也就是說,即便microtask
是非同步任務,也不能無節制的註冊,否則會阻塞task
和頁面渲染
的執行。比如,下面的這段程式碼中的setTimeout
回撥任務將永遠得不到執行(注意,謹慎執行這段程式碼,瀏覽器可能卡死):
setTimeout(() => {
console.log('run setTimout callback');
}, 0);
function promiseLoop() {
console.log('run promise callback');
return Promise.resolve().then(() => {
return promiseLoop();
});
}
promiseLoop();
複製程式碼
現在回過頭來再看上圖中的粉紅色虛線部分,該過程表示的是瀏覽器渲染過程,比如dom
元素的style
、layout
以及position
這些渲染,那為什麼用虛線表示呢?是因為該部分的排程是由瀏覽器控制,而且是以60HZ
的頻率排程,之所以是60HZ
是為了能滿足人眼視覺效果的同時儘量低頻的排程,如果瀏覽器一刻不停的頻繁渲染,那麼不僅人眼觀察不到介面的變化效果(就如同夏天電扇轉太快人眼分辨不出來),而且耗費計算資源。因此上圖中渲染過程用虛線表示不一定每次事件迴圈都會執行渲染過程。
仔細看虛線框起來的渲染過程,可以看到在執行渲染之前可以執行一個回撥函式requestAnimationFrame
,執行渲染之後可以執行一個回撥函式requestIdleCallback
。使用這兩個鉤子函式註冊的回撥函式同task
回撥和microtask
回撥一樣,會進入專屬的事件佇列,但是這兩個鉤子函式與setTimeout
不一樣,不是為了在4ms,16ms或1s
之後再執行,而是在下一次頁面渲染
階段去執行,具體來說是requestAnimationFrame
在style
和layout
計算之前執行,requestIdleCallback
則是在變更真正渲染到頁面後執行。
requestAnimationFrame
比setTimeout
更適合做動畫,這裡有個例子可以參考:jsfiddle.net/H7EEE/245/。效果如下圖所示,可以看出requestAnimationFrame
比setTimeout
動畫效果更加流暢。
requestIdleCallback
則是在每一渲染貞後的空閒時間去完成回撥任務,因此一般用於一些低優先順序的任務排程
,比如react16
則使用了該鉤子函式實現非同步reconcilation
演算法以保證頁面效能,當然由於requestIdleCallback
是比較新的API
,react
團隊實現了pollyfill
,注意是目前是使用requestAnimationFrame
實現的哦。
現在總結一下瀏覽器端的事件佇列,共包括四個事件佇列:task
佇列、requestAnimationFrame
佇列、requestIdleCallback
佇列以及microtask
佇列,javascript
指令碼載入完成後首先執行第一個task
佇列任務,即初始化任務
,然後執行所有microtask
佇列任務,接著再次執行第二個task
佇列任務,以此類推,這其中穿插著60HZ
的渲染過程
。先執行誰後執行誰現在瞭解清楚了,可是到每個事件佇列執行的輪次時,分別會有多少個事件出隊執行呢?答案見下圖(截圖自Jake Archibald
大神的JSConf
演講視訊):
task
每次出隊一項回撥函式去執行,requestAnimationFrame
每次出隊所有當前佇列的回撥函式去執行(requestIdleCallback
一樣),microtask
每次出隊所有當前佇列的回撥函式以及自己輪次執行過程中又新增到隊尾的回撥函式。這三種不同的排程方式正好覆蓋了所有場景。
實踐一下
demo1: 對比index.js
、promise
、setTimeout
的執行先後順序
console.log('script start');
setTimeout(function () {
console.log('setTimeout');
}, 0);
new Promise(function (resolve) {
console.log('promise1.1');
resolve();
}).then(function () {
console.log('promise1.2');
}).then(function () {
console.log('promise1.3');
}).then(function () {
console.log('promise1.4');
});
new Promise(function (resolve) {
console.log('promise2.1');
resolve();
}).then(function () {
console.log('promise2.2');
}).then(function () {
console.log('promise2.3');
}).then(function () {
console.log('promise2.4');
});
console.log('script end');
複製程式碼
這段程式碼的輸入如下:
script start
promise1.1
promise2.1
script end
promise1.2
promise2.2
promise1.3
promise2.3
promise1.4
promise2.4
setTimeout
複製程式碼
按照前面的事件迴圈示例圖,按照如下順序執行:
- 執行
task
(index.js); 這裡包括四項輸出:script start
、promise1.1
、promise2.1
、script end
。其中需要留意promise1.1
和promise2.1
,因為new Promise
中resolve()
呼叫之前也是同步程式碼,因此也會同步執行。 - 執行
microtask
; 這裡需要留意microtask
會邊執行邊生成新的新增到事件佇列
隊尾,因此執行完所有microtask
才重新進入事件迴圈開始下一項。 - 執行
task
(setTimeout); 根據前面的示例圖,這裡又輪到了task
的執行,只不過這次是setTimout
。
node端
前面介紹了下瀏覽器端的事件迴圈,涉及到task
和microtask
,其實node端的非同步任務也包括這些,只不過node
端的task
劃分的更細,如下圖所示,node
端的task
可以分為4類任務佇列:
- index.js(entry)、
setTimeout
、setInterval
- 網路I/O、
fs(disk)
、child_process
setImmediate
- close事件
而microtask
包括:
process.nextTick
promise
開始後會首先執行註冊過的所有microtask
,然後會依次執行該4類task
佇列。而每執行完一個task
佇列就會接著執行microtask
佇列,然後再接著執行下一個task
佇列。因此microtask
佇列的執行是穿插在各個類形的task
之間的,當然也可以。node
端與瀏覽器
端事件迴圈的一個很重要的不同點是,瀏覽器
端task
佇列每輪事件迴圈僅出隊一個回撥函式去執行接著去執行microtask
,而node
端只要輪到執行task
,則會跟執行完佇列中的所有當前任務,但是當前輪次新新增到隊尾的任務則會等到下一輪次才會執行,該機制與瀏覽器端的requestAnimationFrame
的排程機制時一樣的。 總結一下node
端的事件迴圈,共包括4類task
事件佇列與2類microtask
事件佇列,microtask
穿插在task
之間執行。task
每次輪到執行會將當前佇列中的所有回撥函式出隊執行,而microtask
的排程機制則與瀏覽器
端一樣,每次輪到執行都會出隊所有當前佇列的回撥函式以及自己輪次執行過程中又新增到隊尾的回撥函式去執行。與瀏覽器
端不一樣的是node
端的microtask
包括process.nextTick
和promise
兩類。
實踐一下
demo1: 對比promise與setTimeout的執行順序
console.log('main');
setTimeout(function () {
console.log('execute in first timeout');
Promise.resolve(3).then(res => {
console.log('execute in third promise');
});
}, 0);
setTimeout(function () {
console.log('execute in second timeout');
Promise.resolve(4).then(res => {
console.log('execute in fourth promise');
});
}, 0);
Promise.resolve(1).then(res => {
console.log('execute in first promise');
});
Promise.resolve(2).then(res => {
console.log('execute in second promise');
});
複製程式碼
前面這段程式碼的輸出結果如下:
main
execute in first promise
execute in second promise
execute in first timeout
execute in second timeout
execute in third promise
execute in fourth promise
複製程式碼
執行順序如下:
- index.js(主程式程式碼main);
- microtask(promise1, promise2);
- task(setTimeout1, setTimeout2);
- microtask(promise3, promise4);
這個執行順序與之前畫的圖完全對應。
demo2: 對比index.js
、promise
、async await
、setTimeout
的執行先後順序
console.log('script start');
async function async1() {
console.log('async1 start');
await async2();
console.log('async1 end');
}
async function async2() {
console.log('entry async2');
return Promise.resolve();
}
setTimeout(function () {
console.log('setTimeout');
}, 0);
async1();
new Promise(function (resolve) {
console.log('promise1');
resolve();
}).then(function () {
console.log('promise2');
}).then(function () {
console.log('promise3');
}).then(function () {
console.log('promise4');
}).then(function () {
console.log('promise5');
}).then(function () {
console.log('promise6');
});
console.log('script end');
複製程式碼
這段程式碼在node10
環境的執行結果如下:
script start
async1 start
entry async2
promise1
script end
promise2
promise3
promise4
async1 end
promise5
promise6
setTimeout
複製程式碼
注意我這裡強調了是node10
環境,是因為node8
和node9
下面async await
有bug
,而node10
中得到了修復,詳情可以參考這篇文章:Faster async functions and promises。下面按照前面的事件迴圈
示例圖分析下前面這段程式碼的執行結果:
- 執行
task
(index.js); 這裡包括5項輸出:script start
、async1 start
、entry async2
、promise1
、script end
。這裡要注意async
函式中第一個await
之前執行的程式碼也是同步程式碼,因此會列印出scync1 start
以及entry async2
。 - 執行
microtask
; 這裡列印了所有剩下的promise
以及一個位於await
後的語句async1 end
。列印這個集合肯定是沒問題的,但是問題是為什麼async1 end
會比promise
延遲3個呢? 這個問題是這段程式碼最難懂的地方,答案在剛剛提到的那篇文章中:每個await
需要至少3個microtask queue ticks
,因此這裡async1 end
的列印相對於promise
晚列印了3個tick
。其實通過這裡例子我們也應該的出一個結論,就是最要不要把promise
和async await
混用,否則容易時序混亂。 - 執行
task
(setTimeout)。 根據前面的示例圖,這裡又輪到了task的執行,只不過這次是setTimout。 從demo2
可以看出,雖然async await
本質上也是microtask
,但是每個await
會耗費至少3個microtask queue ticks
,這點需要注意。
引用
本篇總結主要參考瞭如下資源,強烈推薦瀏覽閱讀:
- Jake Archibald: In The Loop - JSConf.Asia 2018
- Philip Roberts: What the heck is the event loop anyway? - JSConf.EU 2014
- Everything You Need to Know About Node.js Event Loop - Bert Belder, IBM
- Event Loop and the Big Picture — NodeJS Event Loop Part 1
- Timers, Immediates and Process.nextTick— NodeJS Event Loop Part 2
- Promises, Next-Ticks and Immediates— NodeJS Event Loop Part 3
- Handling IO — NodeJS Event Loop Part 4
- Event Loop Best Practices — NodeJS Event Loop Part 5
- Using requestIdleCallback