聊聊Javascript的事件迴圈

程式碼寫著寫著就懂了發表於2018-08-07

JavaScript、瀏覽器、事件之間的關係

JavaScript程式採用了非同步事件驅動程式設計(Event-driven programming)模型,維基百科對它的解釋是:

事件驅動程式設計(英語:Event-driven programming)是一種電腦程式設計模型。這種模型的程式執行流程是由使用者的動作(如滑鼠的按鍵,鍵盤的按鍵動作)或者是由其他程式的訊息來決定的。相對於批處理程式設計(batch programming)而言,程式執行的流程是由程式設計師來決定。批量的程式設計在初級程式設計教學課程上是一種方式。然而,事件驅動程式設計這種設計模型是在互動程式(Interactive program)的情況下孕育而生的

簡而言之,在web前端程式設計裡面JavaScript通過瀏覽器提供的事件模型API和使用者互動,接受使用者的輸入。

事件驅動程式模型基本的實現原理基本上都是使用 事件迴圈(Event Loop)。

而JS的執行環境主要有兩個:瀏覽器、Node。

在兩個環境下的Event Loop實現是不一樣的,在瀏覽器中基於 規範 來實現,不同瀏覽器可能有小小區別。在Node中基於 libuv 這個庫來實現

JS是單執行緒執行的,而基於事件迴圈模型,形成了基本沒有阻塞(除了alert或同步XHR等操作)的狀態。

瀏覽器中的事件迴圈 event loop

先看HTML標準的一系列解釋:

為了協調事件(event),使用者互動(user interaction),指令碼(script),渲染(rendering),網路(networking)等,使用者代理(user agent)必須使用事件迴圈(event loops)。 有兩類事件迴圈:一種針對瀏覽上下文(browsing context),還有一種針對worker(web worker)。

為了更好地理解Event Loop,請看下圖(轉引自Philip Roberts的演講《Help, I'm stuck in an event-loop》)

EventLoop

上圖中,主執行緒執行的時候,產生堆疊,棧中的程式碼呼叫各種外部API,非同步操作執行完成後,就在訊息佇列中排隊。只要棧中的程式碼執行完畢,主執行緒就會去讀取“任務佇列”,依次執行那些事件所對應的回撥函式。

詳細的步驟如下:
  1. 所有同步任務都在主執行緒上執行,形成一個執行棧
  2. 主執行緒之外,還存在一個“訊息佇列”。只要非同步操作執行完成,就到訊息佇列中排隊
  3. 一旦執行棧中的所有同步任務執行完畢,系統就會依次讀取訊息佇列的非同步任務,於是被讀取的非同步任務結束等待狀態,進入執行棧,開始執行
  4. 主執行緒不斷重複上面的的第三步

下面看一個有意思的例子,猜一下它的執行結果:

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

new Promise(
    function(resolve){
        console.log('2');
        resolve()
}).then(
    function(){
        console.log('3');
});

console.log('4');
複製程式碼

列印結果:

2
4
3
1
複製程式碼

這是為什麼?是不是跟上面說的相違背了?其實這裡面就有了兩個概念巨集任務(task/macrotask),微任務(microtask),下面我們來詳細介紹一下這兩個東東。

Macrotask 與 Microtask

根據 規範,每個執行緒都有一個事件迴圈(Event Loop),在瀏覽器中除了主要的頁面執行執行緒 外,Web worker是在一個新的執行緒中執行的,所以可以將其獨立看待。

每個事件迴圈有至少一個任務佇列(Task Queue,也可以稱作Macrotask巨集任務),各個任務佇列中放置著不同來源(或者不同分類)的任務,可以讓瀏覽器根據自己的實現來進行優先順序排序

以及一個微任務佇列(Microtask Queue),主要用於處理一些狀態的改變,UI渲染工作之前的一些必要操作(可以防止多次無意義的UI渲染)

主執行緒的程式碼執行時,會將執行程式置入執行棧(Stack)中,執行完畢後出棧,另外有個堆空間(Heap),主要用於儲存物件及一些非結構化的資料。

image

常見的macrotask有:

run <script>(同步的程式碼執行)
setTimeout

setInterval

setImmediate (Node環境中)

requestAnimationFrame

I/O

UI rendering
複製程式碼

常見的microtask有:

process.nextTick (Node環境中)

Promise callback

Object.observe (基本上已經廢棄)

MutationObserver
複製程式碼

事件迴圈執行順序

1. event loop 執行步驟:

1、執行巨集任務(先進先出),一次迴圈只執行一個巨集任務)
2、執行棧 —— 同步方法順序執行,非同步方法交給非同步處理模組
3、執行棧為空時取出微任務執行(先進先出),直到微任務佇列為空
4、更新UI渲染。完成一輪迴圈,反覆執行1-4。(不一定每次迴圈都會渲染)
複製程式碼

