一道題來淺析 JavaScript 的事件循環
//一道經典面試題
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
*/
複制代碼
這道題主要考察的是JS的eventloop的執行順序問題、微任務、宏任務 與異步函數特性等,下面一一道來。
一、EventLoop、微任務、宏任務
1.JS執行順序
JS是單線程語言。JS任務需要排隊順序執行,如果一個任務耗時過長(如:ajax請求、I/O操作等),會嚴重阻塞承勳的執行,基于此 将任務設計成了兩類:
- 同步任務
- 異步任務
同步任務:執行在主線程上,同步程式"由上向下"依次壓入棧中,先進先出,也就是所謂的執行棧。
異步任務:當進行異步操作有結果傳回時(如:ajax請求、I/O操作等),會在主線程之外,事件觸發線程管理的任務隊列之中放置一個事件。
當主線程的執行棧執行完全部同步任務時,執行棧中已經沒有可以執行的任務時,引擎會讀取異步任務隊列,任務隊列類似一個緩沖區,可以執行的任務會被移到執行棧,然後主線程調用執行棧的任務。。
總而言之,檢查執行棧是否為空,以及确定把哪個任務加入執行棧的這個過程就是事件循環,而JS實作異步的核心就是事件循環EventLoop。
從規範了解,浏覽器至少有一個事件循環,一個事件循環至少有一個任務隊,也就是可以有一個或者多個任務隊列(task queue),每個任務都有自己的分組,浏覽器會為不同的任務組設定優先級。
2.微任務、宏任務
一個事件循環包含一個至多個任務隊列,每個任務都有自己的分組,浏覽器會為不同的任務組設定優先級。
宏任務macrotask
包含執行整體的JS代碼(script标簽内),事件回調,XHR回調,,IO操作,UI render、(setTimeout/setInterval/setImmediate)
可以了解是每次執行棧執行的代碼就是一個宏任務(包括每次從事件隊列中擷取一個事件回調并放到執行棧中執行)。浏覽器為了能夠使得JS内部macrotask與DOM任務能夠有序的執行,會在一個macrotask執行結束後,在下一個macrotask 執行開始前,對頁面進行重新渲染,流程如下:
宏任務macrotask->渲染->宏任務macrotask->...
對于宏任務的執行順序是這樣的,那麼微任務呢?
微任務microtask
更新應用程式狀态的任務,主要包含:Promise.then、MutaionObserver、process.nextTick(Node.js 環境)
可以了解是在目前 task 執行結束後立即執行的任務。也就是說,在目前task任務後,下一個task之前,在渲染之前:
宏任務macrotask->微任務microtask(如果存在)->渲染->宏任務macrotask->微任務microtask...
在每一次執行完宏任務後,引擎立即會執行目前eventloop所有産生的微任務,也就是在視圖更新之前(下一次宏任務之前)。
是以微任務Promise的執行順序會早于宏任務setTimeout,當引擎遇到promise時,加入微任務,遇到settimeout加入宏任務,目前執行棧為空後,會執行所有此期間産生的微任務,微任務執行完畢後,渲染更新,然後執行宏任務。
關鍵步驟如下:
- 執行一個宏任務(棧中沒有就從事件隊列中擷取)
- 執行過程中如果遇到微任務,就将它添加到微任務的任務隊列中
- 宏任務執行完畢後,立即執行目前微任務隊列中的所有微任務(依次執行)
- 目前宏任務執行完畢,開始檢查渲染,然後GUI線程接管渲染
- 渲染完畢後,JS線程繼續接管,開始下一個宏任務(從事件隊列中擷取)
流程圖如下:
3.async/await
上面一節的宏任務微任務分辨介紹了原理與Promise與定時器的執行順序,這一節說說async/await。 當函數标記async時,就會标記為異步函數,進入異步任務隊列。
Async/Await 其實是Generator函數的文法糖,也可以認為是一個自執行的generate函數
//Generator函數
function * testDat() {
yield 1;
yield 2;
}
let func = testDat();
func.next(); //1
func.next(); //2
複制代碼
執行 Generator 函數會傳回一個周遊器對象,調用 Generator 函數後,該函數并不執行,傳回的也不是函數運作結果,而是一個指向内部狀态的指針對象。通過next(),來時Generator函數内部的指針向下移動,也就是說,Generator 函數是分段執行的,yield表達式是暫停執行的标記
Async/Await:當遇到await關鍵字是,會等待執行await後邊的語句,很多人以為await會一直等待之後的表達式執行完之後才會繼續執行後面的代碼,實際上await是一個讓出線程的标志。await緊跟的函數會先執行一遍,然後就會跳出整個async函數來執行後面調用棧的代碼。等主線程宏任務執行完了(不包括微任務)之後,又會跳回到async函數中等待await後面表達式的傳回值(promise)
await關鍵字後面函數的傳回值是一個Promise對象,如果不是Promise則會隐式的轉換為Promise:
//同步函數
const a = await 'hello world'
// 相當于
const a = await Promise.resolve('hello world');
複制代碼
正因為此,引擎遇到await關鍵字後,會将緊跟await後的函數先執行一遍,然後await後面的代碼加入到microtask中,然後就會跳出整個async函數
async function async1() {
console.log('async1 start');
await async2();
console.log('async1 end');
}
//等價于
async function async1() {
console.log('async1 start');
Promise.resolve(async2()).then(() => {
console.log('async1 end');
})
}
複制代碼
再次來看這道題
//标注執行順序
/*
async關鍵字标記函數内部可能存在異步操作,await關鍵字之前,代碼會正常執行
*/
async function async1() {
console.log('async1 start');//2.首先輸出 async1 start
await async2(); //進入async2();執行完成async2後,壓入微任務隊列(隐式Promise),并交出線程回到主線程
console.log('async1 end'); // 6 (上文提到await後面的代碼加入到microtask中)
// 根據壓入微任務隊列的順序依次讀取加入調用棧 輸出async1 end
}
async function async2() {
console.log('async2'); //3.列印 async2(記得上文說的,await後的函數會先執行一遍)
}
console.log('script start'); //1.函數開始執行輸出 script start
//加入宏任務隊列
setTimeout(function() {
console.log('setTimeout'); //8.執行宏任務隊列 輸出setTimeout
}, 0)
async1(); //2.執行
new Promise(function(resolve) {
console.log('promise1');//4.Promise構造函數正常同步執行 輸出promise1
resolve();
}).then(function() { //傳回Promise對象,壓入微任務隊列
console.log('promise2'); // 7.根據壓入微任務隊列的順序依次讀取加入調用棧 輸出promise2,至此微任務全部執行完畢,準備渲染->執行宏任務隊列
});
console.log('script end');// 5. 輸出script end,至此,執行棧全部執行完成,然後準備調用
//此次程式運作期間所有産生的微任務
複制代碼
執行順序如下:
- 1.函數開始執行輸出 script start
- 2.遇到async1函數,進入執行首先輸出 async1 start,
-
3.遇到await async2();會先執行async2一次,輸出async2,
await後面的函數是一個Promise,是以加入微任務隊列。并交出線程回到主線程
-
4.Promise構造函數正常同步執行
輸出promise1,傳回Promise,then()當中的代碼壓入微任務隊列
- 5.輸出script end,至此,執行棧全部執行完成,然後準備調用此次程式運作期間所有産生的微任務
- 6.輸出async1 end
- 7.根據壓入微任務隊列的順序依次讀取加入調用棧 輸出promise2,至此微任務全部執行完畢,準備渲染->執行宏任務隊列
- 8.執行宏任務隊列 輸出setTimeout
*對于例子中的Promise操作全部都是 直接console輸出,實際工作中例如ajax傳回資料時間未知,那麼按照上述邏輯假如async2函數需要10s才傳回結果(pending狀态),那麼會不會影響(阻塞)下一個微任務 "console.log('promise2');"的執行呢,實際上:
promise.then 并不是立即注冊了一個微任務,這有兩種情況:
- 如果目前的 promise 狀态已經是 fulfilled 或者 rejected,那麼promise.then 會立刻注冊一個微任務
- 如果目前的 promise 狀态還是 pending, 那麼promise.then 會把這個回調“存儲起來”,等到該 promise 的狀态改變再注冊一個微任務。
轉載于:https://juejin.im/post/5d3036205188252d1f28ec20