[譯] JavaScript 如何工作的: 事件迴圈和非同步程式設計的崛起 + 5 個關於如何使用 async/await 編寫更好的技巧

小烜同學發表於2017-12-02

歡迎來到旨在探索 JavaScript 以及它的核心元素的系列文章的第四篇。在認識、描述這些核心元素的過程中,我們也會分享一些當我們構建 SessionStack 的時候遵守的一些經驗規則,一個 JavaScript 應用應該保持健壯和高效能來維持競爭力。

如果你錯過了前三章可以在這兒找到它們:

  1. 對引擎、執行時和呼叫棧的概述
  2. 深入 V8 引擎以及 5 個寫出更優程式碼的技巧
  3. 記憶體管理以及四種常見的記憶體洩漏的解決方法

這次我們將展開第一篇文章的內容,回顧一下在單執行緒環境中程式設計的缺點,以及如何克服它們來構建出色的 JavaScript UI。按照慣例,在文章的末尾我們將分享 5 個如何使用 async/await 寫出更簡潔的程式碼的技巧。

為什麼單執行緒會限制我們?

第一篇文章 中, 我們思考了一個問題 當呼叫棧中的函式呼叫需要花費我們非常多的時間,會發生什麼?

比如,想象一下你的瀏覽器現在正在執行一個複雜的影象轉換的演算法。

當呼叫棧有函式在執行,瀏覽器就不能做任何事了 —— 它被阻塞了。這意味著瀏覽器不能渲染頁面,不能執行任何其它的程式碼,它就這樣被卡住了。那麼問題來了 —— 你的應用不再高效和令人滿意了。

你的應用卡住了

在某些情況下,這可能不是一個很嚴重的問題。但這其實是一個更大的問題。一旦你的瀏覽器開始在呼叫棧執行很多很多的任務,它就很有可能會長時間得不到響應。在這一點上,大多數的瀏覽器會採取丟擲錯誤的解決方案,詢問你是否要終止這個頁面:

它很醜,並且它會毀了你的使用者體驗:

[譯] JavaScript 如何工作的: 事件迴圈和非同步程式設計的崛起 + 5 個關於如何使用 async/await 編寫更好的技巧

JavaScript 程式的單元塊

你可能會將你的 JavaScript 程式碼寫在一個 .js 檔案中,但你的程式一定是由幾個程式碼塊組成的,而且只有一個能夠 現在 執行,其餘的都會在 之後 執行。最常見的單元塊就是函式。

JavaScript 開發的新手最不能理解的就是 之後 的程式碼並不一定會在 現在 的程式碼執行之後執行。換句話說,在定義中不能 現在 立刻完成的任務將會非同步執行,這意味著可能不會像你認為的那樣發生上面所說的阻塞問題。

讓我們來看看下面的例子:

// ajax(..) 是任意庫提供的任意一個 Ajax 的函式
var response = ajax('https://example.com/api');

console.log(response);
// `response` 不會是響應的 response,因為 Ajax 是非同步的
複製程式碼

你可能已經意識到了,標準的 Ajax 請求不會同步發生,這意味著在程式碼執行的時候,ajax(..) 函式在沒有任何返回值之前,是不會賦值給 response 變數的。

有一個簡單的辦法去 “等待” 非同步函式返回它的結果,就是使用 回撥函式

ajax('https://example.com/api', function(response) {
    console.log(response); // `response` 現在是有值的
});
複製程式碼

注意:雖然實際上是可以 同步 實現 Ajax 請求的,但是最好永遠都不要這麼做。如果你使用了同步的 Ajax 請求,你的 JavaScript 應用就會被阻塞 —— 使用者就不能點選、輸入資料、導航或是滾動。這將會阻止使用者的任何互動動作。這是一種非常糟糕的做法。

這就是使用同步的樣子,但是千萬不要這麼做,不要毀了你的 web 應用:

// 假設你正在使用 jQuery
jQuery.ajax({
    url: 'https://api.example.com/endpoint',
    success: function(response) {
        // 這是你的回撥
    },
    async: false // 這是一個壞主意
});
複製程式碼

