簡介
JavaScript ES7 中的 async / await 讓多個非同步 promise 協同工作起來更容易。如果要按一定順序從多個資料庫或者 API 非同步獲取資料,你可能會以一堆亂七八糟的 promise 和回撥函式而告終。而 async / await 結構讓我們能用可讀性強、易維護的程式碼更加簡潔地實現這些邏輯。
本教程用圖表和簡單示例講解了 JavaScript 中 async / await 的語法和語義。
在深入之前,我們先簡單回顧一下 promise. 如果你已經對 JS 的 promise 有所瞭解,可放心大膽地跳過這一部分。
Promises
在 JavaScript 中,promise 代表非阻塞非同步執行的抽象概念。如果你熟悉Java 的 Future、C# 的 Task, 你會發現 promise 跟它們很像。
Promise 一般用於網路和 I/O 操作,比如讀取檔案,或者建立 HTTP 請求。我們可以建立非同步 promise,然後用 then 新增一個回撥函式,當 promise 結束後會觸發這個回撥函式,而非阻塞住當前“執行緒”。回撥函式本身也可以返回一個 promise 物件,所以我們能夠鏈式呼叫 promise。
為了簡單起見,我們假設後面所有示例都已經像這樣安裝並載入了 request-promise 類庫:
1 |
var rp = require('request-promise'); |
現在我們就可以像這樣建立一個返回 promise 物件的簡易 HTTP GET 請求:
1 |
const promise = rp('http://example.com/') |
我們現在來看個例子:
1 2 3 4 5 6 |
console.log('Starting Execution'); const promise = rp('http://example.com/'); promise.then(result => console.log(result)); console.log("Can't know if promise has finished yet..."); |
我們在第3行建立了一個 promise 物件,在第4行給它加了個回撥函式。Promise 是非同步的,所以當執行到第6行時,我們並不知道 promise 是否已完成。如果把段這程式碼多執行幾次,可能每次都得到不同的結果。一般地說,就是 promise 建立後的程式碼和 promise 是同時執行的。
直到 promise 執行完,才有辦法阻塞當前操作序列。這不同於 Java 的 Future.get, 它讓我們能夠在 Future 結束之前就阻塞當前執行緒。對於 JavaScript,我們沒法等待 promise 執行完。在 promise 後面編排程式碼的唯一方法是用 then 給它新增回撥函式。
下圖描述了本例的計算過程:
通過 then 新增的回撥函式只有當 promise 成功時才會執行。如果它失敗了(比如由於網路錯誤),回撥函式不會執行。你可以用 catch 再附加一個回撥函式來處理失敗的 promise:
1 2 3 |
rp('http://example.com/'). then(() => console.log('Success')). catch(e => console.log(`Failed: ${e}`)) |
最後,為了測試,我們可以用 Promise.resolve 和 Promise.reject 很容易地建立執行成功或失敗的“傻瓜” promise:
1 2 3 4 5 6 7 8 9 10 11 12 |
const success = Promise.resolve('Resolved'); // 列印 "Successful result: Resolved" success. then(result => console.log(`Successful result: ${result}`)). catch(e => console.log(`Failed with: ${e}`)) const fail = Promise.reject('Err'); // 列印 "Failed with: Err" fail. then(result => console.log(`Successful result: ${result}`)). catch(e => console.log(`Failed with: ${e}`)) |
想要更詳細的 promise 教程,可以參考這篇文章。
問題來了——組合 promise
只用一個 promise 很容易搞定。但是,當需要針對複雜非同步邏輯程式設計時,我們很可能最後要同時用好幾個 promise 物件。寫一堆 then 語句和匿名回撥很容易搞得難以控制。
例如,假設我們需要程式設計解決如下需求:
- 建立 HTTP 請求,等待請求結束並列印出結果;
- 再建立兩個並行 HTTP 請求;
- 等這兩個請求結束後,列印出它們的結果。
下面這段程式碼示範瞭如何解決此問題:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
// 第一次呼叫 const call1Promise = rp('http://example.com/'); call1Promise.then(result1 => { // 第一個請求完成後會執行 console.log(result1); const call2Promise = rp('http://example.com/'); const call3Promise = rp('http://example.com/'); return Promise.all([call2Promise, call3Promise]); }).then(arr => { // 兩個 promise 都結束後會執行 console.log(arr[0]); console.log(arr[1]); }) |
我們開頭建立了第一個 HTTP 請求,並且加了個完成時候執行的回撥(1-3行)。在這個回撥函式裡,我們為隨後的 HTTP 請求建立了另外兩個 promise(8-9行)。這兩個 promise 同時執行,我們需要加一個能等它們都完成後才執行的回撥函式。因此,我們需要用 Promise.all 將它們組合到同一個 promise 中(11 行),它們都結束後這個 promise 才算完成。這個回撥返回的是 promise 物件,所以我們要再加一個 then 回撥函式來列印結果(12-16行)。
下圖描述了這一計算流程:
對於這個簡單的例子,我們最後用了兩個 then 回撥方法,並且不得不用 Promise.all 來讓兩個並行的 promise 同時執行。如果我們必須執行更多非同步操作,或者加上錯誤處理會怎麼樣呢?這種方法最後很容易產生一堆亂七八糟的 then, Promise.all 和回撥函式。
Async 方法
Async 是定義返回 promise 物件函式的快捷方法。
例如,下面這兩種定義是等價的:
1 2 3 4 5 6 7 8 |
function f() { return Promise.resolve('TEST'); } // asyncF 和 f 是等價的 async function asyncF() { return 'TEST'; } |
類似地,丟擲異常的 async 方法等價於返回拒絕 promise 的方法:
1 2 3 4 5 6 7 8 |
function f() { return Promise.reject('Error'); } // asyncF 和 f 是等價的 async function asyncF() { throw 'Error'; } |
Await
我們建立了 promise 但不能同步等待它執行完成。我們只能通過 then 傳一個回撥函式。不允許等待 promise 是為了鼓勵開發非阻塞程式碼。否則,開發者們總會忍不住執行阻塞操作,因為那比使用 promise 和回撥更簡單。
然而,為了讓 promise 能同步執行,我們需要讓他們等待彼此完成。換句話說,如果一個操作是非同步的(即封裝在 promise 中),它應該能夠等待另一個非同步操作執行完。但是 JavaScript 直譯器怎麼能知道一個操作是否在 promise 中執行呢?
答案就在 async 這個關鍵詞中。每個 async 方法都返回一個 promise 物件。因此,JavaScript 直譯器就明白所有 async 方法中的操作都被封裝在 promise 裡非同步執行。所以直譯器能夠允許它們等待其他 promise 執行完。
下面引入 await 關鍵詞。它只能被用在 async 方法中,讓我們能同步等待 promise 執行完。如果在 async 函式外使用 promise, 我們仍然需要用 then 回撥函式:
1 2 3 4 5 6 7 8 9 |
async function f(){ // response 就是 promise 執行成功的值 const response = await rp('http://example.com/'); console.log(response); } // 不能在 async 方法外面用 await // 需要使用 then 回撥函式…… f().then(() => console.log('Finished')); |
現在我們來看如何解決上一節的問題:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
// 將解決方法封裝到 async 函式中 async function solution() { // 等待第一個 HTTP 請求並列印出結果 console.log(await rp('http://example.com/')); // 建立兩個 HTTP 請求,不等它們執行完 —— 讓他們同時執行 const call2Promise = rp('http://example.com/'); // Does not wait! const call3Promise = rp('http://example.com/'); // Does not wait! // 建立完以後 —— 等待它們都執行完 const response2 = await call2Promise; const response3 = await call3Promise; console.log(response2); console.log(response3); } // 呼叫這一 async 函式 solution().then(() => console.log('Finished')); |
上面這段程式碼中,我們把解決方法封裝到 async 函式中。這讓我們能直接對裡面的 promise 使用 await 關鍵字,所以不再需要使用 then 回撥函式。最後,呼叫這個 async 函式,它簡單地建立了一個 promise 物件, 這個 promise 封裝了呼叫其他 promise 的邏輯。
當然,在第一個例子(沒有用 async / await)中,兩個 promise會被同時觸發。這段程式碼也一樣(7-8 行)。注意,直到第 11-12 行我們才使用 await, 將程式一直阻塞到兩個 promise 執行完成。然後我們就能斷定上例中兩個 promise 都成功執行了(和使用 Promise.all(…).then(…) 類似)。
這背後的計算過程跟上一節給出的基本相當。但是程式碼可讀性更強、更易於理解。
實際上,async / await 在底層轉換成了 promise 和 then 回撥函式。也就是說,這是使用 promise 的語法糖。每次我們使用 await, 直譯器都建立一個 promise 物件,然後把剩下的 async 函式中的操作放到 then 回撥函式中。
我們再看看下面的例子:
1 2 3 4 5 |
async function f() { console.log('Starting F'); const result = await rp('http://example.com/'); console.log(result); } |
下面給出了函式 f 底層運算過程。由於 f 是 async 的,所以它會跟它的呼叫方同時執行:
函式 f 開始執行並建立了一個 promise 物件。就在那一刻,函式中剩下的部分被封裝到一個回撥函式中,並在 promise 結束後執行。
錯誤處理
前面大部分例子中,我們都假設 promise 執行成功。因此在 promise 上使用 await 會返回值。如果我們進行 await 的 promise 失敗了,async 函式就會發生異常。我們可以用標準的 try / catch 來處理這種情況:
1 2 3 4 5 6 7 |
async function f() { try { const promiseResult = await Promise.reject('Error'); } catch (e){ console.log(e); } } |
Async 函式不會處理異常,不管異常是由拒絕的 promise 還是其他 bug 引起的,它都會返回一個拒絕 promise:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
async function f() { // Throws an exception const promiseResult = await Promise.reject('Error'); } // Will print "Error" f(). then(() => console.log('Success')). catch(err => console.log(err)) async function g() { throw "Error"; } // Will print "Error" g(). then(() => console.log('Success')). catch(err => console.log(err)) |
這讓我們能得心應手地通過熟悉的異常處理機制來處理拒絕的 promise.
討論
Async / await 是讓 promise 更完美的語言結構。它讓我們能用更少的程式碼使用 promise. 然而,async / await 並沒有取代普通 promise. 例如,如果在普通函式中或者全域性範圍內呼叫 async 函式,我們就沒辦法使用 await 而要依賴於普通 promise:
1 2 3 4 5 6 7 |
async function fAsync() { // actual return value is Promise.resolve(5) return 5; } // can't call "await fAsync()". Need to use then/catch fAsync().then(r => console.log(`result is ${r}`)); |
我通常會將大部分非同步邏輯封裝到一個或者幾個 async 函式中,然後在非非同步程式碼中呼叫。這讓我儘可能少地寫 try / catch 回撥。
Async / await 結構是讓使用 promise 更簡練的語法糖。每一個 async / await 結構都可以寫成普通 promise. 歸根結底,這是一個編碼風格和簡潔的問題。
關於說明併發和並行有區別的資料,可以檢視 Rob Pike 關於這個問題的討論,或者我這篇文章。併發是指將獨立程式(通常意義上的程式)組合在一起工作,而並行是指真正同時處理多個任務。併發關乎應用設計和架構,而並行關乎實實在在的執行。
我們拿一個多執行緒應用來舉例。應用程式分離成執行緒明確了它的併發模型。這些執行緒在可用核心上的對映定義了其級別或並行性。併發系統可以在單個處理器上高效執行,在這種情況下,它並不是並行的。
併發VS並行
從這個意義上說,promise 讓我們能夠將程式分解成併發模組,這些模組可能會也可能不會並行執行。Javascript 實際否並行執行取決於具體實現方法。例如,Node JS 是單執行緒的,如果 promise 是計算密集型(CPU bound)那就不會有並行處理。但是,如果你用 Nashorn 之類的東西把程式碼編譯成 java 位元組碼,理論上可能能夠將計算密集型的 promise 對映到不同 CPU 核上,從而達到並行效果。所以我認為,promise(不管是普通的還是用了 async / await 的)組成了 JavaScript 應用的併發模組。
打賞支援我翻譯更多好文章,謝謝!
打賞譯者
打賞支援我翻譯更多好文章,謝謝!
任選一種支付方式