上一章介紹的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!");
以上程式的輸出結果為:
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();
以上程式的輸出結果為:
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 / await 轉 Promise和產生器」的結果。
來詳細看一下程式第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」語法取代Promise的then、catch和finally方法
學會「async / await」語法之後,我們就再也沒有必要用Promise的then、catch和finally方法來撰寫「看起來像同步」的非同步程式啦!改用「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的模組系統,順便解決掉這個問題。
下一章:模組。

