聊聊Javascript的事件循環

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

JavaScript程序採用了異步事件驅動編程(Event-driven programming)模型,維基百科對它的解釋是:javascript

事件驅動程序設計(英語:Event-driven programming)是一種電腦程序設計模型。這種模型的程序運行流程是由用戶的動做(如鼠標的按鍵,鍵盤的按鍵動做)或者是由其餘程序的消息來決定的。相對於批處理程序設計(batch programming)而言,程序運行的流程是由程序員來決定。批量的程序設計在初級程序設計教學課程上是一種方式。然而,事件驅動程序設計這種設計模型是在交互程序(Interactive program)的狀況下孕育而生的html

簡而言之,在web前端編程裏面JavaScript經過瀏覽器提供的事件模型API和用戶交互,接受用戶的輸入。前端

事件驅動程序模型基本的實現原理基本上都是使用 事件循環(Event Loop)。html5

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

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

JS是單線程執行的,而基於事件循環模型,造成了基本沒有阻塞(除了alert或同步XHR等操做)的狀態。程序員

瀏覽器中的事件循環 event loop

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

爲了協調事件(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》)vim

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 執行步驟:

一、執行宏任務(先進先出),一次循環只執行一個宏任務)
二、執行棧 —— 同步方法順序執行,異步方法交給異步處理模塊
三、執行棧爲空時取出微任務執行(先進先出),直到微任務隊列爲空
四、更新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事件循環機制(上)

相關文章
相關標籤/搜索