目錄
請支持這本書:購買(PDF、EPUB、MOBI)捐款
(廣告,請不要封鎖。)

5. 非同步函式

ECMAScript 2017 功能「非同步函式」由 Brian Terlson 提出。

5.1 概觀

5.1.1 變體

非同步函式有以下變體。請注意每個變體中的關鍵字 async

5.1.2 非同步函式總是回傳 Promise

達成非同步函式的 Promise

async function asyncFunc() {
    return 123;
}

asyncFunc()
.then(x => console.log(x));
    // 123

拒絕非同步函式的 Promise

async function asyncFunc() {
    throw new Error('Problem!');
}

asyncFunc()
.catch(err => console.log(err));
    // Error: Problem!

5.1.3 透過 await 處理非同步運算的結果和錯誤

運算子 await(僅允許在非同步函式內使用)會等待其運算元(一個 Promise)解決

處理單一非同步結果

async function asyncFunc() {
    const result = await otherAsyncFunc();
    console.log(result);
}

// Equivalent to:
function asyncFunc() {
    return otherAsyncFunc()
    .then(result => {
        console.log(result);
    });
}

循序處理多個非同步結果

async function asyncFunc() {
    const result1 = await otherAsyncFunc1();
    console.log(result1);
    const result2 = await otherAsyncFunc2();
    console.log(result2);
}

// Equivalent to:
function asyncFunc() {
    return otherAsyncFunc1()
    .then(result1 => {
        console.log(result1);
        return otherAsyncFunc2();
    })
    .then(result2 => {
        console.log(result2);
    });
}

平行處理多個非同步結果

async function asyncFunc() {
    const [result1, result2] = await Promise.all([
        otherAsyncFunc1(),
        otherAsyncFunc2(),
    ]);
    console.log(result1, result2);
}

// Equivalent to:
function asyncFunc() {
    return Promise.all([
        otherAsyncFunc1(),
        otherAsyncFunc2(),
    ])
    .then([result1, result2] => {
        console.log(result1, result2);
    });
}

處理錯誤

async function asyncFunc() {
    try {
        await otherAsyncFunc();
    } catch (err) {
        console.error(err);
    }
}

// Equivalent to:
function asyncFunc() {
    return otherAsyncFunc()
    .catch(err => {
        console.error(err);
    });
}

5.2 了解非同步函式

在解釋非同步函式之前,我需要說明如何結合 Promise 和產生器,透過看似同步的程式碼執行非同步作業。

對於計算一次性結果的非同步函式,ES6 的 Promise 已廣受歡迎。一個範例是 用戶端 fetch API,它是用來擷取檔案的 XMLHttpRequest 替代方案。使用方式如下

function fetchJson(url) {
    return fetch(url)
    .then(request => request.text())
    .then(text => {
        return JSON.parse(text);
    })
    .catch(error => {
        console.log(`ERROR: ${error.stack}`);
    });
}
fetchJson('http://example.com/some_file.json')
.then(obj => console.log(obj));

5.2.1 透過產生器撰寫非同步程式碼

co 是一個使用 Promise 和產生器的函式庫,可用於啟用看起來更同步的編碼樣式,但運作方式與前一個範例中使用的樣式相同

const fetchJson = co.wrap(function* (url) {
    try {
        let request = yield fetch(url);
        let text = yield request.text();
        return JSON.parse(text);
    }
    catch (error) {
        console.log(`ERROR: ${error.stack}`);
    }
});

每次呼叫回函式 (一個產生器函式!) 將 Promise 傳回給 co 時,呼叫回函式就會暫停。一旦 Promise 解決,co 就會繼續執行呼叫回函式:如果 Promise 已完成,yield 會傳回完成值;如果 Promise 已拒絕,yield 會擲出拒絕錯誤。此外,co 會將呼叫回函式傳回的結果轉換為 Promise (類似於 then() 的運作方式)。

5.2.2 透過非同步函式撰寫非同步程式碼

非同步函式基本上是 co 所做工作的專用語法

