從一道題談 JavaScript 的事件循環(huán)

注:本篇文章運行環(huán)境為當(dāng)前最新版本的谷歌瀏覽器(72.0.3626.109)
最近看到這樣一道有關(guān)事件循環(huán)的前端面試題:

//請寫出輸出內(nèi)容
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
*/

這道題主要考察的是事件循環(huán)中函數(shù)執(zhí)行順序的問題溜哮,其中包括async 婚惫,awaitsetTimeoutPromise函數(shù)泛豪。下面來說一下本題中涉及到的知識點蜒滩。

任務(wù)隊列

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

  • JS分為同步任務(wù)和異步任務(wù)
  • 同步任務(wù)都在主線程上執(zhí)行倒谷,形成一個執(zhí)行棧
  • 主線程之外写隶,事件觸發(fā)線程管理著一個任務(wù)隊列,只要異步任務(wù)有了運行結(jié)果帚屉,就在任務(wù)隊列之中放置一個事件谜诫。
  • 一旦執(zhí)行棧中的所有同步任務(wù)執(zhí)行完畢(此時JS引擎空閑),系統(tǒng)就會讀取任務(wù)隊列攻旦,將可運行的異步任務(wù)添加到可執(zhí)行棧中喻旷,開始執(zhí)行。

根據(jù)規(guī)范牢屋,事件循環(huán)是通過任務(wù)隊列的機制來進行協(xié)調(diào)的且预。一個 Event Loop 中,可以有一個或者多個任務(wù)隊列(task queue)烙无,一個任務(wù)隊列便是一系列有序任務(wù)(task)的集合锋谐;每個任務(wù)都有一個任務(wù)源(task source),源自同一個任務(wù)源的 task 必須放到同一個任務(wù)隊列截酷,從不同源來的則被添加到不同隊列涮拗。 setTimeout/Promise 等API便是任務(wù)源,而進入任務(wù)隊列的是他們指定的具體執(zhí)行任務(wù)迂苛。

宏任務(wù)

(macro)task(又稱之為宏任務(wù))三热,可以理解是每次執(zhí)行棧執(zhí)行的代碼就是一個宏任務(wù)(包括每次從事件隊列中獲取一個事件回調(diào)并放到執(zhí)行棧中執(zhí)行)。

瀏覽器為了能夠使得JS內(nèi)部(macro)task與DOM任務(wù)能夠有序的執(zhí)行三幻,會在一個(macro)task執(zhí)行結(jié)束后就漾,在下一個(macro)task 執(zhí)行開始前,對頁面進行重新渲染念搬,流程如下:

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

(macro)task主要包含:script(整體代碼)抑堡、setTimeout、setInterval锁蠕、I/O夷野、UI交互事件、postMessage荣倾、MessageChannel悯搔、setImmediate(Node.js 環(huán)境)

微任務(wù)

microtask(又稱為微任務(wù)),可以理解是在當(dāng)前 task 執(zhí)行結(jié)束后立即執(zhí)行的任務(wù)舌仍。也就是說妒貌,在當(dāng)前task任務(wù)后,下一個task之前铸豁,在渲染之前灌曙。

所以它的響應(yīng)速度相比setTimeout(setTimeout是task)會更快,因為無需等渲染节芥。也就是說在刺,在某一個macrotask執(zhí)行完后逆害,就會將在它執(zhí)行期間產(chǎn)生的所有microtask都執(zhí)行完畢(在渲染前)。

microtask主要包含:Promise.then蚣驼、MutaionObserver魄幕、process.nextTick(Node.js 環(huán)境)

運行機制

在事件循環(huán)中,每進行一次循環(huán)操作稱為 tick颖杏,每一次 tick 的任務(wù)處理模型是比較復(fù)雜的纯陨,但關(guān)鍵步驟如下:

  • 執(zhí)行一個宏任務(wù)(棧中沒有就從事件隊列中獲取)
  • 執(zhí)行過程中如果遇到微任務(wù)留储,就將它添加到微任務(wù)的任務(wù)隊列中
  • 宏任務(wù)執(zhí)行完畢后翼抠,立即執(zhí)行當(dāng)前微任務(wù)隊列中的所有微任務(wù)(依次執(zhí)行)
  • 當(dāng)前宏任務(wù)執(zhí)行完畢,開始檢查渲染获讳,然后GUI線程接管渲染
  • 渲染完畢后阴颖,JS線程繼續(xù)接管,開始下一個宏任務(wù)(從事件隊列中獲扰夂俊)

