深入理解JavaScript的事件循環(Event Loop)
一、什麽是事件循環
JS的代碼執行是基於一種事件循環的機制,之所以稱作事件循環,MDN給出的解釋為
因為它經常被用於類似如下的方式來實現
while (queue.waitForMessage()) { queue.processNextMessage(); }如果當前沒有任何消息
queue.waitForMessage
會等待同步消息到達
我們可以把它當成一種程序結構的模型,處理的方案。更詳細的描述可以查看 這篇文章
而JS的運行環境主要有兩個:瀏覽器、Node。
在兩個環境下的Event Loop實現是不一樣的,在瀏覽器中基於 規範 來實現,不同瀏覽器可能有小小區別。在Node中基於 libuv 這個庫來實現
JS是單線程執行的,而基於事件循環模型,形成了基本沒有阻塞(除了alert或同步XHR等操作)的狀態
二、Macrotask 與 Microtask
根據 規範,每個線程都有一個事件循環(Event Loop),在瀏覽器中除了主要的頁面執行線程 外,Web worker是在一個新的線程中運行的,所以可以將其獨立看待。
每個事件循環有至少一個任務隊列(Task Queue,也可以稱作Macrotask宏任務),各個任務隊列中放置著不同來源(或者不同分類)的任務,可以讓瀏覽器根據自己的實現來進行優先級排序
以及一個微任務隊列(Microtask Queue),主要用於處理一些狀態的改變,UI渲染工作之前的一些必要操作(可以防止多次無意義的UI渲染)
主線程的代碼執行時,會將執行程序置入執行棧(Stack)中,執行完畢後出棧,另外有個堆空間(Heap),主要用於存儲對象及一些非結構化的數據
一開始
宏任務與微任務隊列裏的任務隨著:任務進棧、出棧、任務出隊、進隊之間交替著進行
從macrotask隊列中取出一個任務處理,處理完成之後(此時執行棧應該是空的),從microtask隊列中一個個按順序取出所有任務進行處理,處理完成之後進入UI渲染後續工作
需要註意的是:microtask並不是在macrotask完成之後才會觸發,在回調函數之後,只要執行棧是空的,就會執行microtask。也就是說,macrotask執行期間,執行棧可能是空的(比如在冒泡事件的處理時)
然後循環繼續
常見的macrotask有:
-
run <script>(同步的代碼執行)
- setTimeout
-
setInterval
-
setImmediate (Node環境中)
-
requestAnimationFrame
-
I/O
-
UI rendering
常見的microtask有:
-
process.nextTick (Node環境中)
-
Promise callback
-
Object.observe (基本上已經廢棄)
-
MutationObserver
macrotask種類很多,還有 dispatch event事件派發等
run <script>這個可能看起來比較奇怪,可以把它看成一段代碼(針對單個<script>標簽)的同步順序執行,主要用來描述執行程序的第一步執行
dispatch event主要用來描述事件觸發之後的執行任務,比如用戶點擊一個按鈕,觸發的onClick回調函數。需要註意的是,事件的觸發是同步的,這在下文有例子說明
註:
當然,也可認為 run <script>不屬於macrotask,畢竟規範也沒有這樣的說明,也可以將其視為主線程上的同步任務,不在主線程上的其他部分為異步任務
三、在瀏覽器中的實現
先來看看這段蠻復雜的代碼,思考一下會輸出什麽
console.log(‘start‘); var intervalA = setInterval(() => { console.log(‘intervalA‘); }, 0); setTimeout(() => { console.log(‘timeout‘); clearInterval(intervalA); }, 0); var intervalB = setInterval(() => { console.log(‘intervalB‘); }, 0); var intervalC = setInterval(() => { console.log(‘intervalC‘); }, 0); new Promise((resolve, reject) => { console.log(‘promise‘); for (var i = 0; i < 10000; ++i) { i === 9999 && resolve(); } console.log(‘promise after for-loop‘); }).then(() => { console.log(‘promise1‘); }).then(() => { console.log(‘promise2‘); clearInterval(intervalB); }); new Promise((resolve, reject) => { setTimeout(() => { console.log(‘promise in timeout‘); resolve(); }); console.log(‘promise after timeout‘); }).then(() => { console.log(‘promise4‘); }).then(() => { console.log(‘promise5‘); clearInterval(intervalC); }); Promise.resolve().then(() => { console.log(‘promise3‘); }); console.log(‘end‘);
上述代碼結合了常規執行代碼,setTimeout,setInterval,Promise
答案為
在解釋為什麽之前,先看一個更簡單的例子
console.log(‘start‘); setTimeout(() => { console.log(‘timeout‘); }, 0); Promise.resolve().then(() => { console.log(‘promise‘); }); console.log(‘end‘);
大概的步驟,文字有點多
1. 運行時(runtime)識別到log方法為一般的函數方法,將其入棧,然後執行輸出 start 再出棧
2. 識別到setTimeout為特殊的異步方法(macrotask),將其交由其他內核模塊處理,setTimeout的匿名回調函數被放入macrotask隊列中,並設置了一個 0ms的立即執行標識(提供後續模塊的檢查)
3. 識別到Promise的resolve方法為一般的方法,將其入棧,然後執行 再出棧
4. 識別到then為Promise的異步方法(microtask),將其交由其他內核模塊處理,匿名回調函數被放入microtask隊列中
5. 識別到log方法為一般的函數方法,將其入棧,然後執行輸出 end 再出棧
6. 主線程執行完畢,棧為空,隨即從microtask隊列中取出隊首的項,
這裏隊首為匿名函數,匿名函數裏面有 console的log方法,也將其入棧(如果執行過程中識別到特殊的方法,就在這時交給其他模塊處理到對應隊列尾部),
輸出 promise後出棧,並將這一項從隊列中移除
7. 繼續檢查microtask隊列,當前隊列為空,則將當前macrotask出隊,進入下一步(如果不為空,就繼續取下一個microtask執行)
8.檢查是否需要進行UI重新渲染等,進行渲染...
9. 進入下一輪事件循環,檢查macrotask隊列,取出一項進行處理
所以最終的結果是
再看上面那個例子,對比起來只是代碼多了點,混入了setInterval,多個setTimeout與promise的函數部分,按照上面的思路,應該不難理解
需要註意的三點:
1. clearInterval(intervalA); 運行的時候,實際上已經執行了 intervalA 的macrotask了
2. promise函數內部是同步處理的,不會放到隊列中,放入隊列中的是它的then或catch回調
3. promise的then返回的還是promise,所以在輸出promise4後,繼續檢測到後續的then方法,馬上放到microtask隊列尾部,再繼續取出執行,馬上輸出promise5;
而輸出promise1之後,為什麽沒有馬上輸出promise2呢?因為此時promise1所在任務之後是promise3的任務,1和3在promise函數內部返回後就添加至隊列中,2在1執行之後才添加
再來看個例子,就有點微妙了
<script> console.log(‘start‘); setTimeout(() => { console.log(‘timeout1‘); }, 0); Promise.resolve().then(() => { console.log(‘promise1‘); }); </script> <script> setTimeout(() => { console.log(‘timeout2‘); }, 0); requestAnimationFrame(() => { console.log(‘requestAnimationFrame‘); }); Promise.resolve().then(() => { console.log(‘promise2‘); }); console.log(‘end‘); </script>
輸出結果
requestAnimationFrame是在setTimeout之前執行的,start之後並不是直接輸出end,也許這兩個<script>標簽被獨立處理了
來看一個關於DOM操作的例子,Tasks, microtasks, queues and schedules
<style type="text/css"> .outer { width: 100px; background: #eee; height: 100px; margin-left: 300px; margin-top: 150px; display: flex; align-items: center; justify-content: center; } .inner { width: 50px; height: 50px; background: #ddd; } </style> <script> var outer = document.querySelector(‘.outer‘), inner = document.querySelector(‘.inner‘), clickTimes = 0; new MutationObserver(() => { console.log(‘mutate‘); }).observe(outer, { attributes: true }); function onClick() { console.log(‘click‘); setTimeout(() => { console.log(‘timeout‘); }, 0); Promise.resolve().then(() => { console.log(‘promise‘); }); outer.setAttribute(‘data-click‘, clickTimes++); } inner.addEventListener(‘click‘, onClick); outer.addEventListener(‘click‘, onClick); // inner.click(); // console.log(‘done‘); </script>
點擊內部的inner塊,會輸出什麽呢?
MutationObserver優先級比promise高,雖然在一開始就被定義,但實際上是觸發之後才會被添加到microtask隊列中,所以先輸出了promise
兩個timeout回調都在最後才觸發,因為click事件冒泡了,事件派發這個macrotask任務包括了前後兩個onClick回調,兩個回調函數都執行完之後,才會執行接下來的 setTimeout任務
期間第一個onClick回調完成後執行棧為空,就馬上接著執行microtask隊列中的任務
如果把代碼的註釋去掉,使用代碼自動 click(),思考一下,會輸出什麽?
可以看到,事件處理是同步的,done在連續輸出兩個click之後才輸出
而mutate只有一個,是因為當前執行第二個onClick回調的時候,microtask隊列中已經有一個MutationObserver,它是第一個回調的,因為事件同步的原因沒有被及時執行。瀏覽器會對MutationObserver進行優化,不會重復添加監聽回調
四、在Node中的實現
在Node環境中,macrotask部分主要多了setImmediate,microtask部分主要多了process.nextTick,而這個nextTick是獨立出來自成隊列的,優先級高於其他microtask
不過事件循環的的實現就不太一樣了,可以參考 Node事件文檔 libuv事件文檔
Node中的事件循環有6個階段
- timers:執行
setTimeout()
和setInterval()
中到期的callback - I/O callbacks:上一輪循環中有少數的I/Ocallback會被延遲到這一輪的這一階段執行
- idle, prepare:僅內部使用
- poll:最為重要的階段,執行I/O callback,在適當的條件下會阻塞在這個階段
- check:執行setImmediate的callback
- close callbacks:執行close事件的callback,例如
socket.on("close",func)
每一輪事件循環都會經過六個階段,在每個階段後,都會執行microtask
比較特殊的是在poll階段,執行程序同步執行poll隊列裏的回調,直到隊列為空或執行的回調達到系統上限
接下來再檢查有無預設的setImmediate,如果有就轉入check階段,沒有就先查詢最近的timer的距離,以其作為poll階段的阻塞時間,如果timer隊列是空的,它就一直阻塞下去
而nextTick並不在這些階段中執行,它在每個階段之後都會執行
看一個例子
setTimeout(() => console.log(1)); setImmediate(() => console.log(2)); process.nextTick(() => console.log(3)); Promise.resolve().then(() => console.log(4)); console.log(5);
根據以上知識,應該很快就能知道輸出結果是 5 3 4 1 2
修改一下
process.nextTick(() => console.log(1)); Promise.resolve().then(() => console.log(2)); process.nextTick(() => console.log(3)); Promise.resolve().then(() => { process.nextTick(() => console.log(0)); console.log(4); });
輸出為 1 3 2 4 0,因為nextTick隊列優先級高於同一輪事件循環中其他microtask隊列
修改一下
process.nextTick(() => console.log(1)); console.log(0); setTimeout(()=> { console.log(‘timer1‘); Promise.resolve().then(() => { console.log(‘promise1‘); }); }, 0); process.nextTick(() => console.log(2)); setTimeout(()=> { console.log(‘timer2‘); process.nextTick(() => console.log(3)); Promise.resolve().then(() => { console.log(‘promise2‘); }); }, 0);
輸出為
與在瀏覽器中不同,這裏promise1並不是在timer1之後輸出,因為在setTimeout執行的時候是出於timer階段,會先一並處理timer回調
setTimeout是優先於setImmediate的,但接下來這個例子卻不一定是先執行setTimeout的回調
setTimeout(() => { console.log(‘timeout‘); }, 0); setImmediate(() => { console.log(‘immediate‘); });
因為在Node中識別不了0ms的setTimeout,至少也得1ms.
所以,如果在進入該輪事件循環的時候,耗時不到1ms,則setTimeout會被跳過,進入check階段執行setImmediate回調,先輸出 immediate
如果超過1ms,timer階段中就可以馬上處理這個setTimeout回調,先輸出 timeout
修改一下代碼,讀取一個文件讓事件循環進入IO文件讀取的poll階段
let fs = require(‘fs‘); fs.readFile(‘./event.html‘, () => { setTimeout(() => { console.log(‘timeout‘); }, 0); setImmediate(() => { console.log(‘immediate‘); }); });
這麽一來,輸出結果肯定就是 先 immediate 後 timeout
五、用好事件循環
知道JS的事件循環是怎麽樣的了,就需要知道怎麽才能把它用好
1. 在microtask中不要放置復雜的處理程序,防止阻塞UI的渲染
2. 可以使用process.nextTick處理一些比較緊急的事情
3. 可以在setTimeout回調中處理上輪事件循環中UI渲染的結果
4. 註意不要濫用setInterval和setTimeout,它們並不是可以保證能夠按時處理的,setInterval甚至還會出現丟幀的情況,可考慮使用 requestAnimationFrame
5. 一些可能會影響到UI的異步操作,可放在promise回調中處理,防止多一輪事件循環導致重復執行UI的渲染
6. 在Node中使用immediate來可能會得到更多的保證
7. 不要糾結
深入理解JavaScript的事件循環(Event Loop)