JavaScript是如何工作的:事件循環(huán)和異步編程的崛起+ 5種使用 async/await 更好地編碼方式!
通過***篇文章回顧在單線程環(huán)境中編程的缺陷以及如何解決這些缺陷來構(gòu)建健壯的JavaScript UI。按照慣例,在本文的***,分享5個如何使用async/ wait編寫更簡潔代碼的技巧。
為什么單線程是一個限制?
在發(fā)布的***篇文章中,思考了這樣一個問題:當(dāng)調(diào)用堆棧中有函數(shù)調(diào)用需要花費大量時間來處理時會發(fā)生什么?
例如,假設(shè)在瀏覽器中運行一個復(fù)雜的圖像轉(zhuǎn)換算法。
當(dāng)調(diào)用堆棧有函數(shù)要執(zhí)行時,瀏覽器不能做任何其他事情--它被阻塞了。這意味著瀏覽器不能渲染,不能運行任何其他代碼,只是卡住了。那么你的應(yīng)用 UI 界面就卡住了,用戶體驗也就不那么好了。
在某些情況下,這可能不是主要的問題。還有一個更大的問題是一旦你的瀏覽器開始處理調(diào)用堆棧中的太多任務(wù),它可能會在很長一段時間內(nèi)停止響應(yīng)。這時,很多瀏覽器會拋出一個錯誤,提示是否終止頁面:

JavaScript程序的構(gòu)建塊
你可能在單個.js文件中編寫 JavaScript 應(yīng)用程序,但可以肯定的是,你的程序由幾個塊組成,其中只有一個正在執(zhí)行,其余的將在稍后執(zhí)行。最常見的塊單元是函數(shù)。
大多數(shù)剛接觸JavaScript的開發(fā)人員似乎都有這樣的問題,就是認為所有函數(shù)都是同步完成,沒有考慮的異步的情況。如下例子:

你可能知道標(biāo)準(zhǔn) Ajax 請求不是同步完成的,這說明在代碼執(zhí)行時Ajax(..)函數(shù)還沒有返回任何值來分配給變量response。
一種等待異步函數(shù)返回的結(jié)果簡單的方式就是回調(diào)函數(shù):

注意:實際上可以設(shè)置同步Ajax請求,但永遠不要那樣做。如果設(shè)置同步Ajax請求,應(yīng)用程序的界面將被阻塞--用戶將無法單擊、輸入數(shù)據(jù)、導(dǎo)航或滾動。這將阻止任何用戶交互,這是一種可怕的做法。
以下是同步 Ajax 地,但是請千萬不要這樣做:

這里使用Ajax請求作為示例,你可以讓任何代碼塊異步執(zhí)行。
這可以通過 setTimeout(callback,milliseconds) 函數(shù)來完成。setTimeout 函數(shù)的作用是設(shè)置一個回調(diào)函數(shù)milliseconds后執(zhí)行,如下:
- function first() {
- console.log('first');
- }
- function second() {
- console.log('second');
- }
- function third() {
- console.log('third');
- }
- first();
- setTimeout(second, 1000); // Invoke `second` after 1000ms
- third();
輸出:
- first
- third
- second
解析事件循環(huán)
這里從一個有點奇怪的聲明開始--盡管允許異步 JavaScript 代碼(就像上例討論的setTimeout),但在ES6之前,JavaScript本身實際上從來沒有任何內(nèi)置異步的概念,JavaScript引擎在任何給定時刻只執(zhí)行一個塊。
那么,是誰告訴JS引擎執(zhí)行程序的代碼塊呢?實際上,JS引擎并不是單獨運行的--它是在一個宿主環(huán)境中運行的,對于大多數(shù)開發(fā)人員來說,宿主環(huán)境就是典型的web瀏覽器或Node.js。實際上,現(xiàn)在JavaScript被嵌入到各種各樣的設(shè)備中,從機器人到燈泡,每個設(shè)備代表 JS 引擎的不同類型的托管環(huán)境。
所有環(huán)境中的共同點是一個稱為事件循環(huán)的內(nèi)置機制,它處理程序的多個塊在一段時間內(nèi)通過調(diào)用調(diào)用JS引擎的執(zhí)行。
這意味著JS引擎只是任意JS代碼的按需執(zhí)行環(huán)境,是宿主環(huán)境處理事件運行及結(jié)果。
例如,當(dāng) JavaScript 程序發(fā)出 Ajax 請求從服務(wù)器獲取一些數(shù)據(jù)時,在函數(shù)(“回調(diào)”)中設(shè)置“response”代碼,JS引擎告訴宿主環(huán)境:"我現(xiàn)在要推遲執(zhí)行,但當(dāng)完成那個網(wǎng)絡(luò)請求時,會返回一些數(shù)據(jù),請回調(diào)這個函數(shù)并給數(shù)據(jù)傳給它"。
然后瀏覽器將偵聽來自網(wǎng)絡(luò)的響應(yīng),當(dāng)監(jiān)聽到網(wǎng)絡(luò)請求返回內(nèi)容時,瀏覽器通過將回調(diào)函數(shù)插入事件循環(huán)來調(diào)度要執(zhí)行的回調(diào)函數(shù)。以下是示意圖:

