天天看點

一道題來看 async/await、Promise、setTimeout

一道題來淺析 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

繼續閱讀