上一章介紹的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的模組系統,順便解決掉這個問題。
下一章:模組。