javascript事件迴圈(瀏覽器/node)

iamswf發表於2018-12-09

為什麼要了解js中的事件迴圈

javascript是一種基於事件的單執行緒、非同步、非阻塞程式語言,我經常在看書或者瀏覽別人部落格的時候看到這種說法,可是之前一直沒有深入理解過,只知道javascript中經常使用各種回撥函式,比如瀏覽器端的各種事件回撥(點選事件、滾動事件等)、ajax請求回撥、setTimeout回撥以及react16的核心fiber中用到的requestAnimationFramepromise回撥、nodefs模組非同步讀取檔案內容回撥、process模組的nextTick等等。最近有時間瀏覽了各種資料(後面有各種相關資料的連結),終於明白所有的這些內容其實都離不開javascript事件迴圈,而瀏覽器端的事件迴圈又與node端的事件迴圈有較大區別,下面分別介紹下。

瀏覽器 vs node

自從有了nodejavascript既可以執行在瀏覽器端又可以執行在服務端,以chrome瀏覽器為例,相同點是都基於v8引擎,不同的是瀏覽器端實現了頁面渲染、而node端則提供了一些服務端會用到的特性,比如fsprocess等模組,同時node端為了實現跨平臺,底層使用libuv來相容linuxwindowsmacOS三大作業系統。因此雖然都實現了javascript的非同步、非阻塞特性,但是卻有有不少不同之處。

瀏覽器端

無論是在瀏覽器端還是node端,執行緒入口都是一個javascript指令碼檔案,整個指令碼檔案從第一行開始到最後執行完成可以看作是一個entry task,即初始化任務,下圖task中第一項即為該過程。初始化過程中肯定會註冊不少非同步事件,比如常見的setTimeoutonClickpromise等,這些非同步事件執行中又有可能註冊更多非同步事件。所有的這些非同步任務都是在事件迴圈一次次的迴圈中得到執行,而這些非同步任務又可以分為兩大類,即microtasktask(或macrotask)。那麼一次事件迴圈中會執行多少個非同步任務?microtasktask的執行先後順序是什麼呢?看下圖。

瀏覽器端事件迴圈

先忽略圖中的紅色部分(渲染過程,後面再介紹),順時針方向即為事件迴圈方向,可以看出每次迴圈會先後執行兩類任務,taskmicrotask每一類任務都由一個佇列組成,其中task主要包括如下幾類任務:

  1. index.js(entry)
  2. setTimeout
  3. setInterval
  4. 網路I/O

microtask主要包括:

  1. promise
  2. 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元素的stylelayout以及position這些渲染,那為什麼用虛線表示呢?是因為該部分的排程是由瀏覽器控制,而且是以60HZ的頻率排程,之所以是60HZ是為了能滿足人眼視覺效果的同時儘量低頻的排程,如果瀏覽器一刻不停的頻繁渲染,那麼不僅人眼觀察不到介面的變化效果(就如同夏天電扇轉太快人眼分辨不出來),而且耗費計算資源。因此上圖中渲染過程用虛線表示不一定每次事件迴圈都會執行渲染過程。

仔細看虛線框起來的渲染過程,可以看到在執行渲染之前可以執行一個回撥函式requestAnimationFrame,執行渲染之後可以執行一個回撥函式requestIdleCallback。使用這兩個鉤子函式註冊的回撥函式同task回撥和microtask回撥一樣,會進入專屬的事件佇列,但是這兩個鉤子函式與setTimeout不一樣,不是為了在4ms,16ms或1s之後再執行,而是在下一次頁面渲染階段去執行,具體來說是requestAnimationFramestylelayout計算之前執行,requestIdleCallback則是在變更真正渲染到頁面後執行。

requestAnimationFramesetTimeout更適合做動畫,這裡有個例子可以參考:jsfiddle.net/H7EEE/245/。效果如下圖所示,可以看出requestAnimationFramesetTimeout動畫效果更加流暢。

javascript事件迴圈(瀏覽器/node)
requestIdleCallback則是在每一渲染貞後的空閒時間去完成回撥任務,因此一般用於一些低優先順序的任務排程,比如react16則使用了該鉤子函式實現非同步reconcilation演算法以保證頁面效能,當然由於requestIdleCallback是比較新的APIreact團隊實現了pollyfill,注意是目前是使用requestAnimationFrame實現的哦。
react16使用requestIdleCallback實現精細的排程演算法