流程圖如下:

Promise和async中的立即執(zhí)行

我們知道Promise中的異步體現(xiàn)在thencatch中膘盖,所以寫在Promise中的代碼是被當(dāng)做同步任務(wù)立即執(zhí)行的。而在async/await中尤误,在出現(xiàn)await出現(xiàn)之前侠畔,其中的代碼也是立即執(zhí)行的。那么出現(xiàn)了await時候發(fā)生了什么呢损晤?

await做了什么

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

很多人以為await會一直等待之后的表達式執(zhí)行完之后才會繼續(xù)執(zhí)行后面的代碼喘落,實際上await是一個讓出線程的標志。await后面的表達式會先執(zhí)行一遍最冰,將await后面的代碼加入到microtask中瘦棋,然后就會跳出整個async函數(shù)來執(zhí)行后面的代碼。

這里感謝@chenjigeng的糾正:

由于因為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');
        })
}

回到本題

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

  1. 首先沛慢,事件循環(huán)從宏任務(wù)(macrotask)隊列開始,這個時候达布,宏任務(wù)隊列中团甲,只有一個script(整體代碼)任務(wù);當(dāng)遇到任務(wù)源(task source)時黍聂,則會先分發(fā)任務(wù)到對應(yīng)的任務(wù)隊列中去躺苦。所以身腻,上面例子的第一步執(zhí)行如下圖所示:

  2. 然后我們看到首先定義了兩個async函數(shù),接著往下看圾另,然后遇到了 console 語句霸株,直接輸出 script start。輸出之后集乔,script 任務(wù)繼續(xù)往下執(zhí)行,遇到 setTimeout坡椒,其作為一個宏任務(wù)源扰路,則會先將其任務(wù)分發(fā)到對應(yīng)的隊列中:

  3. script 任務(wù)繼續(xù)往下執(zhí)行,執(zhí)行了async1()函數(shù)倔叼,前面講過async函數(shù)中在await之前的代碼是立即執(zhí)行的汗唱,所以會立即輸出async1 start

    遇到了await時丈攒,會將await后面的表達式執(zhí)行一遍哩罪,所以就緊接著輸出async2,然后將await后面的代碼也就是console.log('async1 end')加入到microtask中的Promise隊列中巡验,接著跳出async1函數(shù)來執(zhí)行后面的代碼际插。

  4. script任務(wù)繼續(xù)往下執(zhí)行,遇到Promise實例显设。由于Promise中的函數(shù)是立即執(zhí)行的框弛,而后續(xù)的 .then 則會被分發(fā)到 microtask 的 Promise 隊列中去。所以會先輸出 promise1捕捂,然后執(zhí)行 resolve瑟枫,將 promise2 分配到對應(yīng)隊列。

  5. script任務(wù)繼續(xù)往下執(zhí)行指攒,最后只有一句輸出了 script end慷妙,至此,全局任務(wù)就執(zhí)行完畢了允悦。

    根據(jù)上述膝擂,每次執(zhí)行完一個宏任務(wù)之后,會去檢查是否存在 Microtasks澡屡;如果有猿挚,則執(zhí)行 Microtasks 直至清空 Microtask Queue。

    因而在script任務(wù)執(zhí)行完畢之后驶鹉,開始查找清空微任務(wù)隊列绩蜻。此時,微任務(wù)中室埋, Promise 隊列有的兩個任務(wù)async1 endpromise2办绝,因此按先后順序輸出 async1 end伊约,promise2。當(dāng)所有的 Microtasks 執(zhí)行完畢之后孕蝉,表示第一輪的循環(huán)就結(jié)束了屡律。

  6. 第二輪循環(huán)開始,這個時候就會跳回async1函數(shù)中執(zhí)行后面的代碼降淮,然后遇到了同步任務(wù) console 語句超埋,直接輸出 async1 end。這樣第二輪的循環(huán)就結(jié)束了佳鳖。(也可以理解為被加入到script任務(wù)隊列中霍殴,所以會先與setTimeout隊列執(zhí)行)

  7. 第二輪循環(huán)依舊從宏任務(wù)隊列開始。此時宏任務(wù)中只有一個 setTimeout系吩,取出直接輸出即可来庭,至此整個流程結(jié)束。

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