async function fetchJson(url) {
    try {
        let request = await fetch(url);
        let text = await request.text();
        return JSON.parse(text);
    }
    catch (error) {
        console.log(`ERROR: ${error.stack}`);
    }
}

在內部,非同步函式的運作方式與產生器非常類似。

5.2.3 非同步函式同步啟動,非同步解決

以下是非同步函式的執行方式

  1. 非同步函式的結果永遠都是 Promise p。在開始執行非同步函式時,就會建立該 Promise。
  2. 執行主體。執行可能會透過 returnthrow 永久結束。或者,它可能會透過 await 暫時結束;在這種情況下,執行通常會在稍後繼續進行。
  3. 傳回 Promise p

在執行非同步函式的主體時,return x 會使用 x 解決 Promise p,而 throw err 會使用 err 拒絕 p。解決通知會非同步發生。換句話說:then()catch() 的呼叫回函式會在目前的程式碼結束後才執行。

以下程式碼示範其運作方式

async function asyncFunc() {
    console.log('asyncFunc()'); // (A)
    return 'abc';
}
asyncFunc().
then(x => console.log(`Resolved: ${x}`)); // (B)
console.log('main'); // (C)

// Output:
// asyncFunc()
// main
// Resolved: abc

您可以依賴下列順序

  1. 第 (A) 行:非同步函式同步啟動。非同步函式的 Promise 會透過 return 解決。
  2. 第 (C) 行:執行繼續進行。
  3. 第 (B) 行:Promise 解決通知會非同步發生。

5.2.4 傳回的 Promise 沒有包裝

解決 Promise 是標準作業。return 使用它來解決非同步函式的 Promise p。這表示

  1. 傳回非 Promise 值會以該值完成 p
  2. 傳回 Promise 表示 p 現在反映該 Promise 的狀態。

因此,您可以傳回 Promise,而該 Promise 也不會包裝在 Promise 中

async function asyncFunc() {
    return Promise.resolve(123);
}
asyncFunc()
.then(x => console.log(x)) // 123

有趣的是,傳回已拒絕的 Promise 會導致非同步函式的結果遭到拒絕(通常,您會使用 throw 進行此操作)

async function asyncFunc() {
    return Promise.reject(new Error('Problem!'));
}
asyncFunc()
.catch(err => console.error(err)); // Error: Problem!

這與 Promise 解析運作方式一致。它讓您能夠轉送其他非同步運算的完成和拒絕,而不需要 await

async function asyncFunc() {
    return anotherAsyncFunc();
}

前述程式碼大致類似於下列程式碼(僅解開 anotherAsyncFunc() 的 Promise,然後再次包裝),但效率更高

async function asyncFunc() {
    return await anotherAsyncFunc();
}

5.3 使用 await 的提示

5.3.1 別忘記 await

在非同步函式中容易犯的一個錯誤是,在進行非同步函式呼叫時忘記 await

async function asyncFunc() {
    const value = otherAsyncFunc(); // missing `await`!
    ···
}

在此範例中,value 設定為 Promise,這通常不是您在非同步函式中想要的。

即使非同步函式沒有傳回任何內容,await 仍有意義。然後,其 Promise 僅用作通知呼叫者已完成的訊號。例如

async function foo() {
    await step1(); // (A)
    ···
}

第 (A) 行的 await 保證 step1() 在執行 foo() 的其餘部分之前完全完成。

5.3.2 如果您「發射後忘記」,就不需要 await

有時候,您只想觸發非同步運算,而對它何時完成不感興趣。下列程式碼是一個範例

async function asyncFunc() {
    const writer = openFile('someFile.txt');
    writer.write('hello'); // don’t wait
    writer.write('world'); // don’t wait
    await writer.close(); // wait for file to close
}

在此,我們不關心個別寫入何時完成,只關心它們是否按正確順序執行(API 必須保證這一點,但這是由非同步函式的執行模型鼓勵的,正如我們所見)。

asyncFunc() 最後一行的 await 確保函式僅在檔案成功關閉後才完成。

由於回傳的 Promise 沒有包裝,您也可以使用 return 代替 await writer.close()

async function asyncFunc() {
    const writer = openFile('someFile.txt');
    writer.write('hello');
    writer.write('world');
    return writer.close();
}

這兩個版本各有優缺點,await 版本可能稍微容易理解一些。

5.3.3 await 是順序的,Promise.all() 是並行的

下列程式碼執行兩個非同步函式呼叫,asyncFunc1()asyncFunc2()

async function foo() {
    const result1 = await asyncFunc1();
    const result2 = await asyncFunc2();
}

然而,這兩個函式呼叫是順序執行的。並行執行它們往往可以加快速度。您可以使用 Promise.all() 執行此操作

async function foo() {
    const [result1, result2] = await Promise.all([
        asyncFunc1(),
        asyncFunc2(),
    ]);
}

我們現在不是等待兩個 Promise,而是等待一個包含兩個元素的陣列的 Promise。

5.4 非同步函式和回呼函式

非同步函式的其中一個限制是 await 僅影響直接周圍的非同步函式。因此,非同步函式無法在回呼函式中使用 await(然而,回呼函式本身可以是非同步函式,我們稍後會看到)。這使得基於回呼函式的工具函式和方法難以使用。範例包括陣列方法 map()forEach()

5.4.1 Array.prototype.map()

我們從陣列方法 map() 開始。在下列程式碼中,我們想要下載由 URL 陣列指向的檔案,並將它們回傳到陣列中。

async function downloadContent(urls) {
    return urls.map(url => {
        // Wrong syntax!
        const content = await httpGet(url);
        return content;
    });
}

這無法運作,因為 await 在一般的箭頭函式中語法不合法。那麼使用非同步箭頭函式如何呢?

async function downloadContent(urls) {
    return urls.map(async (url) => {
        const content = await httpGet(url);
        return content;
    });
}

這段程式碼有兩個問題

我們可以透過 Promise.all() 修復這兩個問題,它會將 Promise 陣列轉換為陣列的 Promise(其中值由 Promise 完成)

async function downloadContent(urls) {
    const promiseArray = urls.map(async (url) => {
        const content = await httpGet(url);
        return content;
    });
    return await Promise.all(promiseArray);
}

map() 的回呼函式不會對 httpGet() 的結果做太多處理,它只會轉送它。因此,我們這裡不需要非同步箭頭函式,一般的箭頭函式就可以了

async function downloadContent(urls) {
    const promiseArray = urls.map(
        url => httpGet(url));
    return await Promise.all(promiseArray);
}

我們仍然可以做一個小改進:這個非同步函式有點低效率,它會先透過 await 解開 Promise.all() 的結果,然後再透過 return 重新包裝它。由於 return 沒有包裝 Promise,我們可以直接回傳 Promise.all() 的結果

async function downloadContent(urls) {
    const promiseArray = urls.map(
        url => httpGet(url));
    return Promise.all(promiseArray);
}

5.4.2 Array.prototype.forEach()

讓我們使用陣列方法 forEach() 來記錄透過 URL 指向的幾個檔案的內容

async function logContent(urls) {
    urls.forEach(url => {
        // Wrong syntax
        const content = await httpGet(url);
        console.log(content);
    });
}

再次,這段程式碼會產生語法錯誤,因為你無法在一般的箭頭函式中使用 await

讓我們使用非同步箭頭函式

async function logContent(urls) {
    urls.forEach(async url => {
        const content = await httpGet(url);
        console.log(content);
    });
    // Not finished here
}

這確實有效,但有一個注意事項:httpGet() 回傳的 Promise 是非同步解析的,這表示當 forEach() 回傳時,呼叫函式尚未完成。因此,你無法等待 logContent() 結束。

如果你不想要這樣,你可以將 forEach() 轉換為 for-of 迴圈

async function logContent(urls) {
    for (const url of urls) {
        const content = await httpGet(url);
        console.log(content);
    }
}

現在,在 for-of 迴圈之後,一切就都完成了。但是,處理步驟是按順序發生的:只有在第一次呼叫完成之後,才會第二次呼叫 httpGet()。如果你希望處理步驟並行發生,你必須使用 Promise.all()