我們使用 Ajax 請求只是一個例子。事實上你可以非同步執行任何程式碼。

setTimeout(callback, milliseconds) 也能夠非同步執行。setTimeout 函式所做的就是設定了一個事件(超時)等待觸發執行。我們來看一看:

function first() {
    console.log('first');
}
function second() {
    console.log('second');
}
function third() {
    console.log('third');
}
first();
setTimeout(second, 1000); // 1000ms 後呼叫 `second`
third();
複製程式碼

console 列印出來將會是下面這樣的:

first
third
second
複製程式碼

解析事件迴圈

我們先從一個奇怪的說法談起 —— 儘管 JavaScript 允許非同步的程式碼(就像是我們剛剛說的 setTimeout) ,但直到 ES6,JavaScript 自身從未有過任何關於非同步的直接概念。JavaScript 引擎只會在任意時刻執行一個程式。

關於 JavaScript 引擎是如何工作的更多細節(特別是 V8 引擎)請看我們的前一章

那麼,誰會告訴 JS 引擎去執行你的程式?事實上,JS 引擎不是單獨執行的 —— 它執行在一個宿主環境中,對於大多數開發者來說就是典型的瀏覽器和 Node.js。實際上,如今,JavaScript 被應用到了從機器人到燈泡的各種裝置上。每個裝置都代表了一種不同型別的 JS 引擎的宿主環境。

所有的環境都有一個共同點,就是都擁有一個 事件迴圈 的內建機制,它隨著時間的推移每次都去呼叫 JS 引擎去處理程式中多個塊的執行。

這意味著 JS 引擎只是任意的 JS 程式碼按需執行的環境。是它周圍的環境來排程這些事件(JS 程式碼執行)。

所以,比如當你的 JavaScript 程式發出了一個 Ajax 請求去伺服器獲取資料,你在一個函式(回撥)中寫了 “response” 程式碼,然後 JS 引擎就會告訴宿主環境: “嘿,我現在要暫停執行了,但是當你完成了這個網路請求,並且獲取到資料的時候,請回來呼叫這個函式。”

然後瀏覽器設定對網路響應的監聽,當它有東西返回給你的時候,它將會把回撥函式插入到事件迴圈佇列裡然後執行。

我們來看下面的圖:

[譯] JavaScript 如何工作的: 事件迴圈和非同步程式設計的崛起 + 5 個關於如何使用 async/await 編寫更好的技巧

你可以在前一章瞭解到更多關於記憶體堆和呼叫棧的知識。

那圖中的這些 Web API 是什麼東西呢?從本質上講,它們是你無法訪問的執行緒,但是你能夠呼叫它們。它們是瀏覽器並行啟動的一部分。如果你是一個 Node.js 的開發者,這些就是 C++ 的一些 API。

事件迴圈 究竟是什麼?

[譯] JavaScript 如何工作的: 事件迴圈和非同步程式設計的崛起 + 5 個關於如何使用 async/await 編寫更好的技巧

事件迴圈有一個簡單的任務 —— 去監控呼叫棧和回撥佇列。如果呼叫棧是空的,它就會取出佇列中的第一個事件,然後將它壓入到呼叫棧中,然後執行它。

這樣的迭代在事件迴圈中被稱作一個 tick。每一個事件就是一個回撥函式。

console.log('Hi');
setTimeout(function cb1() { 
    console.log('cb1');
}, 5000);
console.log('Bye');
複製程式碼

讓我們執行一下這段程式碼,看看會發生什麼:

  1. 狀態是乾淨的。瀏覽器 console 是乾淨的,並且呼叫棧是空的。

[譯] JavaScript 如何工作的: 事件迴圈和非同步程式設計的崛起 + 5 個關於如何使用 async/await 編寫更好的技巧

  1. console.log('Hi') 被新增到了呼叫棧裡。

[譯] JavaScript 如何工作的: 事件迴圈和非同步程式設計的崛起 + 5 個關於如何使用 async/await 編寫更好的技巧

  1. console.log('Hi') 被執行。

