javascript事件循環(瀏覽器/node)

爲何要了解js中的事件循環

javascript是一種基於事件的單線程、異步、非阻塞編程語言,我常常在看書或者瀏覽別人博客的時候看到這種說法,但是以前一直沒有深刻理解過,只知道javascript中常用各類回調函數,好比瀏覽器端的各類事件回調(點擊事件、滾動事件等)、ajax請求回調、setTimeout回調以及react16的內核fiber中用到的requestAnimationFramepromise回調、nodefs模塊異步讀取文件內容回調、process模塊的nextTick等等。最近有時間瀏覽了各類資料(後面有各類相關資料的連接),終於明白全部的這些內容其實都離不開javascript事件循環,而瀏覽器端的事件循環又與node端的事件循環有較大區別,下面分別介紹下。javascript

瀏覽器 vs node

自從有了nodejavascript既能夠運行在瀏覽器端又能夠運行在服務端,以chrome瀏覽器爲例,相同點是都基於v8引擎,不一樣的是瀏覽器端實現了頁面渲染、而node端則提供了一些服務端會用到的特性,好比fsprocess等模塊,同時node端爲了實現跨平臺,底層使用libuv來兼容linuxwindowsmacOS三大操做系統。所以雖然都實現了javascript的異步、非阻塞特性,可是卻有有很多不一樣之處。java

瀏覽器端

不管是在瀏覽器端仍是node端,線程入口都是一個javascript腳本文件,整個腳本文件從第一行開始到最後運行完成能夠看做是一個entry task,即初始化任務,下圖task中第一項即爲該過程。初始化過程當中確定會註冊很多異步事件,好比常見的setTimeoutonClickpromise等,這些異步事件執行中又有可能註冊更多異步事件。全部的這些異步任務都是在事件循環一次次的循環中獲得執行,而這些異步任務又能夠分爲兩大類,即microtasktask(或macrotask)。那麼一次事件循環中會執行多少個異步任務?microtasktask的執行前後順序是什麼呢?看下圖。 node

瀏覽器端事件循環

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

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

microtask主要包括:linux

  1. promise
  2. MutationObserver

所以microtask的執行事件結點是在兩次task執行間隙。前面說了,每類任務都由一個隊列組成,這實際上是一種生產者-消費者模型,事件的註冊過程即爲任務生產過程,任務的執行過程即爲事件的消費過程。那麼每次輪到一類任務執行各個隊列會出隊多少個任務來執行呢?圖中我已經標明,task隊列每次出隊一項任務來執行,執行完成以後開始執行microtask;而microtask則每次都把全部(包括當前microtask執行過程當中新添加的任務)任務執行完成,而後纔會繼續執行task。也就是說,即使microtask是異步任務,也不能無節制的註冊,不然會阻塞task頁面渲染的執行。好比,下面的這段代碼中的setTimeout回調任務將永遠得不到執行(注意,謹慎運行這段代碼,瀏覽器可能卡死):web

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是爲了能知足人眼視覺效果的同時儘可能低頻的調度,若是瀏覽器一刻不停的頻繁渲染,那麼不只人眼觀察不到界面的變化效果(就如同夏天電扇轉太快人眼分辨不出來),並且耗費計算資源。所以上圖中渲染過程用虛線表示不必定每次事件循環都會執行渲染過程。ajax

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

requestAnimationFramesetTimeout更適合作動畫,這裏有個例子能夠參考:jsfiddle.net/H7EEE/245/。效果以下圖所示,能夠看出requestAnimationFramesetTimeout動畫效果更加流暢。 chrome

requestIdleCallback則是在每一渲染貞後的空閒時間去完成回調任務,所以通常用於一些低優先級的 任務調度,好比 react16則使用了該鉤子函數實現異步 reconcilation算法以保證頁面性能,固然因爲 requestIdleCallback是比較新的 APIreact團隊實現了 pollyfill,注意是目前是使用 requestAnimationFrame實現的哦。
react16使用requestIdleCallback實現精細的調度算法

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

能夠看出,在一次事件循環中: 普通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
相關文章
相關標籤/搜索