搞懂JS的事件循環(Event Loop)和宏任務/微任務


在之前的一篇文章中簡單理了下JS的運行機制,順着這條線深入就又遇到了幾個概念,什么是事件循環,什么又是宏任務、微任務呢,今天用這篇文章梳理一下。
以下是我自己的理解,如有錯誤,還望不吝賜教。

事件循環與消息隊列

首先大家都知道JS是一門單線程的語言,所有的任務都是在一個線程上完成的。而我們知道,有一些像I/O,網絡請求等等的操作可能會特別耗時,如果程序使用"同步模式"等到任務返回再繼續執行,就會使得整個任務的執行特別緩慢,運行過程大部分事件都在等待耗時操作的完成,效率特別低。

為了解決這個問題,於是就有了事件循環(Event Loop)這樣的概念,簡單來說就是在程序本身運行的主線程會形成一個"執行棧",除此之外,設立一個"任務隊列",每當有異步任務完成之后,就會在"任務隊列"中放置一個事件,當"執行棧"所有的任務都完成之后,會去"任務隊列"中看有沒有事件,有的話就放到"執行棧"中執行。

這個過程會不斷重復,這種機制就被稱為事件循環(Event Loop)機制。

宏任務/微任務

宏任務可以被理解為每次"執行棧"中所執行的代碼,而瀏覽器會在每次宏任務執行結束后,在下一個宏任務執行開始前,對頁面進行渲染,而宏任務包括:

  • script(整體代碼)
  • setTimeout
  • setInterval
  • I/O
  • UI交互事件
  • postMessage
  • MessageChannel
  • setImmediate
  • UI rendering

微任務,可以理解是在當前"執行棧"中的任務執行結束后立即執行的任務。而且早於頁面渲染和取任務隊列中的任務。宏任務包括:

  • Promise.then
  • Object.observe
  • MutaionObserver
  • process.nextTick

他們的運行機制是這樣的:

  • 執行一個宏任務(棧中沒有就從事件隊列中獲取)
  • 執行過程中如果遇到微任務,就將它添加到微任務的任務隊列中
  • 宏任務執行完畢后,立即執行當前微任務隊列中的所有微任務(依次執行)
  • 當前宏任務執行完畢,開始檢查渲染,然后GUI線程接管渲染
  • 渲染完畢后,JS線程繼續接管,開始下一個宏任務(從事件隊列中獲取)

image

在了解了宏任務和微任務之后,整個Event Loop的流程圖就可以用下面的流程圖來概括:

image

例子

如無特殊說明,我們用setTimeout來模擬異步任務,用Promise來模擬微任務。

主線程上有宏任務和微任務

console.log('task start');

setTimeout(()=>{
    console.log('setTimeout')
},0)

new Promise((resolve, reject)=>{
    console.log('new Promise')
    resolve()
}).then(()=>{
    console.log('Promise.then')
})

console.log('task end');

//----------------------執行結果----------------------
// task start
// new Promise
// task end
// Promise.then
// setTimeout

這個例子比較簡單,就是在主任務上加了一個宏任務(setTimeout),加了一個微任務(Promise.then),看執行的順序,打印出了主任務的task start、new Promise、task end,主任務完成,接下來執行了微任務的Promise.then,到此第一輪事件循環結束,去任務隊列里取出了setTimeout並執行。

主線程上有多個宏任務

console.log('task start');

setTimeout(()=>{
    console.log('setTimeout1')
    new Promise((resolve, reject)=>{
        console.log('new Promise1')
        resolve()
    }).then(()=>{
        console.log('Promise.then1')
    })
},0)
setTimeout(()=>{
    console.log('setTimeout2')
    new Promise((resolve, reject)=>{
        console.log('new Promise2')
        resolve()
    }).then(()=>{
        console.log('Promise.then2')
    })
},0)
console.log('task end');

//----------------------執行結果----------------------
// task start
// task end
// setTimeout1
// new Promise1
// Promise.then1
// setTimeout2
// new Promise2
// Promise.then2

這個例子主要是在主線程上有兩個異步任務要被加到任務隊列,同時每個任務又都有自己的微任務。從這個例子可以看出,被加到任務隊列中的任務會一個一個被取出來放到主線程上執行,完成自己的微任務之后,才會再去事件隊列中取下一個事件。