[譯] JavaScript 如何工作的: 事件迴圈和非同步程式設計的崛起 + 5 個關於如何使用 async/await 編寫更好的技巧

  1. console.log('Hi') 被移出呼叫棧。

[譯] JavaScript 如何工作的: 事件迴圈和非同步程式設計的崛起 + 5 個關於如何使用 async/await 編寫更好的技巧

  1. setTimeout(function cb1() { ... }) 被新增到呼叫棧。

[譯] JavaScript 如何工作的: 事件迴圈和非同步程式設計的崛起 + 5 個關於如何使用 async/await 編寫更好的技巧

  1. setTimeout(function cb1() { ... }) 執行。瀏覽器建立了一個定時器(Web API 的一部分),並且開始倒數計時。

[譯] JavaScript 如何工作的: 事件迴圈和非同步程式設計的崛起 + 5 個關於如何使用 async/await 編寫更好的技巧

  1. setTimeout(function cb1() { ... }) 本身執行完了,然後被移出呼叫棧。

[譯] JavaScript 如何工作的: 事件迴圈和非同步程式設計的崛起 + 5 個關於如何使用 async/await 編寫更好的技巧

  1. console.log('Bye') 被新增到呼叫棧。

[譯] JavaScript 如何工作的: 事件迴圈和非同步程式設計的崛起 + 5 個關於如何使用 async/await 編寫更好的技巧

  1. console.log('Bye') 執行。

[譯] JavaScript 如何工作的: 事件迴圈和非同步程式設計的崛起 + 5 個關於如何使用 async/await 編寫更好的技巧

  1. console.log('Bye') 被移出呼叫棧。

[譯] JavaScript 如何工作的: 事件迴圈和非同步程式設計的崛起 + 5 個關於如何使用 async/await 編寫更好的技巧

  1. 在至少 5000ms 過後,定時器完成,然後將回撥 cb1 壓入到回撥佇列。

[譯] JavaScript 如何工作的: 事件迴圈和非同步程式設計的崛起 + 5 個關於如何使用 async/await 編寫更好的技巧

  1. 事件迴圈從回撥佇列取走 cb1,然後把它壓入呼叫棧。

[譯] JavaScript 如何工作的: 事件迴圈和非同步程式設計的崛起 + 5 個關於如何使用 async/await 編寫更好的技巧

  1. cb1 被執行,然後把 console.log('cb1') 壓入呼叫棧。

[譯] JavaScript 如何工作的: 事件迴圈和非同步程式設計的崛起 + 5 個關於如何使用 async/await 編寫更好的技巧

  1. console.log('cb1') 被執行。

[譯] JavaScript 如何工作的: 事件迴圈和非同步程式設計的崛起 + 5 個關於如何使用 async/await 編寫更好的技巧

  1. console.log('cb1') 被移出呼叫棧。

[譯] JavaScript 如何工作的: 事件迴圈和非同步程式設計的崛起 + 5 個關於如何使用 async/await 編寫更好的技巧

  1. cb1 被移出呼叫棧。

[譯] JavaScript 如何工作的: 事件迴圈和非同步程式設計的崛起 + 5 個關於如何使用 async/await 編寫更好的技巧

快速回顧一下:

[譯] JavaScript 如何工作的: 事件迴圈和非同步程式設計的崛起 + 5 個關於如何使用 async/await 編寫更好的技巧

有趣的是,ES6 指定了事件迴圈該如何工作,這意味著在技術上它屬於 JS 引擎的職責範圍了,不再是宿主環境的一部分了。造成這種變化的一個主要原因是在 ES6 中引入了 promise,因為後者需要對事件迴圈佇列的排程操作進行直接的、細微的控制(後面我們會詳細的討論它們)。

setTimeout(…) 是如何工作的

需要重點注意的是 setTimeout(…) 不會自動的把你的回撥放到事件迴圈佇列中。它設定了一個定時器。當定時器過期了,宿主環境會將你的回撥放到事件迴圈佇列中,以便在以後的迴圈中取走執行它。看看下面的程式碼:

setTimeout(myCallback, 1000);
複製程式碼

這並不意味著 myCallback 將會在 1,000ms 之後執行,而是,在 1,000ms 之後將被新增到事件佇列。然而,這個佇列中可能會擁有一些早一點新增進來的事件 —— 你的回撥將會等待被執行。

有很多文章或教程在介紹非同步程式碼的時候都會從 setTimeout(callback, 0) 開始。好了,現在你知道了事件迴圈做了什麼以及 setTimeout 是怎麼執行的:以第二個引數是 0 的方式呼叫 setTimeout 就是推遲到呼叫棧為空才執行回撥。

來看看下面的程式碼:

console.log('Hi');
setTimeout(function() {
    console.log('callback');
}, 0);
console.log('Bye');
複製程式碼

儘管等待的事件設定成 0 了,但是瀏覽器 console 的結果將會是下面這樣:

Hi
Bye
callback
複製程式碼

ES6 中的作業(Jobs)是什麼?

ES6 中介紹了一種叫 “作業佇列(Job Queue)” 的新概念。它是事件迴圈佇列之上的一層。你很有可能會在處理 Promises 的非同步的時候遇到它(我們後面也會討論到它們)。

我們現在只簡單介紹一下這個概念,以便當我們討論 Promises 的非同步行為的時候,你能理解這些行為是如何被排程和處理的。

想象一下:作業佇列是一個跟在事件佇列的每個 tick 的末尾的一個佇列。在事件迴圈佇列的一個 tick 期間可能會發生某些非同步操作,這不會導致把一整個新事件新增到事件迴圈佇列中,而是會在當前 tick 的作業佇列的末尾新增一項(也就是作業)。

這意味著你可以新增一個稍後執行的功能,並且你可以放心,它會在執行任何其他操作之前執行。

作業還能夠使更多的作業被新增到同一個佇列的末尾。從理論上說,一個作業的“迴圈”(一個不停的新增其他作業的作業,等等)可能會無限迴圈,從而使進入下一個事件迴圈 tick 的程式的必要資源被消耗殆盡。從概念上講,這就和你寫了一個長時間執行的程式碼或是死迴圈(就像是 while (true))一樣。

作業有點像 setTimeout(callback, 0) 的“hack”,但是它們引入了一個更加明確、更有保證的執行順序:稍後執行,但是會盡快執行。

回撥

眾所周知,在 JavaScript 程式中,回撥是表達和管理非同步目前最常用的方式。確實,回撥是 JavaScript 中最基礎的非同步模式。無數的 JS 程式,甚至是非常複雜的 JS 程式,都是使用回撥作為非同步的基礎。

回撥也不是沒有缺點。許多開發者都嘗試去找到更好的非同步模式。但是,如果你不理解底層的實際情況,你是不可能有效的去使用任何抽象化的東西。

在下一章中,我們將深入挖掘這些抽象的概念來說明為什麼更復雜的非同步模式(將會在後續的帖子中討論)是必須的甚至是被推薦的。

巢狀回撥

看看下面的程式碼:

listen('click', function (e){
    setTimeout(function(){
        ajax('https://api.example.com/endpoint', function (text){
            if (text == "hello") {
	        doSomething();
	    }
	    else if (text == "world") {
	        doSomethingElse();
            }
        });
    }, 500);
});
複製程式碼

我們有一個三個函式巢狀在一起的函式鏈,每一步都代表非同步序列中的一步。

這種程式碼我們把它叫做“回撥地獄”。但是“回撥地獄”顯然和巢狀/縮排沒有關係。這是個更深層次的問題了。

首先,我們在等待一個“click”事件,然後等待定時器觸發,再然後等著 Ajax 的響應返回,在這點上可能會再次重複。

乍一看,這個程式碼似乎可以分解成連續的幾個步驟:

listen('click', function (e) {
	// ..
});
複製程式碼

然後:


setTimeout(function(){
    // ..
}, 500);
複製程式碼

再然後:

ajax('https://api.example.com/endpoint', function (text){
    // ..
});
複製程式碼

最後:

if (text == "hello") {
    doSomething();
}
else if (text == "world") {
    doSomethingElse();
}
複製程式碼

所以,用這樣一種順序的方式來表達你的非同步程式碼是不是看起來更自然一些了?一定會有方法做到這一點,不是嗎?

Promises

看看下面的程式碼:

var x = 1;
var y = 2;
console.log(x + y);
複製程式碼

這是段簡單的程式碼:它對 xy 求和,然後在控制檯列印出來。但,假如 x 或是 y 的值是待確定的呢?比如說,我們需要在使用這兩個值之前去伺服器檢索 xy 的值。然後,有兩個函式 loadXloadY,分別從伺服器獲取 xy 的值。最後,函式 sum 來將獲取到的 xy 的值加起來。

看起來就是這樣的(相當醜,不是嗎?):

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);
        }
    });
}
// 一個同步或者非同步的函式,獲取 `x` 的值
function fetchX() {
    // ..
}


// 一個同步或者非同步的函式,獲取 `y` 的值
function fetchY() {
    // ..
}
sum(fetchX, fetchY, function(result) {
    console.log(result);
});
複製程式碼

這裡面的關鍵點在於 — 這段程式碼中,xy未來 的值,然後我們還寫了一個 sum(…) 函式,並且從外面看它並不關心 x 或者 y 現在是不是可用的。

當然,這種基於回撥的方式是粗糙的並且有很多不足。這只是初步理解 未來值 以及不需要去擔心它們什麼時候可用的第一步。

Promise 值

讓我們看一下這個簡短的例子是如何用 Promises 來表達 x + y 的:

function sum(xPromise, yPromise) {
	// `Promise.all([ .. ])` 接受一個 promises 的陣列,
	// 並且返回一個新的 promise 物件去等待它們
	// 全部完成
	return Promise.all([xPromise, yPromise])

	// 當 promise 完成的時候,我們就能獲取
	// `X` and `Y` 的值,並且計算他們
	.then(function(values){
		// `values` 是一個來自前面完成的 promise
		// 的訊息陣列
		return values[0] + values[1];
	} );
}

// `fetchX()` and `fetchY()` 返回 promises 的值,有他們各自的
// 值,或許*現在* 已經準備好了
// 也可能要 *等一會兒*。
sum(fetchX(), fetchY())

// 我們從返回的 promise 得到了這
// 兩個數字的和。
// 現在我們連續的呼叫了 `then(...)` 去等待已經完成的
// promise。
.then(function(sum){
    console.log(sum);
});
複製程式碼

這段程式碼可以看到兩層 Promises。

fetchX()fetchY() 被直接呼叫,然後他們的返回值(promises!)被傳給 sum(...)。這些 promises 代表的值可能在 現在 或是 將來 準備好,但每個 promise 的自身規範都是相同的。我們以一種與時間無關的方式來解釋 xy 的值。它們在一段時間內是 未來值

第二層 promise 是 sum(...) 建立 (通過 Promise.all([ ... ])) 並返回的,我們通過呼叫 then(...) 來等待返回。當 sum(...) 操作完成的時候,未來值 的總和也就準備就緒了,然後就可以把值列印出來了。我們隱藏了在 sum(...) 函式內部等待 xy未來值 的邏輯。

注意:在 sum(…) 函式中,Promise.all([ … ]) 建立了一個 promise (這個 promise 等待 promiseX and promiseY 的完成)。鏈式呼叫 .then(...) 來建立另一個 promise,返回的 values[0] + values[1] 會立即執行完成(還要加上加運算的結果)。因此,我們在 sum(...) 呼叫結束後加上的 then(...) — 在上面程式碼的末尾 — 實際上是在第二個 promise 返回後執行,而不是第一個 Promise.all([ ... ]) 建立的 promise。還有,儘管我們沒有在第二個 then(...) 後面再進行鏈式呼叫,但是它也建立了一個 promise,我們可以去觀察或是使用它。關於 Promise 的鏈式呼叫會在後面詳細地解釋。

使用 Promises,這個 then(...) 的呼叫其實有兩個方法,第一個方法被呼叫的時機是在已完成的時候 (就像我們前面使用的那樣),而另一個被呼叫的時機是已失敗的時候:

sum(fetchX(), fetchY())
.then(
    // 完成時
    function(sum) {
        console.log( sum );
    },
    // 失敗時
    function(err) {
    	console.error( err ); // bummer!
    }
);
複製程式碼

如果在獲取 x 或者 y 的時候出錯了,又或許是在進行加運算的時候失敗了,sum(...) 返回的 promise 將會是已失敗的狀態,並且會將 promise 已失敗的值傳給 then(...) 的第二個回撥處理。

因為 Promises 封裝了依賴時間的狀態 — 等待內部的值已完成或是已失敗 — 從外面看,Promise 是獨立於時間的,因此 Promises 可以能通過一種可預測的方式組合起來,而不用去考慮底層的時間或者結果。

而且,一旦 Promise 的狀態確定了,那麼他就永遠也不會改變狀態了 — 在這時它會變成一個 不可改變的值 — 然後就可以在有需要的時候多次 觀察 它。

實際上鍊式的 promises 是非常有用的:

function delay(time) {
    return new Promise(function(resolve, reject){
        setTimeout(resolve, time);
    });
}

delay(1000)
.then(function(){
    console.log("after 1000ms");
    return delay(2000);
})
.then(function(){
    console.log("after another 2000ms");
})
.then(function(){
    console.log("step 4 (next Job)");
    return delay(5000);
})
// ...
複製程式碼

呼叫 delay(2000) 會建立一個在 2000ms 完成的 promise,然後我們返回第一個 then(...) 的成功回撥,這會導致第二個 then(...) 的 promise 要再等待 2000ms 執行。

注意:因為 Promise 一旦完成了就不能再改變狀態了,所以可以安全的傳遞到任何地方,因為它不會再被意外或是惡意的修改。這對於在多個地方監聽 Promise 的解決方案來說,尤其正確。一方不可能影響到另一方所監聽到的結果。不可變聽起來像是一個學術性的話題,但是它是 Promise 設計中最基礎、最重要方面,不應該被忽略。

用不用 Promise?

使用 Promises 最重要的一點在於能否確定一些值是否是真正的 Promise。換句話說,它的值像一個 Promise 嗎?

我們知道 Promises 是由 new Promise(…) 語句構造出來的,你可能會認為 p instanceof Promise 就能判斷一個 Promise。其實,並不完全是。

主要是因為另一個瀏覽器視窗(比如 iframe)獲取一個 Promise 的值,它擁有自己的 Promise 類,且不同於當前或其他視窗,所以使用 instance 來區分 Promise 是不準確的。

而且,一個框架或者庫可以選擇自己的 Promise,而不是使用 ES6 原生的 Promise 實現。事實上,你很可能會在不支援 Promise 的老式瀏覽器中使用第三方的 Promise 庫。

吞噬異常

如果在任何一個建立 Promise 或是對其結果觀察的過程中,丟擲了一個 JavaScript 異常錯誤,比如說 TypeError 或是 ReferenceError,那麼這個異常會被捕獲,然後它就會把 Promise 的狀態變成已失敗。

例如:

var p = new Promise(function(resolve, reject){
    foo.bar();	  // 對不起,`foo` 沒有定義
    resolve(374); // 不會執行 :(
});

p.then(
    function fulfilled(){
        // 不會執行 :(
    },
    function rejected(err){
        // `err` 是 `foo.bar()` 那一行
	// 丟擲的 `TypeError` 異常物件。
    }
);
複製程式碼

如果一個 Promise 已經結束了,但是在監聽結果(在 then(…) 裡的回撥函式)的時候發生了 JS 異常會怎麼樣呢?即使這個錯誤沒有丟失,你可能也會對它的處理方式有點驚訝。除非你深入的挖掘一下:

var p = new Promise( function(resolve,reject){
	resolve(374);
});

p.then(function fulfilled(message){
    foo.bar();
    console.log(message);   // 不會執行
},
    function rejected(err){
        // 不會執行
    }
);
複製程式碼

這看起來就像 foo.bar() 的異常真的被吞了。當然了,異常並不是被吞了。這是更深層次的問題出現了,我們沒有監聽到異常。p.then(…) 呼叫它自己會返回另一個 promise,而這個 promise 會因為 TypeError 的異常變為已失敗狀態。

處理未捕獲的異常

還有一些 更好的 辦法解決這個問題。

最常見的就是給 Promise 加一個 done(…),用來標誌 Promise 鏈的結束。done(…) 不會建立或返回一個 Promise,所以傳給 done(..) 的回撥顯然不會將問題報告給一個不存在的 Promise。

在未捕獲異常的情況下,這可能才是你期望的:在 done(..) 已失敗的處理函式裡的任何異常都會丟擲一個全域性的未捕獲異常(通常是在開發者的控制檯)。

var p = Promise.resolve(374);

p.then(function fulfilled(msg){
    // 數字不會擁有字串的方法,
    // 所以會丟擲一個錯誤
    console.log(msg.toLowerCase());
})
.done(null, function() {
    // 如果有異常發生,它就會被全域性丟擲 
});
複製程式碼

ES8 發生了什麼? Async/await

JavaScript ES8 介紹了 async/await,使得我們能更簡單的使用 Promises。我們將簡單的介紹 async/await 會帶給我們什麼以及如何利用它們寫出非同步的程式碼。

所以,來讓我們看看 async/await 是如何工作的。

使用 async 函式宣告來定義一個非同步函式。這樣的函式返回一個 AsyncFunction 物件。AsyncFunction 物件表示執行包含在這個函式中的程式碼的非同步函式。

當一個 async 函式被呼叫,它返回一個 Promise。當 async 函式返回一個值,它不是一個 PromisePromise 將會被自動建立,然後它使用函式的返回值來決定狀態。當 async 丟擲一個異常,Promise 使用丟擲的值進入已失敗狀態。

一個 async 函式可以包含一個 await 表示式,它會暫停執行這個函式然後等待傳給它的 Promise 完成,然後恢復 async 函式的執行,並返回已成功的值。

你可以把 JavaScript 的 Promise 看作是 Java 的 Future 或是 C# 的 Task。

async/await 的目的是簡化使用 promises 的寫法。

讓我們來看看下面的例子:


// 一個標準的 JavaScript 函式
function getNumber1() {
    return Promise.resolve('374');
}
// 這個 function 做了和 getNumber1 同樣的事
async function getNumber2() {
    return 374;
}
複製程式碼

同樣,丟擲異常的函式等於返回已失敗的 promises:

function f1() {
    return Promise.reject('Some error');
}
async function f2() {
    throw 'Some error';
}
複製程式碼

關鍵字 await 只能使用在 async 的函式中,並允許你同步等待一個 Promise。如果我們在 async 函式之外使用 promise,我們仍然要用 then 回撥函式:

async function loadData() {
    // `rp` 是一個請求非同步函式
    var promise1 = rp('https://api.example.com/endpoint1');
    var promise2 = rp('https://api.example.com/endpoint2');
   
    // 現在,兩個請求都被觸發, 
    // 我們就等待它們完成。
    var response1 = await promise1;
    var response2 = await promise2;
    return response1 + ' ' + response2;
}
// 但,如果我們沒有在 `async function` 裡
// 我們就必須使用 `then`。
loadData().then(() => console.log('Done'));
複製程式碼

你還可以使用 async 函式表示式的方法建立一個 async 函式。async 函式表示式的寫法和 async 函式宣告差不多。函式表示式和函式宣告最主要的區別就是函式名,它可以在 async 函式表示式中省略來建立一個匿名函式。一個 async 函式表示式可以作為一個 IIFE(立即執行函式) 來使用,當它被定義好的時候就會執行。

它看起來是這樣的:

var loadData = async function() {
    // `rp` 是一個請求非同步函式
    var promise1 = rp('https://api.example.com/endpoint1');
    var promise2 = rp('https://api.example.com/endpoint2');
   
    // 現在,兩個請求都被觸發, 
    // 我們就等待它們完成。
    var response1 = await promise1;
    var response2 = await promise2;
    return response1 + ' ' + response2;
}
複製程式碼

更重要的是,所有主流瀏覽器都支援 async/await:

[譯] JavaScript 如何工作的: 事件迴圈和非同步程式設計的崛起 + 5 個關於如何使用 async/await 編寫更好的技巧

如果這個相容情況不是你想要的,那麼也可以使用一些 JS 轉換器,像 BabelTypeScript

最後,最重要的是不要盲目的選擇“最新”的方法去寫非同步程式碼。更重要的是理解非同步 JavaScript 內部的原理,知道為什麼它為什麼如此重要以及去理解你選擇的方法的內部原理。在程式中每種方法都是有利有弊的。

5 個編寫可維護的、健壯的非同步程式碼的技巧

  1. 乾淨的程式碼: 使用 async/await 能夠讓你少寫程式碼。每一次你使用 async/await 你都能跳過一些不必要的步驟:寫一個 .then,建立一個匿名函式來處理響應,在回撥中命名響應,比如:
// `rp` 是一個請求非同步函式
rp(‘https://api.example.com/endpoint1').then(function(data) {
 // …
});
複製程式碼

對比:

// `rp` 是一個請求非同步函式
var response = await rp(‘https://api.example.com/endpoint1');
複製程式碼
  1. 錯誤處理: Async/await 使得我們可以使用相同的程式碼結構處理同步或者非同步的錯誤 —— 著名的 try/catch 語句。讓我們看看用 Promises 是怎麼實現的:

對比:

async function loadData() {
    try {
        var data = JSON.parse(await getJSON());
        console.log(data);
    } catch(e) {
        console.log(e);
    }
}
複製程式碼
  1. 條件語句: 使用 async/await 來寫條件語句要簡單得多:
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;    
  }
}
複製程式碼
  1. 棧幀:async/await 不同的是,根據promise鏈返回的錯誤堆疊資訊,並不能發現哪出錯了。來看看下面的程式碼:
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);
    // 輸出
    // Error: boom at loadData (index.js:7:9)
});
複製程式碼
  1. 除錯: 如果你使用了 promises,你就會知道除錯它們將會是一場噩夢。比如,你在 .then 裡面打了一個斷點,並且使用類似 “stop-over” 這樣的 debug 快捷方式,偵錯程式不會移動到下一個 .then,因為它只會對同步程式碼生效。而通過 async/await 你就可以逐步的除錯 await 呼叫了,它就像是一個同步函式一樣。

編寫 非同步 JavaScript 程式碼 不僅對於應用程式本身並且對於庫也很重要。

比如,SessionStack 記錄 Web 應用、網站中的所有內容:包括所有 DOM 的改變,使用者互動,JavaScript 異常,棧追蹤,網路請求失敗和 debug 資訊。

這一切都發生在你的生產環境中而不會影響你的使用者體驗。我們需要對我們的程式碼進行大量的優化,使其儘可能的非同步,這樣我們就能增加被事件迴圈處理的事件。

而且這不僅是個庫!當你在 SessionStack 要恢復一個使用者的會話時,我們必須重現所有在使用者的瀏覽器上出現的問題,我們必須重現整個狀態,允許你在會話的事件軸上來回跳轉。為了做到這一點,我們大量地使用了JavaScript 提供的非同步操作。

我們有一個免費的計劃可以讓你免費開始

[譯] JavaScript 如何工作的: 事件迴圈和非同步程式設計的崛起 + 5 個關於如何使用 async/await 編寫更好的技巧

更多資源:


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOSReact前端後端產品設計 等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章