async、await

August 16, 2022

Promise 解決了非同步任務回呼地獄的問題,然而是屬於 API 層面的方案,基本上還是需要搭配回呼函式,而且本質上還是非同步的,總是沒那麼方便;ES8 以後有 asyncawait,從語法層面上支援非同步任務的程式碼撰寫,更易於與同步任務的程式碼結合運用。

async 函式

就語義上,async 用來標示函式執行時是非同步,也就是函式中定義了獨立於程式主流程的任務,若呼叫 async 函式的話,會傳回一個 Promise 物件:

> async function foo() {
...     return 'foo';
... }
undefined
> foo()
Promise { 'foo' }
> foo().then(console.log)
Promise { <pending> }
> foo
[AsyncFunction: foo]
>

async 函式是 AsyncFunction 的實例,型態為 Function 的子類型,雖然函式中定義了 return 傳回字串 'foo',實際上這代表了 async 函式傳回的 Promise 達成任務後的結果,若要取得結果,方式之一就是使用 Promisethen 方法,另一個方式是使用稍後會介紹的 await 來取得。

async 函式中 return 的結果,若是 Promise 或 thenable 物件,可以取得該物件達成任務後的結果,因此可用來銜接其他傳回 Promise 的非同步函式。例如,底下範例會在 1 秒之後顯示數字的結果(而不是顯示 Promise 實例):

function asyncFoo(n) {
    return new Promise(resolve => {
        setTimeout(
            () => resolve(n * Math.random()), 
            1000
        );
    });
}

async function foo(n) {
    return asyncFoo(n); 
}

foo(10).then(v => console.log(v));

如果 async 函式執行之後,無法達成任務呢?可以使用 throw,例如:

async function randomDivided(divisor) {
    let n = Math.floor(Math.random() * 10);
    if(n !== 0) {
        return divisor / n;
    } 
    throw new Error('Shit happens: divided by zero');
}

randomDivided(10)
    .then(
        n   => console.log(n),
        err => console.log(err)
    );

async 函式中若 throw,表示任務無法達成,也就可以使用 Promisethen 的第二個回呼函式來處理。

await 與 Promise

ES8 以後與 async 搭配的是 await,就語義上,若想等待 async 函式執行完後,再執行後續的流程,可以使用 await。例如:

async function foo() {
    return 'foo';
}

async function main() {
    let r = await foo();
    console.log(r); // 顯示 foo
}

main();

await 必須撰寫在 async 函式之中,若不想撰寫 main 函式,另一個方案是使用 IIFE:

async function foo() {
    return 'foo';
}

(async function() {
    let r = await foo();
    console.log(r);
})();

ES13 以後,可以在頂層直接撰寫 await,如果你的環境已經實現該特性,就可以這麼寫:

async function foo() {
    return 'foo';
}

let r = await foo();
console.log(r);

實際上,await 可以接上任何值,不一定要搭配 async 函式,只不過若是接上 Promise 實例,會在任務達成時,取得達成值作為 await 的結果:

function asyncFoo(n) {
    return new Promise(resolve => {
        setTimeout(
            () => resolve(n * Math.random()), 
            2000
        );
    });
}

(async function() {
    let r1 = await asyncFoo(10);
    let r2 = await asyncFoo(r1);
    let r3 = await asyncFoo(r2);
    console.log(r3);
})();   

awaitPromise 若還沒達成任務,只是不會執行 async 函式中定義的後續流程,然而底層的事件迴圈並沒有被阻斷,若檢查到事件佇列中有新事件,還是會執行對應的任務;例如若在上例中加段程式碼:


setTimeout(
    () => console.log(n * Math.random()), 
    1000
);
(async function() {
    let r1 = await asyncFoo(10);
    let r2 = await asyncFoo(r1);
    let r3 = await asyncFoo(r2);
    console.log(r3);
})();   

在程式碼執行 await asyncFoo(10) 後,需要 2 秒的時間才能取得任務達成值,然而 1 秒時間到時,setTimeout 設定的回呼函式還是會執行。

有些開發者會熟悉支援執行緒的語言,在 JavaScript 中會想尋找 sleep 之類、令執行緒停止指定時間的功能;然而,JavaScript 不支援多執行緒,若令唯一的執行緒停止指定時間,結果會像在櫃台前排隊時,令櫃台人員發呆指定的時間,什麼事都不做,想想看隊伍後頭的人會怎樣?

然而基於方才的描述,awaitPromise 若還沒達成任務,底層的事件迴圈並沒有被阻斷,因而透過 awaitPromise,倒是可以模擬 sleep 的功能,例如底下的程式片段,就流程來說確實是間隔了一秒:

async function sleep(millis) {
    return new Promise(resolve => setTimeout(resolve, millis));
}

(async function() {
    console.log(new Date());
    await sleep(1000);
    console.log(new Date());
})();

並不是有了 asyncawait,就不需要 Promise 了,理由之一是 async 函式執行後也是傳回 Promise;另一個理由是在某些場合,必須使用 Promise 來銜接回呼風格的 API。例如,Node.js 的 fs 模組中 readFile 函式,就可以自訂個 readTxtFile 如下傳回 Promise

let fs = require('fs');

function readTxtFile(filename) {
    return new Promise(resolve => {
        fs.readFile(filename, function(_, content) {
            resolve(content.toString());
        });
    });
}

readTxtFile('foo.js')
    .then(console.log);

使用 Promise 來銜接回呼風格的API之後,進一步地就可以搭配 asyncawait 來使用了。例如:

(async function() {
    let content = await readTxtFile('foo.js');
    console.log(content);
})();

分享到 LinkedIn 分享到 Facebook 分享到 Twitter