在微任務中添加宏任務和微任務

跟上個例子相比,我們在Promise.then里加上一個setTimeout和一個Promise.then。

console.log('task start');

setTimeout(()=>{
    console.log('setTimeout1')
},0)

new Promise((resolve, reject)=>{
    console.log('new Promise1')
    resolve()
}).then(()=>{
    console.log('Promise.then1')
    setTimeout(()=>{
        console.log('setTimeout2')
    },0)
    new Promise((resolve, reject)=>{
       console.log('new Promise2')
        resolve()
    }).then(()=>{
        console.log('Promise.then2')
    })
})

console.log('task end');

//----------------------執行結果----------------------
// task start
// new Promise1
// task end
// Promise.then1
// new Promise2
// Promise.then2
// setTimeout1
// setTimeout2

猜對了么,正常的主任務沒有變化,只是在執行第一次微任務的時候,發現了一個宏任務,於是被加進了任務隊列。遇到了一個微任務,放到了微任務隊列,執行完之后又掃了一遍微任務隊列,發現有微任務,於是接着執行完微任務,到這,第一遍事件循環才結束,從任務隊列里拿出了兩次setTimeout執行了。

在異步宏任務中添加宏任務和微任務

其他無異,把剛才添加到Promise.then中的內容添加到setTimeout中。

console.log('task start')

setTimeout(()=>{
    console.log('setTimeout1')
    setTimeout(()=>{
        console.log('setTimeout2')
    },0)
    new Promise((resolve, reject)=>{
       console.log('new Promise2')
        resolve()
    }).then(()=>{
        console.log('Promise.then2')
    })
},0)

new Promise((resolve, reject)=>{
    console.log('new Promise1')
    resolve()
}).then(()=>{
    console.log('Promise.then1')
})

console.log('task end')

//----------------------執行結果----------------------
// task start
// new Promise1
// task end
// Promise.then1
// setTimeout1
// new Promise2
// Promise.then2
// setTimeout2

第一遍主任務執行大家都很明白了,到Promise.then1結束,然后取任務隊列中的setTimeout,執行過程中又發現了一個setTimeout,放到任務隊列中,並且發現一個Promise.then2,把這個微任務執行完之后,第二遍事件循環才結束,然后開始第三遍,打印出了setTimeout2。

有async/await參與

async function async1() {
    console.log( 'async1 start' )
    await async2()
    console.log( 'async1 end' )
}
async function async2() {
    console.log( 'async2' )
}
console.log( 'script start' )
setTimeout( function () {
    console.log( 'setTimeout' )
}, 0 )
async1();
new Promise( function ( resolve ) {
    console.log( 'promise1' )
    resolve();
} ).then( function () {
    console.log( 'promise2' )
} )
console.log( 'script end' )
//---------------------執行結果-------------------------
//script start
//async1 start
//async2
//promise1
//script end
//async1 end
//promise2
//setTimeout

這個例子的特殊之處在於有了async/await的參與,其實也很簡單,可以簡單的理解成async方法會返回一個promise,里面遇到await會阻塞方法的執行,跳出該方法,如果await后跟的返回一個promise,就等待promise完成之后繼續執行后續方法,如果返回的不是一個promise,就不用等待,但后續方法也都相當於在then方法中。

按照上面的思路,看一下代碼,首先執行主程序,打印script start,然后遇到一個setTimeout,所以回調放進事件隊列中,繼續執行,進入方法async1中,這是一個async方法,先打印async1 start,然后進入async2方法,打印async2,同時沒有返回promise,所以相當於不用等待方法完成,這時候async1 end相當於放進了微任務隊列,繼續往下,打印promise1,promise2放進微任務隊列,最后打印script end,然后看微任務隊列中,打印出async1 end和promise2,最后從任務隊列中取出setTimeout打印出來。

加入事件冒泡

事件循環遇到事件冒泡會發生什么?

<div class="outer">
  <div class="inner"></div>
</div>
var outer = document.querySelector('.outer');
var inner = document.querySelector('.inner');

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

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

inner.addEventListener('click', onClick);
outer.addEventListener('click', onClick);

點擊inner,結果:

click		//inner的click
promise		//inner的promise
click		//outer的click
promise		//outer的promise
timeout		//inner的timeout
timeout		//outer的timeout