async function logContent(urls) {
    await Promise.all(urls.map(
        async url => {
            const content = await httpGet(url);
            console.log(content);
        }));
}

map() 用於建立 Promise 陣列。我們對它們完成的結果不感興趣,我們只 await 直到它們全部完成。這表示我們在這個非同步函式的結尾時已經完全完成。我們也可以回傳 Promise.all(),但函式的結果將會是一個陣列,其元素都是 undefined

5.5 使用非同步函式的提示

5.5.1 了解你的 Promise

非同步函式的基礎是 Promise。這就是為什麼了解 Promise 對了解非同步函式至關重要的原因。特別是在將未基於 Promise 的舊程式碼與非同步函式連接時,你常常別無選擇,只能直接使用 Promise。

例如,這是 XMLHttpRequest 的「promisified」版本

function httpGet(url, responseType="") {
    return new Promise(
        function (resolve, reject) {
            const request = new XMLHttpRequest();
            request.onload = function () {
                if (this.status === 200) {
                    // Success
                    resolve(this.response);
                } else {
                    // Something went wrong (404 etc.)
                    reject(new Error(this.statusText));
                }
            };
            request.onerror = function () {
                reject(new Error(
                    'XMLHttpRequest Error: '+this.statusText));
            };
            request.open('GET', url);
            xhr.responseType = responseType;
            request.send();
        });
}

XMLHttpRequest 的 API 基於呼叫函式。透過非同步函式使其 promisified 表示你必須從呼叫函式中完成或拒絕函式回傳的 Promise。這是不可行的,因為你只能透過 returnthrow 來這麼做。而且你無法從呼叫函式中 return 函式的結果。throw 有類似的限制。

因此,非同步函式的常見編碼樣式將會是

進一步閱讀:「探索 ES6」中的「非同步程式設計的 Promise」章節。

5.5.2 立即呼叫非同步函數表達式

有時候,如果你可以在模組或指令碼的最上層使用 await 會很好。唉,它只能在非同步函數內使用。因此你有多種選擇。你可以建立一個非同步函數 main(),然後立即呼叫它

async function main() {
    console.log(await asyncFunction());
}
main();

或者你可以使用立即呼叫非同步函數表達式

(async function () {
    console.log(await asyncFunction());
})();

另一個選擇是立即呼叫非同步箭頭函數

(async () => {
    console.log(await asyncFunction());
})();

5.5.3 使用非同步函數進行單元測試

以下程式碼使用 測試架構 mocha 來對非同步函數 asyncFunc1()asyncFunc2() 進行單元測試

import assert from 'assert';

// Bug: the following test always succeeds
test('Testing async code', function () {
    asyncFunc1() // (A)
    .then(result1 => {
        assert.strictEqual(result1, 'a'); // (B)
        return asyncFunc2();
    })
    .then(result2 => {
        assert.strictEqual(result2, 'b'); // (C)
    });
});

但是,這個測試總是會成功,因為 mocha 沒有等到 (B) 行和 (C) 行中的斷言執行完畢。

你可以透過傳回 Promise 鏈的結果來修正這個問題,因為 mocha 會辨識測試是否傳回 Promise,然後等到該 Promise 解決(除非有逾時)。

return asyncFunc1() // (A)

很方便的是,非同步函數總是傳回 Promise,這讓它們非常適合這種單元測試

import assert from 'assert';
test('Testing async code', async function () {
    const result1 = await asyncFunc1();
    assert.strictEqual(result1, 'a');
    const result2 = await asyncFunc2();
    assert.strictEqual(result2, 'b');
});

因此,在 mocha 中使用非同步函數進行非同步單元測試有兩個優點:程式碼更簡潔,而且傳回 Promise 的問題也解決了。

5.5.4 別擔心未處理的拒絕

JavaScript 引擎越來越擅長警告未處理的拒絕。例如,以下程式碼在過去通常會在沒有提示的情況下失敗,但現在大多數現代的 JavaScript 引擎都會報告未處理的拒絕

async function foo() {
    throw new Error('Problem!');
}
foo();

5.6 進一步閱讀

下一篇:6. 共享記憶體和原子