事件循環(huán)(Event Loop)和異步編程(Async)一直是熱門話題,本文將針對(duì)這兩個(gè)概念做詳細(xì)的講解。
為什么單線程是一個(gè)限制?
可以想象一下在瀏覽器中運(yùn)行的復(fù)雜圖像轉(zhuǎn)換算法,渲染一個(gè)超級(jí)大圖,雖然調(diào)用堆棧還有要執(zhí)行的其他功能,但瀏覽器正在運(yùn)行,不能做其他事情,所以被阻塞了。這意味著瀏覽器無(wú)法渲染和運(yùn)行任何其他代碼,只是卡住了。給用戶帶來(lái)了非常糟糕的體驗(yàn)。
在某些情況下,這可能不是一個(gè)關(guān)鍵問(wèn)題。但是,這會(huì)帶來(lái)更大的問(wèn)題。一旦瀏覽器開(kāi)始處理調(diào)用很多堆棧中的任務(wù),它可能會(huì)在很長(zhǎng)一段時(shí)間內(nèi)停止響應(yīng)。到那時(shí),許多瀏覽器會(huì)通過(guò)引發(fā)錯(cuò)誤來(lái)采取行動(dòng),詢問(wèn)他們是否應(yīng)該終止頁(yè)面:這很就尷尬了??,并且完全破壞了用戶體驗(yàn)。

JavaScript 程序的構(gòu)建塊
我們盡管在單個(gè) .js 文件中編寫 JavaScript 應(yīng)用程序,但程序肯定由幾個(gè)塊組成,其中只有一個(gè)現(xiàn)在要執(zhí)行,其余的在以后執(zhí)行。最常見(jiàn)的塊單元是函數(shù)。
例如下面的demo,大多數(shù)剛接觸 JavaScript 的開(kāi)發(fā)人員似乎都遇到的問(wèn)題,標(biāo)準(zhǔn) Ajax 請(qǐng)求不會(huì)同步完成,代碼執(zhí)行時(shí) ajax(..) 函數(shù)還沒(méi)有任何值可以返回賦值給變量response。
// ajax(..) is some arbitrary Ajax function given by a library
var response = ajax('https://example.com/api');
console.log(response);
// `response` won't have the response
所以需要在第二個(gè)參數(shù)添加回調(diào)函數(shù),用于接收返回結(jié)果。
ajax('https://example.com/api', function(response) {
console.log(response); // `response` is now available
});
當(dāng)然也可以發(fā)出同步 Ajax 請(qǐng)求,就像下面這段代碼,但是最好不要這樣做,如果發(fā)出一個(gè)同步的 Ajax 請(qǐng)求,JavaScript 應(yīng)用程序的 UI 將被阻止——用戶將無(wú)法單擊、輸入數(shù)據(jù)、導(dǎo)航或滾動(dòng)。這將阻止任何用戶交互。這是一種可怕的做法。
ajax({
url: 'https://api.example.com/endpoint',
success: function(response) {
// This is your callback.
},
async: false // 這樣設(shè)置同步,并不建議
});
除了以上說(shuō)的Ajax,想讓代碼塊異步執(zhí)行,這可以通過(guò) setTimeout(callback, milliseconds) 函數(shù)來(lái)完成, setTimeout 函數(shù)的作用是設(shè)置一個(gè)事件(超時(shí))稍后發(fā)生??纯聪旅娴睦樱?/p>
function first() {
console.log('first');
}
function second() {
console.log('second');
}
function third() {
console.log('third');
}
first();
setTimeout(second, 1000); // 延遲1000ms執(zhí)行second
third();
輸出結(jié)果:
first
third
second
什么是事件循環(huán)?
盡管允許異步 JavaScript 代碼(如我們剛剛討論的 setTimeout),但在 ES6 之前,JavaScript 本身實(shí)際上從未內(nèi)置任何直接的異步概念。 JavaScript 引擎所做的只是在任何給定時(shí)刻執(zhí)行程序的一個(gè)塊。
有關(guān) JavaScript 引擎如何工作的更多詳細(xì)信息(特別是 Google 的 V8),可以參考文章https://blog.sessionstack.com/how-javascript-works-inside-the-v8-engine-5-tips-on-how-to-write-optimized-code-ac089e62b12e
那么問(wèn)題來(lái)了,誰(shuí)來(lái)告訴 JS 引擎執(zhí)行你的程序塊呢?實(shí)際上,JS 引擎并不是孤立運(yùn)行的——它在托管環(huán)境中運(yùn)行,對(duì)于大多數(shù)開(kāi)發(fā)人員來(lái)說(shuō),典型的有 Web 瀏覽器或 Node.js。實(shí)際上,如今,JavaScript 被嵌入到各種設(shè)備中,從機(jī)器人到燈泡。每個(gè)設(shè)備都代表 JS 引擎的不同類型的托管環(huán)境。
所有環(huán)境中的共同點(diǎn)是一個(gè)稱為事件循環(huán)的內(nèi)置機(jī)制,它隨著時(shí)間的推移處理程序的多個(gè)塊的執(zhí)行,每次調(diào)用 JS 引擎
這意味著 JS 引擎只是任意 JS 代碼的按需執(zhí)行環(huán)境。調(diào)度事件(JS 代碼執(zhí)行)的是周圍的環(huán)境。
例如,當(dāng)您的 JavaScript 程序發(fā)出 Ajax 請(qǐng)求以從服務(wù)器獲取一些數(shù)據(jù)時(shí),您在函數(shù)中設(shè)置“response代碼(callback),JS 引擎會(huì)告訴托管環(huán)境:
JS 引擎:“嘿,我現(xiàn)在要暫停執(zhí)行,但是當(dāng)你完成了那個(gè)網(wǎng)絡(luò)請(qǐng)求,并且你有一些數(shù)據(jù)時(shí),請(qǐng)回調(diào)這個(gè)函數(shù)?!?br>
托管環(huán)境:“收到,等我成功完成這個(gè)請(qǐng)求,我就調(diào)用你給我的函數(shù)”
然后瀏覽器被設(shè)置為監(jiān)聽(tīng)來(lái)自網(wǎng)絡(luò)的響應(yīng),當(dāng)它有東西要返回時(shí),它會(huì)通過(guò)將回調(diào)函數(shù)插入事件循環(huán)中來(lái)安排回調(diào)函數(shù)的執(zhí)行。

有關(guān)內(nèi)存堆和調(diào)用堆棧的更多信息,可以參考文章:https://blog.sessionstack.com/how-does-javascript-actually-work-part-1-b0bacc073cf
這些 Web API 是什么?從本質(zhì)上講,它們是您無(wú)法訪問(wèn)的線程,您只需調(diào)用它們即可。它們是并發(fā)啟動(dòng)的瀏覽器部分。如果您是 Node.js 開(kāi)發(fā)人員,這些就是 C++ API。
那么事件循環(huán)到底是什么?

事件循環(huán)有一項(xiàng)簡(jiǎn)單的工作——監(jiān)控調(diào)用堆棧和回調(diào)隊(duì)列。如果調(diào)用堆棧為空,事件循環(huán)將從隊(duì)列中取出第一個(gè)事件并將其推送到調(diào)用堆棧,調(diào)用堆棧有效地運(yùn)行它
這樣的迭代在事件循環(huán)中稱為一個(gè)tick。請(qǐng)記住這個(gè)tick,后面會(huì)使用這個(gè)概念。每個(gè)事件只是一個(gè)函數(shù)回調(diào)。
console.log('Hi');
setTimeout(function cb1() {
console.log('cb1');
}, 5000);
console.log('Bye');
讓我們來(lái)看看執(zhí)行上面這段代碼會(huì)發(fā)生什么:
1、瀏覽器控制臺(tái)沒(méi)有打印,調(diào)用堆棧為空

2、
console.log('Hi')添加到調(diào)用堆棧
3、
console.log('Hi')被執(zhí)行
4、
console.log('Hi')移除調(diào)用堆棧
5、
setTimeout(function cb1() { ... })添加到調(diào)用堆棧
6、
setTimeout(function cb1() { ... })被執(zhí)行,瀏覽器創(chuàng)建一個(gè)計(jì)時(shí)器timer添加到Web APIs ,它將用于處理倒計(jì)時(shí)
7、
setTimeout(function cb1() { ... })完成并移除調(diào)用堆棧
8、console.log('Bye') 添加到調(diào)用堆棧

9、console.log('Bye') 被執(zhí)行

10、console.log('Bye') 移除調(diào)用堆棧

11、在5000ms以后,timer完成,將timer的cb1回調(diào)函數(shù)存入回調(diào)隊(duì)列

12、事件循環(huán)從回調(diào)隊(duì)列中取出 cb1 并將其推送到調(diào)用堆棧

13、cb1執(zhí)行并將console.log('cb1')添加到調(diào)用堆棧

14、console.log('cb1')被執(zhí)行

15、console.log('cb1')移除調(diào)用堆棧

16、cb1移除調(diào)用堆棧

快速回顧:
https://miro.medium.com/max/1400/1*TozSrkk92l8ho6d8JxqF_w.gif
有趣的是,ES6 指定了事件循環(huán)應(yīng)該如何工作,這意味著從技術(shù)上講它在 JS 引擎的職責(zé)范圍內(nèi),它不再僅僅扮演托管環(huán)境的角色。這種變化的一個(gè)主要原因是在 ES6 中引入了 Promises,因?yàn)楹笳咝枰L問(wèn)對(duì)事件循環(huán)隊(duì)列上的調(diào)度操作的直接、細(xì)粒度的控制(稍后我們將更詳細(xì)地討論它們)
setTimeout(…) 如何工作?
重要的是要注意 setTimeout(...) 不會(huì)自動(dòng)將回調(diào)放入事件循環(huán)隊(duì)列。它設(shè)置了一個(gè)計(jì)時(shí)器。當(dāng)計(jì)時(shí)器到期時(shí),環(huán)境會(huì)將您的回調(diào)放入事件循環(huán)中,以便將來(lái)的某個(gè)tick將拾取并執(zhí)行它??纯催@段代碼:
setTimeout(myCallback, 1000);
這并不意味著 myCallback 將在 1,000 毫秒內(nèi)執(zhí)行,而是在 1,000 毫秒內(nèi),myCallback 將被添加到事件循環(huán)隊(duì)列中。然而,隊(duì)列中可能有之前添加的其他事件——回調(diào)將不得不等待執(zhí)行。
事件循環(huán)的作用以及 setTimeout 的工作原理現(xiàn)在了解了吧!即便是使用 0 作為第二個(gè)參數(shù)調(diào)用 setTimeout 只是將回調(diào)延遲到調(diào)用堆棧清除為止。
看看下面的代碼:
console.log('Hi');
setTimeout(function() {
console.log('callback');
}, 0);
console.log('Bye');
雖然等待時(shí)間設(shè)置為 0 毫秒,但瀏覽器控制臺(tái)中的結(jié)果將如下所示:
Hi
Bye
callback
事件循環(huán)在 ES6中 如何工作?
ES6 中引入了一個(gè)稱為“作業(yè)隊(duì)列(Job Queue)”的新概念。它是存在于事件循環(huán)隊(duì)列之上的一層,比如處理 Promise 的異步的時(shí)候,后面我們會(huì)討論
我們現(xiàn)在只涉及這個(gè)概念,以便稍后在討論 Promises 的異步行為時(shí),您會(huì)了解這些操作是如何被調(diào)度和處理的。
想象一下:Job Queue 是一個(gè)附加到 Event Loop 隊(duì)列中每個(gè) tick 末尾的隊(duì)列。在事件循環(huán)的一個(gè) tick期間可能發(fā)生的某些異步操作不會(huì)導(dǎo)致將一個(gè)全新的事件添加到事件循環(huán)隊(duì)列中,而是會(huì)在當(dāng)前 tick的Job Queue的末尾添加一個(gè)項(xiàng)目(也稱為Job)。
這意味著您可以添加另一個(gè)稍后執(zhí)行的功能,并且它會(huì)在其他任何事情之前立即執(zhí)行。
一個(gè)Job還可以導(dǎo)致更多Job被添加到同一隊(duì)列的末尾。理論上,Job“循環(huán)”(不斷添加其他Job)可能會(huì)無(wú)限期地旋轉(zhuǎn),從而使程序缺乏移動(dòng)到下一個(gè)事件循環(huán)tick所需的必要資源。從概念上講,這類似于在代碼中添加一個(gè)長(zhǎng)時(shí)間運(yùn)行或無(wú)限循環(huán)的邏輯(如 while (true) ..)。
Jobs有點(diǎn)像 setTimeout(callback, 0) “hack”,但以這樣一種方式實(shí)現(xiàn),它們引入了更明確和有保證的排序:稍后,但盡快。
回調(diào)
如您所知,回調(diào)是迄今為止在 JavaScript 程序中表達(dá)和管理異步性的最常用方法。事實(shí)上,回調(diào)是 JavaScript 語(yǔ)言中最基本的異步模式。無(wú)數(shù)的 JS 程序,甚至是非常復(fù)雜和復(fù)雜的程序,都是在除了回調(diào)之外沒(méi)有其他異步基礎(chǔ)之上編寫的。
回調(diào)也有缺點(diǎn)。所以許多開(kāi)發(fā)人員正在嘗試尋找更好的異步模式。但是,如果您不了解引擎蓋下的實(shí)際內(nèi)容,就不可能有效地使用任何抽象。
在下一章中,我們將深入探討為什么需要且推薦更復(fù)雜的異步模式(將在后續(xù)文章中討論)。
嵌套回調(diào)
看下面的代碼:
listen('click', function (e){
setTimeout(function(){
ajax('https://api.example.com/endpoint', function (text){
if (text == "hello") {
doSomething();
}
else if (text == "world") {
doSomethingElse();
}
});
}, 500);
});
有一個(gè)嵌套在一起的三個(gè)函數(shù)鏈,每個(gè)函數(shù)代表異步序列中的一個(gè)步驟。
這種代碼通常被稱為“回調(diào)地獄”。但“回調(diào)地獄”實(shí)際上與嵌套/縮進(jìn)幾乎無(wú)關(guān)。這是一個(gè)比這更深層次的問(wèn)題。
首先,我們等待“click”事件,然后等待計(jì)時(shí)器觸發(fā),然后等待 Ajax 響應(yīng)返回,此時(shí)可能會(huì)再次重復(fù)。
乍一看,這段代碼似乎將其異步自然地映射到順序步驟,例如:
listen('click', function (e) {
// ..
});
然后
setTimeout(function(){
// ..
}, 500);
接著
ajax('https://api.example.com/endpoint', function (text){
// ..
});
最后
if (text == "hello") {
doSomething();
}
else if (text == "world") {
doSomethingElse();
}
所以,這種表達(dá)異步代碼的順序方式似乎更自然,不是嗎?
Promises
看下面的代碼:
var x = 1;
var y = 2;
console.log(x + y);
非常簡(jiǎn)單:它將 x 和 y 的值相加并將其打印到控制臺(tái)。但是,如果 x 或 y 的值丟失并且仍有待確定怎么辦?比如說(shuō),我們需要從服務(wù)器檢索 x 和 y 的值,然后才能在表達(dá)式中使用它們。假設(shè)我們有一個(gè)函數(shù) loadX 和 loadY 分別從服務(wù)器加載 x 和 y 的值。然后,假設(shè)我們有一個(gè) sum 函數(shù),一旦加載了 x 和 y 的值,它們就會(huì)相加。
它可能看起來(lái)像這樣(很丑,不是嗎):
function sum(getX, getY, callback) {
var x, y;
getX(function(result) {
x = result;
if (y !== undefined) {
callback(x + y);
}
});
getY(function(result) {
y = result;
if (x !== undefined) {
callback(x + y);
}
});
}
// A sync or async function that retrieves the value of `x`
function fetchX() {
// ..
}
// A sync or async function that retrieves the value of `y`
function fetchY() {
// ..
}
sum(fetchX, fetchY, function(result) {
console.log(result);
});
這里有一些非常重要的東西——在那個(gè)片段中,我們將 x 和 y 視為未來(lái)值,并且我們表達(dá)了一個(gè)操作 sum(...) (從外部)不關(guān)心 x 或 y 或兩者是否馬上可用。
當(dāng)然,這種粗略的基于回調(diào)的方法還有很多不足之處。這只是了解推理未來(lái)價(jià)值的好處的第一步,而不必?fù)?dān)心它們何時(shí)可用的時(shí)間方面。
Promise Value
讓我們簡(jiǎn)單地看一下如何用 Promises 表達(dá) x + y 示例:
function sum(xPromise, yPromise) {
// `Promise.all([ .. ])`同時(shí)發(fā)多個(gè)請(qǐng)求,返回一個(gè)新的Promise
return Promise.all([xPromise, yPromise])
//當(dāng)這個(gè)promise得到結(jié)束時(shí),讓接收到 `X` 和 `Y` 值相加
.then(function(values){
// `values` 是前面兩個(gè)請(qǐng)求返回的數(shù)組
return values[0] + values[1];
} );
}
sum(fetchX(), fetchY())
//等兩個(gè)值都返回以后執(zhí)行接下來(lái)的操作
.then(function(sum){
console.log(sum);
});
這個(gè)片段中有兩層 Promises。
第一層是fetchX() 和 fetchY() 被直接調(diào)用,第二層sum函數(shù)返回的值promises,這些promises可能是在現(xiàn)在或者未來(lái)被使用。
第二層是 sum(...) 創(chuàng)建的promise(通過(guò) Promise.all([ ... ])) 并返回,通過(guò)調(diào)用 then(...) 來(lái)等待。當(dāng) sum(...) 操作完成時(shí), sum 未來(lái)值就準(zhǔn)備好了,將其打印出來(lái)。在 sum(...) 中隱藏了等待 x 和 y 未來(lái)值的邏輯。
注意:在 sum(…) 中,Promise.all([ … ]) 調(diào)用創(chuàng)建了一個(gè) Promise(它正在等待 PromiseX 和 PromiseY 解決)。對(duì) .then(...) 的鏈?zhǔn)秸{(diào)用創(chuàng)建了另一個(gè)Promise,返回values[0] + values[1] 立即解析。因此,我們?cè)?sum(...) 調(diào)用的末尾使用 then(...) 調(diào)用,實(shí)際上是在返回的第二個(gè) promise 上運(yùn)行,而不是第一個(gè) Promise.all([ ... ])。此外,雖然我們沒(méi)有執(zhí)行 then(...),但如果我們選擇使用它,它也創(chuàng)造了另一個(gè)Promise。這個(gè) Promise 鏈的東西將在本章后面更詳細(xì)地解釋。
是否使用Promise?
關(guān)于 Promise 的一個(gè)重要細(xì)節(jié)是確定某個(gè)值是否是真正的 Promise。換句話說(shuō),它是一個(gè)表現(xiàn)得像 Promise 的值嗎?
我們知道 Promise 是由 new Promise(...) 語(yǔ)法構(gòu)造的,你可能認(rèn)為 p instanceof Promise 就足夠了。但不完全是。主要是因?yàn)榭梢詮牧硪粋€(gè)瀏覽器窗口(例如 iframe)接收 Promise 值,該值將具有自己的 Promise,與當(dāng)前窗口或框架中的不同,并且該檢查將無(wú)法識(shí)別 Promise 實(shí)例。
此外,庫(kù)或框架可以選擇出售自己的 Promise,而不是使用本機(jī) ES6 Promise 實(shí)現(xiàn)來(lái)這樣做。事實(shí)上,您很可能在完全沒(méi)有 Promise 的舊瀏覽器中將 Promises 與庫(kù)一起使用。
捕獲異常
如果在創(chuàng)建 Promise 或觀察其解決方案的任何時(shí)候,發(fā)生 JavaScript 異常錯(cuò)誤,例如 TypeError 或 ReferenceError,則該異常將被捕獲,并且將強(qiáng)制相關(guān) Promise 被拒絕。