一道面試題引發對javascript事件循環機制(Event Loop)的 思考(這里討論針對瀏覽器)


😄😄廢話不多說,先上題:

//請寫出輸出內容
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`,`setTimeout`,`Promise`函數。下面來說一下本題中涉及到的知識點。

任務隊列

首先我們需要明白以下幾件事情:

* JS分為同步任務和異步任務
* 同步任務都在主線程上執行,形成一個執行棧
* 主線程之外,事件觸發線程管理着一個任務隊列,只要異步任務有了運行結果,就在任務隊列之中放置一個事件。
* 一旦執行棧中的所有同步任務執行完畢(此時JS引擎空閑),系統就會讀取任務隊列,將可運行的異步任務添加到可執行棧中,開始執行。

根據規范,事件循環是通過[任務隊列](https://www.w3.org/TR/html5/webappapis.html#task-queues)的機制來進行協調的。

一個 Event Loop 中,可以有一個或者多個任務隊列(task queue),一個任務隊列便是一系列有序任務(task)的集合;每個任務都有一個任務源(task source),源自同一個任務源的 task 必須放到同一個任務隊列,從不同源來的則被添加到不同隊列。

setTimeout/Promise 等API便是任務源,而進入任務隊列的是他們指定的具體執行任務。

宏任務

(macro)task(又稱之為宏任務),可以理解是每次執行棧執行的代碼就是一個宏任務(包括每次從事件隊列中獲取一個事件回調並放到執行棧中執行)。

瀏覽器為了能夠使得JS內部(macro)task與DOM任務能夠有序的執行,會在一個(macro)task執行結束后,在下一個(macro)task 執行開始前,對頁面進行重新渲染,流程如下:

(macro)task->渲染->(macro)task->...

(macro)task主要包含:script(整體代碼)、setTimeout、setInterval、I/O、UI交互事件、postMessage、MessageChannel、setImmediate(Node.js 環境)

微任務

microtask(又稱為微任務),可以理解是在當前 task 執行結束后立即執行的任務。也就是說,在當前task任務后,下一個task之前,在渲染之前。

所以它的響應速度相比setTimeout(setTimeout是task)會更快,因為無需等渲染。也就是說,在某一個macrotask執行完后,就會將在它執行期間產生的所有microtask都執行完畢(在渲染前)。

microtask主要包含:Promise.then、MutaionObserver、process.nextTick(Node.js 環境)

運行機制

在事件循環中,每進行一次循環操作稱為 tick,每一次 tick 的任務[處理模型](https://www.w3.org/TR/html5/webappapis.html#event-loops-processing-model)是比較復雜的,但關鍵步驟如下:

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

流程圖如下:

Promise和async中的立即執行

我們知道Promise中的異步體現在`then`和`catch`中,所以寫在Promise中的代碼是被當做同步任務立即執行的。而在async/await中,在出現await出現之前,其中的代碼也是立即執行的。那么出現了await時候發生了什么呢?

await做了什么

從字面意思上看await就是等待,await 等待的是一個表達式,這個表達式的返回值可以是一個promise對象也可以是其他值

很多人以為await會一直等待之后的表達式執行完之后才會繼續執行后面的代碼,實際上await是一個讓出線程的標志。await后面的表達式會先執行一遍,將await后面的代碼加入到microtask中,然后就會跳出整個async函數來執行后面的代碼。(其中對於紅色部分文字我是存疑的)

可以看到issue中有人提到,由於因為async await 本身就是promise+generator的語法糖。所以await后面的代碼是microtask。所以對於本題中的

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');
        })
}

接着往下看到issue中,又有人指出這里的`console.log('async1 end')`是屬於macrotask中的script隊列中的,因為script隊列在setTimout隊列前面,所以會比setTimout先輸出。也可以理解為是同步代碼,所以先輸出。

所以這里有兩種說法,我表示是有疑問的......🤔️🤔️

結合這篇文章 https://juejin.im/post/5c148ec8e51d4576e83fd836 中的例子🌰,說說async/await的運行機制。

  • async定義的是一個Promise函數和普通函數一樣只要不調用就不會進入事件隊列。
  • async內部如果沒有主動return Promise,那么async會把函數的返回值用Promise包裝。
  • await關鍵字必須出現在async函數中,await后面不是必須要跟一個異步操作,也可以是一個普通表達式。
  • 遇到await關鍵字,await右邊的語句會被立即執行然后await下面的代碼進入等待狀態,等待await得到結果。當await的后面不是promise對象,那么await會阻塞其后面的代碼,先執行async外部的同步代碼,同步代碼執行完再回到async內部,把這個非promise的東西,作為 await表達式的結果。當await后面如果是 promise 對象,await 也會暫停async后面的代碼,先執行async外面的同步代碼,等着 Promise 對象 fulfilled,然后把 resolve 的參數作為 await 表達式的運算結果。

根據我的觀察,這里我大膽的做個結論:

1、當await后面為非promise時,那么當外部同步代碼執行完后,如果外部Promise執行中resolve的調用帶參數,那么此時await下面的代碼先於外部Promise回調入隊的微任務執行。

若外部Promise執行時resolve調用不帶參數,那么外部Promise回調入隊的微任務先於await后面的代碼執行。

你肯定會問:Why?

* 根據 [Promises/A+規范](http://www.ituring.com.cn/article/66566):

 `Promise.resolve` 方法允許調用時不帶參數,直接返回一個`resolved` 狀態的 `Promise` 對象。立即 `resolved` 的 `Promise` 對象,是在本輪“事件循環”(event loop)的結束時,而不是在下一輪“事件循環”的開始時。

對於Promise.resolve: 如果參數是個非 thenable 對象或者不是一個對象,也是返回一個 `resolved` 狀態的 Promise。

所以,例如:

new Promise(resolve => {
    resolve(1);
    
    Promise.resolve().then(() => {
        // t2
        console.log(2)
    });
    console.log(4)
}).then(t => {
    // t1
    console.log(t)
});
console.log(3);

這段代碼,結果為4321。

2、當await后面為promise時,那么Promise的回調入隊的微任務將先於await下面的代碼執行。

回到本題

以上就本道題涉及到的所有相關知識點了,下面我們再回到這道題來一步一步看看怎么回事兒。

1. 首先,事件循環從宏任務(macrotask)隊列開始,這個時候,宏任務隊列中,只有一個script(整體代碼)任務;當遇到任務源(task source)時,則會先分發任務到對應的任務隊列中去。

2. 然后我們看到首先定義了兩個async函數,接着往下看,然后遇到了 `console` 語句,直接輸出 `script start`。輸出之后,script 任務繼續往下執行,遇到 `setTimeout`,其作為一個宏任務源,則會先將其任務分發到對應的隊列中。

3. script 任務繼續往下執行,執行了async1()函數,前面講過async函數中在await之前的代碼是立即執行的,所以會立即輸出`async1 start`。
遇到了await時,會將await后面的表達式執行一遍,所以就緊接着輸出`async2`,然后將await后面的代碼也就是`console.log('async1 end')`加入到microtask中的Promise隊列中,接着跳出async1函數來執行后面的代碼。

4. script任務繼續往下執行,遇到Promise實例。由於Promise中的函數是立即執行的,而后續的 `.then` 則會被分發到 microtask 的 `Promise` 隊列中去。所以會先輸出 `promise1`,然后執行 `resolve`,將 `promise2` 分配到對應隊列。

5. script任務繼續往下執行,最后只有一句輸出了 `script end`,至此,全局任務就執行完畢了。
根據上述,每次執行完一個宏任務之后,會去檢查是否存在 Microtasks;如果有,則執行 Microtasks 直至清空 Microtask Queue。
因而在script任務執行完畢之后,開始查找清空微任務隊列。此時,微任務中, `Promise` 隊列有的兩個任務`async1 end`和`promise2`,因此按先后順序輸出 `async1 end,promise2`。當所有的 Microtasks 執行完畢之后,表示第一輪的循環就結束了。

6. 第二輪循環依舊從宏任務隊列開始。此時宏任務中只有一個 `setTimeout`,取出直接輸出即可,至此整個流程結束。

 

下面我會改變一下代碼來加深印象。

變式一

在第一個變式中將async2中的函數也變成了Promise函數,代碼如下:

async function async1() {
    console.log('async1 start');
    await async2();
    console.log('async1 end');
}
async function async2() {
    //async2做出如下更改:
    new Promise(function(resolve) {
    console.log('promise1');
    resolve();
}).then(function() {
    console.log('promise2');
    });
}
console.log('script start');

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

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

console.log('script end');

輸出結果:

/*
script start
async1 start
promise1
promise3
script end
promise2
async1 end
promis4 setTimeout
*/

在第一次macrotask執行完之后,也就是輸出`script end`之后,會去清理所有microtask。所以會相繼輸出`promise2`, ` async1 end`,`promise4`。

變式二

在第二個變式中,將async1中await后面的代碼和async2的代碼都改為異步的,代碼如下:

async function async1() {
    console.log('async1 start');
    await async2();
    //更改如下:
    setTimeout(function() {
        console.log('setTimeout1')
    },0)
}
async function async2() {
    //更改如下:
    setTimeout(function() {
        console.log('setTimeout2')
    },0)
}
console.log('script start');

setTimeout(function() {
    console.log('setTimeout3');
}, 0)
async1();

new Promise(function(resolve) {
    console.log('promise1');
    resolve();
}).then(function() {
    console.log('promise2');
});
console.log('script end');

輸出結果:

/*
script start
async1 start
promise1
script end
promise2
setTimeout3
setTimeout2
setTimeout1
*/

在輸出為`promise2`之后,接下來會按照加入setTimeout隊列的順序來依次輸出,通過代碼我們可以看到加入順序為`3 2 1`,所以會按3,2,1的順序來輸出。

變式三

代碼如下:

async function a1 () {
    console.log('a1 start')
    await a2()
    console.log('a1 end')
}
async function a2 () {
    console.log('a2')
}

console.log('script start')

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

Promise.resolve().then(() => {
    console.log('promise1')
})

a1()

let promise2 = new Promise((resolve) => {
    resolve('promise2.then')
    console.log('promise2')
})

promise2.then((res) => {
    console.log(res)
    Promise.resolve().then(() => {
        console.log('promise3')
    })
})
console.log('script end')

輸出結果:

/*
script start
a1 start
a2
promise2
script end
promise1
a1 end
promise2.then
promise3
setTimeout
*/

 

原文地址:https://github.com/Advanced-Frontend/Daily-Interview-Question/issues/7


免責聲明!

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



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