2.update the rendering 渲染更新:

在一輪event loop中多次修改同一dom,只有最後一次會進行繪製。
渲染更新(Update the rendering)會在event loop中的tasks和microtasks完成後進行,但並不是每輪event loop都會更新渲染,瀏覽器有自己的機制來確定是否要更新渲染。如果在一幀(16.7ms)裡多次修改了dom,瀏覽器可能只會渲染繪製一次。
如果希望在每輪event loop都即時呈現變動,可以使用requestAnimationFrame.
複製程式碼

那麼我們回到上面的那個例子就不難解釋了:

==注意==: Promise 自身的程式碼是同步執行的,只有 .then後的回撥函式才是微任務。

主執行緒的執行過程:

  1. 從巨集任務佇列(task)中取出 script,將所有同步程式碼推入執行棧中執行,遇到非同步程式碼交給非同步處理模組,非同步處理模組處理完成後將任務按規則推入事件佇列,巨集任務推巨集任務佇列(先進先出),微任務推微任務佇列(先進先出)。所以輸出 2 和 4。
  2. 執行完 script 中的同步程式碼,再將微任務佇列中最老的任務推入執行棧執行,直到清空微任務佇列。所以輸出 3。
  3. 瀏覽器更新渲染,再去巨集任務佇列中取出最老的任務推入執行棧中執行,迴圈以上步驟。所以輸出 1。

在Node中的實現

在Node環境中,macrotask部分主要多了setImmediate,microtask部分主要多了process.nextTick,而這個nextTick是獨立出來自成佇列的,優先順序高於其他microtask

不過事件迴圈的的實現就不太一樣了,可以參考 Node事件文件 libuv事件文件

Node中的事件迴圈有6個階段

  1. timers:執行setTimeout() 和 setInterval()中到期的callback
  2. I/O callbacks:上一輪迴圈中有少數的I/Ocallback會被延遲到這一輪的這一階段執行
  3. idle, prepare:僅內部使用
  4. poll:最為重要的階段,執行I/Ocallback,在適當的條件下會阻塞在這個階段
  5. check:執行setImmediate的callback
  6. close callbacks:執行close事件的callback,例如socket.on("close",func)

image

每一輪事件迴圈都會經過六個階段,在每個階段後,都會執行microtask

image

比較特殊的是在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);
複製程式碼

輸出結果為:

0
1
2
timer1
timer2
3
promise1
promise2
複製程式碼

與在瀏覽器中不同,這裡promise1並不是在timer1之後輸出,因為在setTimeout執行的時候是出於timer階段,會先一併處理timer回撥.

善用事件迴圈

知道JS的事件迴圈是怎麼樣的了,就需要知道怎麼才能把它用好:

  1. 在microtask中不要放置複雜的處理程式,防止阻塞UI的渲染

  2. 可以使用process.nextTick處理一些比較緊急的事情

  3. 可以在setTimeout回撥中處理上輪事件迴圈中UI渲染的結果

  4. 注意不要濫用setInterval和setTimeout,它們並不是可以保證能夠按時處理的,setInterval甚至還會出現丟幀的情況,可考慮使用 requestAnimationFrame

  5. 一些可能會影響到UI的非同步操作,可放在promise回撥中處理,防止多一輪事件迴圈導致重複執行UI的渲染

  6. 在Node中使用immediate來可能會得到更多的保證

如有錯誤歡迎指正,相互進步。

參考連結:

JavaScript 執行機制詳解:再談Event Loop

深入理解 JavaScript 事件迴圈(一)— event loop

深入淺出Javascript事件迴圈機制(上)

相關文章