變式一

在第一個變式中我將async2中的函數(shù)也變成了Promise函數(shù)月弛,代碼如下:

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

可以先自己看看輸出順序會是什么,下面來公布結(jié)果:

script start
async1 start
promise1
promise3
script end
promise2
async1 end
promise4
setTimeout

在第一次macrotask執(zhí)行完之后科盛,也就是輸出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');

可以先自己看看輸出順序會是什么蜡饵,下面來公布結(jié)果:

script start
async1 start
promise1
script end
promise2
setTimeout3
setTimeout2
setTimeout1

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

變式三

變式三是我在一篇面經(jīng)中看到的原題博杖,整體來說大同小異,代碼如下:

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

無非是在微任務(wù)那塊兒做點文章筷登,前面的內(nèi)容如果你都看懂了的話這道題一定沒問題的剃根,結(jié)果如下:

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

參考文章

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市前方,隨后出現(xiàn)的幾起案子狈醉,更是在濱河造成了極大的恐慌廉油,老刑警劉巖,帶你破解...
    沈念sama閱讀 211,817評論 6 492
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件苗傅,死亡現(xiàn)場離奇詭異抒线,居然都是意外死亡,警方通過查閱死者的電腦和手機渣慕,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 90,329評論 3 385
  • 文/潘曉璐 我一進店門嘶炭,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人摇庙,你說我怎么就攤上這事旱物。” “怎么了卫袒?”我有些...
    開封第一講書人閱讀 157,354評論 0 348
  • 文/不壞的土叔 我叫張陵,是天一觀的道長单匣。 經(jīng)常有香客問我夕凝,道長,這世上最難降的妖魔是什么户秤? 我笑而不...
    開封第一講書人閱讀 56,498評論 1 284
  • 正文 為了忘掉前任码秉,我火速辦了婚禮,結(jié)果婚禮上鸡号,老公的妹妹穿的比我還像新娘转砖。我一直安慰自己,他們只是感情好鲸伴,可當(dāng)我...
    茶點故事閱讀 65,600評論 6 386
  • 文/花漫 我一把揭開白布府蔗。 她就那樣靜靜地躺著,像睡著了一般汞窗。 火紅的嫁衣襯著肌膚如雪姓赤。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 49,829評論 1 290
  • 那天仲吏,我揣著相機與錄音不铆,去河邊找鬼。 笑死裹唆,一個胖子當(dāng)著我的面吹牛誓斥,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播许帐,決...
    沈念sama閱讀 38,979評論 3 408
  • 文/蒼蘭香墨 我猛地睜開眼劳坑,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了舞吭?” 一聲冷哼從身側(cè)響起泡垃,我...
    開封第一講書人閱讀 37,722評論 0 266
  • 序言:老撾萬榮一對情侶失蹤析珊,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后蔑穴,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體忠寻,經(jīng)...
    沈念sama閱讀 44,189評論 1 303
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 36,519評論 2 327
  • 正文 我和宋清朗相戀三年存和,在試婚紗的時候發(fā)現(xiàn)自己被綠了奕剃。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 38,654評論 1 340
  • 序言:一個原本活蹦亂跳的男人離奇死亡捐腿,死狀恐怖纵朋,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情茄袖,我是刑警寧澤操软,帶...
    沈念sama閱讀 34,329評論 4 330
  • 正文 年R本政府宣布,位于F島的核電站宪祥,受9級特大地震影響聂薪,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜蝗羊,卻給世界環(huán)境...
    茶點故事閱讀 39,940評論 3 313
  • 文/蒙蒙 一藏澳、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧耀找,春花似錦翔悠、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,762評論 0 21
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至复罐,卻和暖如春涝登,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背效诅。 一陣腳步聲響...
    開封第一講書人閱讀 31,993評論 1 266
  • 我被黑心中介騙來泰國打工胀滚, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人乱投。 一個月前我還...
    沈念sama閱讀 46,382評論 2 360
  • 正文 我出身青樓咽笼,卻偏偏與公主長得像,于是被迫代替她去往敵國和親戚炫。 傳聞我的和親對象是個殘疾皇子剑刑,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 43,543評論 2 349

推薦閱讀更多精彩內(nèi)容