現在總結一下瀏覽器端的事件佇列,共包括四個事件佇列:task佇列、requestAnimationFrame佇列、requestIdleCallback佇列以及microtask佇列,javascript指令碼載入完成後首先執行第一個task佇列任務,即初始化任務,然後執行所有microtask佇列任務,接著再次執行第二個task佇列任務,以此類推,這其中穿插著60HZ渲染過程。先執行誰後執行誰現在瞭解清楚了,可是到每個事件佇列執行的輪次時,分別會有多少個事件出隊執行呢?答案見下圖(截圖自Jake Archibald大神的JSConf演講視訊):

javascript事件迴圈(瀏覽器/node)
可以看出,在一次事件迴圈中:普通task每次出隊一項回撥函式去執行,requestAnimationFrame每次出隊所有當前佇列的回撥函式去執行(requestIdleCallback一樣),microtask每次出隊所有當前佇列的回撥函式以及自己輪次執行過程中又新增到隊尾的回撥函式。這三種不同的排程方式正好覆蓋了所有場景。

實踐一下

demo1: 對比index.jspromisesetTimeout的執行先後順序
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
複製程式碼

按照前面的事件迴圈示例圖,按照如下順序執行:

  1. 執行task(index.js); 這裡包括四項輸出:script startpromise1.1promise2.1script end。其中需要留意promise1.1promise2.1,因為new Promiseresolve()呼叫之前也是同步程式碼,因此也會同步執行。
  2. 執行microtask; 這裡需要留意microtask會邊執行邊生成新的新增到事件佇列隊尾,因此執行完所有microtask才重新進入事件迴圈開始下一項。
  3. 執行task(setTimeout); 根據前面的示例圖,這裡又輪到了task的執行,只不過這次是setTimout

node端

前面介紹了下瀏覽器端的事件迴圈,涉及到taskmicrotask,其實node端的非同步任務也包括這些,只不過node端的task劃分的更細,如下圖所示,node端的task可以分為4類任務佇列:

  1. index.js(entry)、setTimeoutsetInterval
  2. 網路I/O、fs(disk)child_process
  3. setImmediate
  4. close事件

microtask包括:

  1. process.nextTick
  2. promise
    node端事件迴圈
    開始後會首先執行註冊過的所有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.nextTickpromise兩類。

實踐一下

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
複製程式碼

執行順序如下:

  1. index.js(主程式程式碼main);
  2. microtask(promise1, promise2);
  3. task(setTimeout1, setTimeout2);
  4. microtask(promise3, promise4);

這個執行順序與之前畫的圖完全對應。

demo2: 對比index.jspromiseasync awaitsetTimeout的執行先後順序
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環境,是因為node8node9下面async awaitbug,而node10中得到了修復,詳情可以參考這篇文章:Faster async functions and promises。下面按照前面的事件迴圈示例圖分析下前面這段程式碼的執行結果:

  1. 執行task(index.js); 這裡包括5項輸出:script startasync1 startentry async2promise1script end。這裡要注意async函式中第一個await之前執行的程式碼也是同步程式碼,因此會列印出scync1 start以及entry async2
  2. 執行microtask; 這裡列印了所有剩下的promise以及一個位於await後的語句async1 end列印這個集合肯定是沒問題的,但是問題是為什麼async1 end會比promise延遲3個呢? 這個問題是這段程式碼最難懂的地方,答案在剛剛提到的那篇文章中:每個await需要至少3個microtask queue ticks,因此這裡async1 end的列印相對於promise晚列印了3個tick。其實通過這裡例子我們也應該的出一個結論,就是最要不要把promiseasync await混用,否則容易時序混亂。
  3. 執行task(setTimeout)。 根據前面的示例圖,這裡又輪到了task的執行,只不過這次是setTimout。 從demo2可以看出,雖然async await本質上也是microtask,但是每個await會耗費至少3個microtask queue ticks,這點需要注意。

引用

本篇總結主要參考瞭如下資源,強烈推薦瀏覽閱讀:

  1. Jake Archibald: In The Loop - JSConf.Asia 2018
  2. Philip Roberts: What the heck is the event loop anyway? - JSConf.EU 2014
  3. Everything You Need to Know About Node.js Event Loop - Bert Belder, IBM
  4. Event Loop and the Big Picture — NodeJS Event Loop Part 1
  5. Timers, Immediates and Process.nextTick— NodeJS Event Loop Part 2
  6. Promises, Next-Ticks and Immediates— NodeJS Event Loop Part 3
  7. Handling IO — NodeJS Event Loop Part 4
  8. Event Loop Best Practices — NodeJS Event Loop Part 5
  9. Using requestIdleCallback

相關文章