這些Web api是什么?從本質(zhì)上說,它們是無法訪問的線程,只能調(diào)用它們。它們是瀏覽器的并發(fā)部分。如果你是一個Nojs.jsjs開發(fā)者,這些就是 c++ 的 Api。
這樣的迭代在事件循環(huán)中稱為(tick)標(biāo)記,每個事件只是一個函數(shù)回調(diào)。

讓我們“執(zhí)行”這段代碼,看看會發(fā)生什么:
1.初始化狀態(tài)都為空,瀏覽器控制臺是空的的,調(diào)用堆棧也是空的

2.console.log('Hi')添加到調(diào)用堆棧中

3. 執(zhí)行console.log('Hi')
4.console.log('Hi')從調(diào)用堆棧中移除。

5. setTimeout(function cb1() { ... }) 添加到調(diào)用堆棧。

6. setTimeout(function cb1() { ... }) 執(zhí)行,瀏覽器創(chuàng)建一個計時器計時,這個作為Web api的一部分。

7. setTimeout(function cb1() { ... })本身執(zhí)行完成,并從調(diào)用堆棧中刪除。

8. console.log('Bye') 添加到調(diào)用堆棧

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

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

11. 至少在5秒之后,計時器完成并將cb1回調(diào)推到回調(diào)隊列。

12. 事件循環(huán)從回調(diào)隊列中獲取cb1并將其推入調(diào)用堆棧。

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

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

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

16.cb1從調(diào)用堆棧中移除

快速回顧:

值得注意的是,ES6指定了事件循環(huán)應(yīng)該如何工作,這意味著在技術(shù)上它屬于JS引擎的職責(zé)范圍,不再僅僅扮演宿主環(huán)境的角色。這種變化的一個主要原因是ES6中引入了Promises,因為ES6需要對事件循環(huán)隊列上的調(diào)度操作進行直接、細度的控制。
setTimeout(…) 是怎么工作的
需要注意的是,setTimeout(…)不會自動將回調(diào)放到事件循環(huán)隊列中。它設(shè)置了一個計時器。當(dāng)計時器過期時,環(huán)境將回調(diào)放到事件循環(huán)中,以便將來某個標(biāo)記(tick)將接收并執(zhí)行它。請看下面的代碼:
- setTimeout(myCallback, 1000);
這并不意味著myCallback將在1000毫秒后就立馬執(zhí)行,而是在1000毫秒后,myCallback被添加到隊列中。但是,如果隊列有其他事件在前面添加回調(diào)剛必須等待前后的執(zhí)行完后在執(zhí)行myCallback。
有不少的文章和教程上開始使用異步JavaScript代碼,建議用setTimeout(回調(diào),0),現(xiàn)在你知道事件循環(huán)和setTimeout是如何工作的:調(diào)用setTimeout 0毫秒作為第二個參數(shù)只是推遲回調(diào)將它放到回調(diào)隊列中,直到調(diào)用堆棧是空的。
請看下面的代碼:
- console.log('Hi');
- setTimeout(function() {
- console.log('callback');
- }, 0);
- console.log('Bye');
雖然等待時間被設(shè)置為0 ms,但在瀏覽器控制臺的結(jié)果如下:
- Hi
- Bye
- callback
ES6的任務(wù)隊列是什么?
ES6中引入了一個名為“任務(wù)隊列”的概念。它是事件循環(huán)隊列上的一個層。最為常見在Promises處理的異步方式。
現(xiàn)在只討論這個概念,以便在討論帶有Promises的異步行為時,能夠了解 Promises 是如何調(diào)度和處理。
想像一下:任務(wù)隊列是一個附加到事件循環(huán)隊列中每個標(biāo)記末尾的隊列。某些異步操作可能發(fā)生在事件循環(huán)的一個標(biāo)記期間,不會導(dǎo)致一個全新的事件被添加到事件循環(huán)隊列中,而是將一個項目(即任務(wù))添加到當(dāng)前標(biāo)記的任務(wù)隊列的末尾。
這意味著可以放心添加另一個功能以便稍后執(zhí)行,它將在其他任何事情之前立即執(zhí)行。
任務(wù)還可能創(chuàng)建更多任務(wù)添加到同一隊列的末尾。理論上,任務(wù)“循環(huán)”(不斷添加其他任務(wù)的任等等)可以***運行,從而使程序無法獲得轉(zhuǎn)移到下一個事件循環(huán)標(biāo)記的必要資源。從概念上講,這類似于在代碼中表示長時間運行或***循環(huán)(如while (true) ..)。
任務(wù)有點像 setTimeout(callback, 0) “hack”,但其實現(xiàn)方式是引入一個定義更明確、更有保證的順序:稍后,但越快越好。
回調(diào)
正如你已經(jīng)知道的,回調(diào)是到目前為止JavaScript程序中表達和管理異步最常見的方法。實際上,回調(diào)是JavaScript語言中最基本的異步模式。無數(shù)的JS程序,甚至是非常復(fù)雜的程序,除了一些基本都是在回調(diào)異步基礎(chǔ)上編寫的。
然而回調(diào)方式還是有一些缺點,許多開發(fā)人員都在試圖找到更好的異步模式。但是,如果不了解底層的內(nèi)容,就不可能有效地使用任何抽象出來的異步模式。
在下一章中,我們將深入探討這些抽象,以說明為什么更復(fù)雜的異步模式(將在后續(xù)文章中討論)是必要的,甚至是值得推薦的。
嵌套回調(diào)
請看以下代碼:

我們有一個由三個函數(shù)組成的鏈嵌套在一起,每個函數(shù)表示異步系列中的一個步驟。
這種代碼通常被稱為“回調(diào)地獄”。但是“回調(diào)地獄”實際上與嵌套/縮進幾乎沒有任何關(guān)系,這是一個更深層次的問題。
首先,我們等待“單擊”事件,然后等待計時器觸發(fā),然后等待Ajax響應(yīng)返回,此時可能會再次重復(fù)所有操作。
乍一看,這段代碼似乎可以將其異步性自然地對應(yīng)到以下順序步驟:
- listen('click', function (e) {
- // ..
- });
然后:
- setTimeout(function(){
- // ..
- }, 500);
接著:
- ajax('https://api.example.com/endpoint', function (text){
- // ..
- });
***:
- if (text == "hello") {
- doSomething();
- }
- else if (text == "world") {
- doSomethingElse();
- }
因此,這種連續(xù)的方式來表示異步代碼似乎更自然,不是嗎?一定有這樣的方法,對吧?
Promises
請看下面的代碼:
- var x = 1;
- var y = 2;
- console.log(x + y);
這非常簡單:它對x和y的值進行求和,并將其打印到控制臺。但是,如果x或y的值丟失了,仍然需要求值,要怎么辦?
例如,需要從服務(wù)器取回x和y的值,然后才能在表達式中使用它們。假設(shè)我們有一個函數(shù)loadX和loadY`,它們分別從服務(wù)器加載x和y的值。然后,一旦x和y都被加載,假設(shè)我們有一個函數(shù)sum,它對x和y的值進行求和。
它可能看起來像這樣(很丑,不是嗎?)

這里有一些非常重要的事情--在這個代碼片段中,我們將x和y作為異步獲取的的值,并且執(zhí)行了一個函數(shù)sum(…)(從外部),它不關(guān)心x或y,也不關(guān)心它們是否立即可用。
當(dāng)然,這種基于回調(diào)的粗略方法還有很多不足之處。 這只是一個我們不必判斷對于異步請求的值的處理方式一個小步驟而已。
Promise Value
用Promise來重寫上例:

在這個代碼片段中有兩層Promise。
fetchX和fetchY先直接調(diào)用,返回一個promise,傳給sum。sum創(chuàng)建并返回一個Promise,通過調(diào)用 then 等待 Promise,完成后,sum 已經(jīng)準(zhǔn)備好了(resolve),將會打印出來。
第二層是sum(…)創(chuàng)建的 Promise ( 通過 Promise.all([ ... ]) )然后返回 Promise,通過調(diào)用then(…)來等待。當(dāng)sum(…)操作完成時,sum 傳入的兩個 Promise 都執(zhí)行完后,可以打印出來了。這里隱藏了在sum(…)中等待x和y未來值的邏輯。
注意:在sum(...)內(nèi),Promise.all([...])調(diào)用創(chuàng)建一個 promise(等待 promiseX 和 promiseY 解析)。 然后鏈?zhǔn)秸{(diào)用 .then(...)方法里再的創(chuàng)建了另一個 Promise,然后把 返回的 x 和 和(values[0] + values[1]) 進行求和 并返回 。
因此,我們在sum(...)末尾調(diào)用then(...)方法 - 實際上是在返回的第二個 Pwwromise 上運行,而不是由Promise.all([ ... ])創(chuàng)建 Promise。 此外,雖然沒有在第二個 Promise 結(jié)束時再調(diào)用 then方法 ,其時這里也創(chuàng)建一個 Promise。
Promise.then(…) 實際上可以使用兩個函數(shù),***個函數(shù)用于執(zhí)行成功的操作,第二個函數(shù)用于處理失敗的操作:
如果在獲取x或y時出現(xiàn)錯誤,或者在添加過程中出現(xiàn)某種失敗,sum(…)返回的 Promise將被拒絕,傳遞給 then(…) 的第二個回調(diào)錯誤處理程序?qū)?Promise 接收失敗的信息。
從外部看,由于 Promise 封裝了依賴于時間的狀態(tài)(等待底層值的完成或拒絕,Promise 本身是與時間無關(guān)的),它可以按照可預(yù)測的方式組成,不需要開發(fā)者關(guān)心時序或底層的結(jié)果。一旦 Promise 決議,此刻它就成為了外部不可變的值。
可鏈接調(diào)用 Promise 真的很有用:
創(chuàng)建一個延遲2000ms內(nèi)完成的 Promise ,然后我們從***個then(...)回調(diào)中返回,這會導(dǎo)致第二個then(...)等待 2000ms。
注意:因為Promise 一旦被解析,它在外部是不可變的,所以現(xiàn)在可以安全地將該值傳遞給任何一方,因為它不能被意外地或惡意地修改,這一點在多方遵守承諾的決議時尤其正確。一方不可能影響另一方遵守承諾決議的能力,不變性聽起來像是一個學(xué)術(shù)話題,但它實際上是承諾設(shè)計最基本和最重要的方面之一,不應(yīng)該被隨意忽略。
使用 Promise 還是不用?
關(guān)于 Promise 的一個重要細節(jié)是要確定某個值是否是一個實際的Promise 。換句話說,它是否具有像Promise 一樣行為?
我們知道 Promise 是由new Promise(…)語法構(gòu)造的,你可能認為pinstanceof Promise是一個足夠可以判斷的類型,嗯,不完全是。
這主要是因為可以從另一個瀏覽器窗口(例如iframe)接收 Promise 值,而該窗口或框架具有自己的 Promise 值,與當(dāng)前窗口或框架中的 Promise 值不同,所以該檢查將無法識別 Promise 實例。
此外,庫或框架可以選擇性的封裝自己的 Promise,而不使用原生 ES6 的Promise 來實現(xiàn)。事實上,很可能在老瀏覽器的庫中沒有 Promise。
吞掉錯誤或異常
如果在 Promise 創(chuàng)建中,出現(xiàn)了一個javascript一場錯誤(TypeError 或者 ReferenceError),這個異常會被捕捉,并且使這個 promise 被拒絕。
但是,如果在調(diào)用 then(…) 方法中出現(xiàn)了 JS 異常錯誤,那么會發(fā)生什么情況呢?即使它不會丟失,你可能會發(fā)現(xiàn)它們的處理方式有點令人吃驚,直到你挖得更深一點:

看起來foo.bar()中的異常確實被吞噬了,不過,它不是。然而,還有一些更深層次的問題,我們沒有注意到。 p.then(…) 調(diào)用本身返回另一個 Promise,該 Promise 將被 TypeError 異常拒絕。
處理未捕獲異常
許多人會說,還有其他更好的方法。
一個常見的建議是,Promise 應(yīng)該添加一個done(…),這實際上是將 Promise 鏈標(biāo)記為“done”。done(…) 不會創(chuàng)建并返回 Promise ,因此傳遞給 done(..) 的回調(diào)顯然不會將問題報告給不存在的鏈接 Promise 。
Promise 對象的回調(diào)鏈,不管以 then 方法或 catch 方法結(jié)尾,要是***一個方法拋出錯誤,都有可能無法捕捉到(因為 Promise 內(nèi)部的錯誤不會冒泡到全局)。因此,我們可以提供一個 done 方法,總是處于回調(diào)鏈的尾端,保證拋出任何可能出現(xiàn)的錯誤。

ES8中改進了什么 ?Async/await (異步/等待)
JavaScript ES8引入了async/await,這使得使用 Promise 的工作更容易。這里將簡要介紹async/await 提供的可能性以及如何利用它們編寫異步代碼。
使用 async 聲明異步函數(shù)。這個函數(shù)返回一個AsyncFunction對象。AsyncFunction 對象表示該函數(shù)中包含的代碼的異步函數(shù)。
調(diào)用使用 async 聲明函數(shù)時,它返回一個 Promise。當(dāng)這個函數(shù)返回一個值時,這個值只是一個普通值而已,這個函數(shù)內(nèi)部將自動創(chuàng)建一個承諾,并使用函數(shù)返回的值進行解析。當(dāng)這個函數(shù)拋出異常時,Promise 將被拋出的值拒絕。
使用 async 聲明函數(shù)時可以包含一個 await 符號,await 暫停這個函數(shù)的執(zhí)行并等待傳遞的 Promise 的解析完成,然后恢復(fù)這個函數(shù)的執(zhí)行并返回解析后的值。
async/wait 的目的是簡化使用承諾的行為
讓看看下面的例子:
- function getNumber1() {
- return Promise.resolve('374');
- }
- // 這個函數(shù)與getNumber1相同
- async function getNumber2() {
- return 374;
- }
類似地,拋出異常的函數(shù)等價于返回被拒絕的 Promise 的函數(shù):
- function f1() {
- return Promise.reject('Some error');
- }
- async function f2() {
- throw 'Some error';
- }
await關(guān)鍵字只能在異步函數(shù)中使用,并允許同步等待 Promise。如果在 async 函數(shù)之外使用 Promise,仍然需要使用 then 回調(diào):

還可以使用“異步函數(shù)表達式”定義異步函數(shù)。異步函數(shù)表達式與異步函數(shù)語句非常相似,語法也幾乎相同。異步函數(shù)表達式和異步函數(shù)語句之間的主要區(qū)別是函數(shù)名,可以在異步函數(shù)表達式中省略函數(shù)名來創(chuàng)建匿名函數(shù)。異步函數(shù)表達式可以用作生命(立即調(diào)用的函數(shù)表達式),一旦定義它就會運行。
- var loadData = async function() {
- // `rp` is a request-promise function.
- var promise1 = rp('https://api.example.com/endpoint1');
- var promise2 = rp('https://api.example.com/endpoint2');
- // Currently, both requests are fired, concurrently and
- // now we'll have to wait for them to finish
- var response1 = await promise1;
- var response2 = await promise2;
- return response1 + ' ' + response2;
- }
更重要的是,在所有主流的瀏覽器都支持 async/await:

***,重要的是不要盲目選擇編寫異步代碼的“***”方法。理解異步 JavaScript 的內(nèi)部結(jié)構(gòu)非常重要,了解為什么異步JavaScript如此關(guān)鍵,并深入理解所選擇的方法的內(nèi)部結(jié)構(gòu)。與編程中的其他方法一樣,每種方法都有優(yōu)點和缺點。
編寫高度可維護性、非易碎異步代碼的5個技巧
1、簡介代碼: 使用 async/await 可以編寫更少的代碼。 每次使用 async/await時,都會跳過一些不必要的步驟:使用.then,創(chuàng)建一個匿名函數(shù)來處理響應(yīng),例如:
- // rp是一個請求 Promise 函數(shù)。
- rp(‘https://api.example.com/endpoint1').then(function(data) {
- // …
- });
和:
- // `rp` is a request-promise function.
- var response = await rp(‘https://api.example.com/endpoint1');
2、錯誤處理: Async/wait 可以使用相同的代碼結(jié)構(gòu)(眾所周知的try/catch語句)處理同步和異步錯誤。看看它是如何與 Promise 結(jié)合的:
- function loadData() {
- try { // Catches synchronous errors.
- getJSON().then(function(response) {
- var parsed = JSON.parse(response);
- console.log(parsed);
- }).catch(function(e) { // Catches asynchronous errors
- console.log(e);
- });
- } catch(e) {
- console.log(e);
- }
- }
與:
- async function loadData() {
- try {
- var data = JSON.parse(await getJSON());
- console.log(data);
- } catch(e) {
- console.log(e);
- }
- }
3、條件:用async/ wait編寫條件代碼要簡單得多:
- function loadData() {
- return getJSON()
- .then(function(response) {
- if (response.needsAnotherRequest) {
- return makeAnotherRequest(response)
- .then(function(anotherResponse) {
- console.log(anotherResponse)
- return anotherResponse
- })
- } else {
- console.log(response)
- return response
- }
- })
- }
與:
- async function loadData() {
- var response = await getJSON();
- if (response.needsAnotherRequest) {
- var anotherResponse = await makeAnotherRequest(response);
- console.log(anotherResponse)
- return anotherResponse
- } else {
- console.log(response);
- return response;
- }
- }
4、堆棧幀:與 async/await不同,從 Promise 鏈返回的錯誤堆棧不提供錯誤發(fā)生在哪里。看看下面這些:
- function loadData() {
- return callAPromise()
- .then(callback1)
- .then(callback2)
- .then(callback3)
- .then(() => {
- throw new Error("boom");
- })
- }
- loadData()
- .catch(function(e) {
- console.log(err);
- // Error: boom at callAPromise.then.then.then.then (index.js:8:13)
- });
與:
- async function loadData() {
- await callAPromise1()
- await callAPromise2()
- await callAPromise3()
- await callAPromise4()
- await callAPromise5()
- throw new Error("boom");
- }
- loadData()
- .catch(function(e) {
- console.log(err);
- // output
- // Error: boom at loadData (index.js:7:9)
- });
5.調(diào)試:如果你使用過 Promise ,那么你知道調(diào)試它們是一場噩夢。例如,如果在一個程序中設(shè)置了一個斷點,然后阻塞并使用調(diào)試快捷方式(如“停止”),調(diào)試器將不會移動到下面,因為它只“逐步”執(zhí)行同步代碼。使用async/wait,您可以逐步完成wait調(diào)用,就像它們是正常的同步函數(shù)一樣。
編輯中可能存在的bug沒法實時知道,事后為了解決這些bug,花了大量的時間進行l(wèi)og 調(diào)試,這邊順便給大家推薦一個好用的BUG監(jiān)控工具Fundebug。