我覺得解釋應該是這樣的:
1、開始執行,因為事件冒泡的緣故,事件觸發線程會將向上派發事件的任務放入任務隊列。接着執行,打印了click,把timeout放入任務隊列,把promise放入了微任務隊列。
2、執行棧清空,check微任務隊列,發現微任務,打印promise,第一遍事件循環結束。
3、從任務隊列里取出任務,執行outer的click事件,打印click,把outer的timeout放入任務隊列,把outer的promise放入了微任務隊列。執行inner放入任務隊列的timeout。
4、執行棧清空,check微任務隊列,發現微任務,打印promise,第二遍事件循環結束。
5、從任務隊列里取出任務,把timeout打印出來。

JS觸發上面的click事件

一樣的代碼,只不過用JS觸發結果就會不一樣。
對代碼做了稍稍改變,將click拆分成兩個方法,方便追蹤是誰被觸發了。

var outer = document.querySelector('.outer');
var inner = document.querySelector('.inner');

const onInnerClick = (e) => {
  console.log('inner cilcked');

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

  Promise.resolve().then(function() {
    console.log('inner promise');
  });
}

const onOuterClick = (e) => {
  console.log('outer clicked');

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

  Promise.resolve().then(function() {
    console.log('outer promise');
  });
}

inner.addEventListener('click', onInnerClick);
outer.addEventListener('click', onOuterClick);

inner.click();

執行結果:

inner cilcked
outer clicked
inner promise
outer promise
inner timeout
outer timeout

之所以會出現這樣的差異,我的理解是JS代碼執行中的click事件,分發了一個同步的冒泡事件。所以在第一個click事件結束之后,調用棧中有outer的click事件,所以出現了兩個連續的click。

這也是根據結果猜測過程,心里沒底。

在node環境中執行

加入node環境特有的process.nextTick,再看下面這個例子:

console.log(1);
setTimeout(() => {
  console.log(2);
  process.nextTick(() => {
    console.log(3);
  });
  new Promise((resolve) => {
    console.log(4);
    resolve();
  }).then(() => {
    console.log(5);
  });
});
new Promise((resolve) => {
  console.log(7);
  resolve();
}).then(() => {
  console.log(8);
});
process.nextTick(() => {
  console.log(6);
});
setTimeout(() => {
  console.log(9);
  process.nextTick(() => {
    console.log(10);
  });
  new Promise((resolve) => {
    console.log(11);
    resolve();
  }).then(() => {
    console.log(12);
  });
});

以上代碼會有兩個結果
node <11: 1 7 6 8 2 4 9 11 3 10 5 12
node>=11: 1 7 6 8 2 4 3 5 9 11 10 12

NodeJS中微隊列主要有2個:

  • 1.Next Tick Queue:是放置process.nextTick(callback)的回調任務的
  • 2.Other Micro Queue:放置其他microtask,比如Promise等

在瀏覽器中,也可以認為只有一個微隊列,所有的microtask都會被加到這一個微隊列中,但是在NodeJS中,不同的microtask會被放置在不同的微隊列中。

Node.js中的EventLoop過程:

  • 1.執行全局Script的同步代碼
  • 2.執行microtask微任務,先執行所有Next Tick Queue中的所有任務,再執行Other Microtask Queue中的所有任務
  • 3.開始執行macrotask宏任務,共6個階段,從第1個階段開始執行相應每一個階段macrotask中的所有任務,注意,這里是所有每個階段宏任務隊列的所有任務,在瀏覽器的Event Loop中是只取宏隊列的第一個任務出來執行,每一個階段的macrotask任務執行完畢后,開始執行微任務,也就是步驟2
  • 4.Timers Queue -> 步驟2 -> I/O Queue -> 步驟2 -> Check Queue -> 步驟2 -> Close Callback Queue -> 步驟2 -> Timers Queue ......

Node 11.x新變化
現在node11在timer階段的setTimeout,setInterval...和在check階段的immediate都在node11里面都修改為一旦執行一個階段里的一個任務就立刻執行微任務隊列。為了和瀏覽器更加趨同.

參考資料:
什么是 Event Loop?
Tasks, microtasks, queues and schedules
js中的宏任務與微任務


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM