上一章介紹的Promise雖然可以將非同步程式用看起來像同步程式的方式來撰寫,但寫法還是與實際的同步程式有不小的差異。也因為Promise有這樣的問題,所以後來的ES2017引入了「async / await」語法,可以完全地以同步程式的寫法使用非同步程式。



「async / await」是Promise和產生器的語法糖

在前面的章節中,我們有看過以下的程式:

async function infiniteLoop() {
    while (1) {
    }
}

infiniteLoop();

console.log("Hello!");

「async / await」語法的功能是用ES6新增的Promise和產生器來實現的。加上async關鍵字的函數,會變成非同步函數,其主體會變成產生器函數的主體(不過我們無法直接在非同步函數中使用yield關鍵字),而非同步函數的主體中會再建立Promise物件實體來將產生器函數產生出來的迭代器的迭代結果當作「正確值」回傳出來(用resolve函數)。非同步函數則會將這個Promise物件回傳出來。

如下:

function infiniteLoop() {
    const generator = function* () {
        while (1) {
        }
    };

    return new Promise<void>(function (resolve) {
        const iterator = generator();

        const result = iterator.next();

        resolve(result.value);
    });
}

infiniteLoop();

console.log("Hello!");

以上程式,會在第10行陷入無窮迴圈。注意,Hello!文字並不會被輸出,因為傳入Promise物件建構子的回呼函數是會被同步執行的,換句話說它不會等到JavaScript執行緒進入事件循環後才被呼叫。

透過上面的轉換說明,我們可以知道原先這個infiniteLoop非同步函數的回傳值型別為Promise<void>。所以若要明確地寫出infiniteLoop非同步函數的回傳值型別,就會變成以下這樣:

async function infiniteLoop(): Promise<void> {
    while (1) {
    }
}

infiniteLoop();

console.log("Hello!");

我們試著讓非同步函數回傳隨便的值出來看看,程式如下:

async function f(): Promise<number> {
    return 123;
}

f()
    .then((value) => {
        console.log(value);
    })
    .catch((err) => {
        console.error(err);
    });

console.log("Hello!");

以上程式的輸出結果為:

Hello!
123

傳入Promise物件實體的then方法的回呼函數需要透過微任務來呼叫,所以它在JavaScript執行緒進入事件循環後才會被執行,因此Hello!文字會在123之前輸出。

以上程式,用Promise物件和產生器轉換如下:

function f() {
    const generator = function* () {
        return 123;
    };

    return new Promise<number>(function (resolve) {
        const iterator = generator();

        const result = iterator.next();

        resolve(result.value);
    });
}

f()
    .then((value) => {
        console.log(value);
    })
    .catch((err) => {
        console.error(err);
    });

console.log("Hello!");

您可以能會有這樣的疑問:為什麼要用到產生器?這是因為await關鍵字要依靠產生器的yield關鍵字功能來實作。

await關鍵字可以用來「等待」一個Promise物件實體結束執行,並將「正確值」直接回傳;將「錯誤值」直接用throw關鍵字拋出。例如:

async function f(): Promise<number> {
    return 123;
}

try {
    const value = await f();

    console.log(value);
} catch (err) {
    console.error(err);
}

console.log("Hello!");

當然,以上程式是無法編譯的,因為我們無法在產生器函數之外使用yield關鍵字(await關鍵字需要被轉換為yield關鍵字),所以await關鍵字必須要用在非同步函數之中。

為了讓以上程式能夠成功編譯,可以將其改寫如下:

async function f(): Promise<number> {
    return 123;
}

async function main(): Promise<void> {
    try {
        const value = await f();

        console.log(value);
    } catch (err) {
        console.error(err);
    }

    console.log("Hello!");
}

main();

以上程式的輸出結果為:

123
Hello!

以上程式,用Promise物件和產生器轉換如下:

function f() {
    const generator = function* () {
        return 123;
    };

    return new Promise<number>(function (resolve) {
        const iterator = generator();

        const result = iterator.next();

        resolve(result.value);
    });
}

function main() {
    const generator = function* () {
        try {
            const value = yield f();

            console.log(value);
        } catch (err) {
            console.error(err);
        }

        console.log("Hello!");
    };

    return new Promise<void>(function (resolve, reject) {
        const iterator = generator();

        const nextStep = (result: IteratorResult<Promise<number>>) => {
            if (result.done) {
                resolve(result.value);
            } else {
                let p;

                if (result.value instanceof Promise) {
                    p = result.value;
                } else {
                    p = Promise.resolve(result.value);
                }

                p
                    .then((value) => {
                        nextStep(iterator.next(value));
                    })
                    .catch((err) => {
                        iterator.throw(err);
                    });
            }
        };

        nextStep(iterator.next());
    });
}

main();

以上程式的第6行到第12行,是因為我們知道第2行到第4行的產生器函數內沒有使用到yield關鍵字(換句話說原本的非同步函數主體中並沒有使用到await關鍵字),而撰寫出來的「async(無await) 轉 Promise和產生器」的結果。實際上,「async / await」語法的實作方式會更接近於程式第28行到第54行撰寫的「async / awaitPromise和產生器」的結果。

來詳細看一下程式第28行到第54行到底在做什麼吧!

Promise物件的建構子回呼函數中,在呼叫產生器函數建立迭代器後,會先呼叫第一次的next方法(程式第53行)。next方法的回傳結果會經過一個特定的程序(在此為nextStep函數)來處理。

nextStep函數中,首先會去判斷next方法的回傳結果是否已經是最終結果(done欄位值為true),如果是的話,就將value欄位值當作此Promise物件實體的「正確值」。如果next方法的回傳結果尚不是最終的(因為有使用yield關鍵字),就會判斷value欄位值是否為一個Promise物件實體(也就是說,await關鍵字的右邊不一定要是一個Promise物件實體)。如果value欄位值不是一個Promise物件實體,就會產生一個Promise物件實體,並讓value欄位值作為其「正確值」。

接著會去呼叫這個Promise物件實體的then方法和catch方法。如果這個Promise物件實體是回傳「正確值」,then方法的回呼函數就會被執行(程式第44行),會利用迭代器的next方法,使yield關鍵字繼續執行,並且回傳原本這個「正確值」。也就是說,await關鍵字可以直接將其右邊的Promise物件實體的「正確值」回傳到左邊。

如果Promise物件實體是回傳「錯誤值」,catch方法的回呼函數就會被執行(程式第47行),會利用迭代器的throw方法,將「錯誤值」交到正在暫停的yield關鍵字的位置來拋出。也就是說,await關鍵字可以被寫在try區塊中,來處理其Promise物件實體的「錯誤值」。

了解「async / await」語法的原理後,我們就自然而然地會用它了!

非同步函數與無窮迴圈

再重新回來看這個例子:

async function infiniteLoop() {
    while (1) {
    }
}

infiniteLoop();

console.log("Hello!");

以上的infiniteLoop會運行一個無迴圈,它是一個非同步函數,但Hello!文字在infiniteLoop函數被呼叫之後就永遠不會被執行到……為什麼?

首先,我們要知道非同步函數並不會讓JavaScript開啟一個新的執行緒來執行它。再來,非同步函數主體中的程式敘述,因為是在Promise物件的建構子的回呼函數被呼叫時執行,所以並不會在一個新的微任務中被執行,換句話說,它實際是被同步執行的!

但是,當非同步函數中有使用await關鍵字時,此await關鍵字後的程式敘述就會被分配到新的微任務中被執行。也就是說,非同步函數主體中,await關鍵字前的敘述會被同步執行,而await關鍵字後的敘述則會被非同步執行(在新的微任務中執行)

舉例來說:

async function infiniteLoop() {
    const n = await 1;

    while (1) {
    }
}

infiniteLoop();

console.log("Hello!");

以上程式,僅僅是在第二行插入看似沒有什麼用的await關鍵字的敘述,就能讓Hello!文字成功輸出在主控台中。這就是因為await關鍵字後的敘述是非同步執行的!

用「async / await」語法取代Promisethencatchfinally方法

學會「async / await」語法之後,我們就再也沒有必要用Promisethencatchfinally方法來撰寫「看起來像同步」的非同步程式啦!改用「async / await」語法可以讓程式碼更好寫,也更加容易閱讀。

例如:

import fs = require("node:fs");
import path = require("node:path");

const INPUT_PATH = "input";
const OUTPUT_FOLDER = "folder";
const OUTPUT_NAME = "output";

const pStat = () => new Promise((resolve, reject) => {
    fs.stat(INPUT_PATH, (err, stats) => {
        if (err) {
            return reject(err);
        }

        resolve(stats);
    });
});

const pMkdir = () => new Promise((resolve, reject) => {
    fs.mkdir(OUTPUT_FOLDER, { recursive: true }, (err) => {
        if (err) {
            return reject(err);
        }

        resolve();
    });
});

const pCopyFile = () => new Promise((resolve, reject) => {
    const outputPath = path.join(OUTPUT_FOLDER, OUTPUT_NAME);

    fs.copyFile(INPUT_PATH, outputPath, (err) => {
        if (err) {
            return reject(err);
        }

        resolve();
    });
});

const pUnlink = () => new Promise((resolve, reject) => {
    fs.unlink(INPUT_PATH, (err) => {
        if (err) {
            return reject(err);
        }

        resolve();
    });
});

pStat()
    .then((stats) => {
        console.log(stats);

        return pMkdir();
    })
    .then(() => {
        return pCopyFile();
    })
    .then(() => {
        return pUnlink();
    })
    .then(() => {
        console.log("Done!");
    })
    .catch((err) => {
        console.error(err);
    });

以上程式可以改寫為:

import fs = require("node:fs");
import path = require("node:path");

const INPUT_PATH = "input";
const OUTPUT_FOLDER = "folder";
const OUTPUT_NAME = "output";

const pStat = () => new Promise((resolve, reject) => {
    fs.stat(INPUT_PATH, (err, stats) => {
        if (err) {
            return reject(err);
        }

        resolve(stats);
    });
});

const pMkdir = () => new Promise((resolve, reject) => {
    fs.mkdir(OUTPUT_FOLDER, { recursive: true }, (err) => {
        if (err) {
            return reject(err);
        }

        resolve();
    });
});

const pCopyFile = () => new Promise((resolve, reject) => {
    const outputPath = path.join(OUTPUT_FOLDER, OUTPUT_NAME);

    fs.copyFile(INPUT_PATH, outputPath, (err) => {
        if (err) {
            return reject(err);
        }

        resolve();
    });
});

const pUnlink = () => new Promise((resolve, reject) => {
    fs.unlink(INPUT_PATH, (err) => {
        if (err) {
            return reject(err);
        }

        resolve();
    });
});

async function main() {
    try {
        const stats = await pStat();
        console.log(stats);

        await pMkdir();
        await pCopyFile();
        await pUnlink();

        console.log("Done!");
    } catch (err) {
        console.error(err);
    }
}

main();

promisify

因為「async / await」要和Promise物件實體搭配使用,才能將非同步程式撰寫成「看起來同步」的程式碼,所以原先的非同步程式必須要先轉成Promise物件實體才行。

例如以下程式就是在做這件事情:

const pStat = () => new Promise((resolve, reject) => {
    fs.stat(INPUT_PATH, (err, stats) => {
        if (err) {
            return reject(err);
        }

        resolve(stats);
    });
});

雖然撰寫這樣的程式碼來製作Promise物件實體並不難,但數量一多的話也還是挺麻煩的。還好Node.js內建的util模組,有提供promisify函數,可以將任何最後一個參數的型別為(err: any, value: any) => void的函數作為promisify函數的第一個參數,promisify函數就會回傳用來產生其Promise物件實體的函數。此函數前面幾個參數會變成promisify函數最後一個參數的函數的前面幾個參數。

以下是一個完整的例子:

import fs = require("node:fs");
import path = require("node:path");
import util = require("node:util");

const INPUT_PATH = "input";
const OUTPUT_FOLDER = "folder";
const OUTPUT_NAME = "output";

const pStat = util.promisify(fs.stat);

const pMkdir = util.promisify(fs.mkdir);

const pCopyFile = util.promisify(fs.copyFile);

const pUnlink = util.promisify(fs.unlink);

async function main() {
    try {
        const stats = await pStat(INPUT_PATH);
        console.log(stats);

        await pMkdir(OUTPUT_FOLDER, { recursive: true });

        const outputPath = path.join(OUTPUT_FOLDER, OUTPUT_NAME);
        await pCopyFile(INPUT_PATH, outputPath);

        await pUnlink(INPUT_PATH);

        console.log("Done!");
    } catch (err) {
        console.error(err);
    }
}

main();

有沒有覺得當TypeScript程式碼寫成以上這樣後,頓時完全沒有非同步程式的影子了呢?

總結

JavaScript的事件循環、回呼、Promise和「async/await」語法是JavaScript程式語言觀念中最難的部份,雖然這個系列文章主要是在學習TypeScript,但我們還是花了三個章節(或四個章節,如果產生器那章也算的話)的篇幅來弄清楚它們,因為它們實在是很重要!

我們現在使用Node.js提供的模組時,都是先用import關鍵字來「假宣告」要require的模組,再用typeof關鍵字來取得這個模組的定義,以便知道實際呼叫require函數時所回傳的值的型別。然而這個方式用在TypeScript的編譯目標是ES6或是以上的版本的情況下就會出問題。所以在下一章節中,我們要來學習TypeScript的模組系統,順便解決掉這個問題。

下一章:模組