TypeScript 課程:Node.js + TypeScript(實務應用)— async/await 在 Node 中的使用
簡介
在現代的 Node.js 開發中,非同步程式設計已成為必備技能。過去常用的回呼(callback)與 Promise 雖然功能完整,卻容易造成「回呼地獄」或是程式碼可讀性低的問題。ECMAScript 2017 引入的 async/await,讓非同步流程看起來像同步程式碼一樣直觀,極大提升開發效率與維護性。
對於使用 TypeScript 的 Node.js 專案而言,async/await 不僅保留了 JavaScript 的簡潔語法,還能透過型別系統在編譯期捕捉錯誤。掌握它的核心概念與實務應用,能讓你在撰寫 API、資料庫操作、檔案 I/O 等場景時,寫出更安全、可讀、可測試的程式碼。
本篇文章將從概念說明、實作範例、常見陷阱與最佳實踐,逐步帶領 初學者到中級開發者 完成從「會用」到「寫得好」的轉變。
核心概念
1. async 與 await 的基本語法
async:用於宣告一個函式,使其自動回傳Promise。即使函式內部沒有await,也會被包裝成Promise.resolve(回傳值)。await:只能在async函式內使用,會 暫停 當前函式的執行,等候Promise解決(resolve)或拒絕(reject),再繼續往下跑。
// 範例 1:最簡單的 async/await
async function greet(name: string): Promise<string> {
// 等待 1 秒鐘的非同步操作
await new Promise(res => setTimeout(res, 1000));
return `Hello, ${name}!`;
}
// 呼叫方式
greet('Node').then(msg => console.log(msg)); // 1 秒後印出 Hello, Node!
重點:
await會把Promise的結果「解包」成原始值(或拋出錯誤),讓後續程式碼不必再寫.then()。
2. TypeScript 中的型別推斷
async 函式的回傳型別會自動被推斷為 Promise<原始回傳型別>。若要手動指定,直接在函式宣告時寫上 Promise<...> 即可。
// 範例 2:型別推斷與手動指定
async function fetchNumber(): Promise<number> {
// 假設這裡是呼叫外部 API,回傳數字
return 42;
}
// 使用時直接取得 number
async function demo() {
const result = await fetchNumber(); // result 的型別是 number
console.log(result);
}
3. 錯誤處理:try / catch 與 Promise.catch
在 async 函式內,任何 await 的 Promise 若被拒絕,都會拋出例外。最直觀的做法是使用 try / catch 包住可能失敗的程式碼。
// 範例 3:錯誤捕捉
async function readFile(path: string): Promise<string> {
const fs = require('fs').promises;
try {
const data = await fs.readFile(path, 'utf-8');
return data;
} catch (err) {
// 重新拋出自訂錯誤,保留型別資訊
throw new Error(`讀取檔案失敗: ${(err as Error).message}`);
}
}
4. 並行(Parallel)與串行(Sequential)執行
- 串行:使用多個
await,每一步都必須等前一步完成。適合相依性高的流程。 - 並行:先把
Promise建立起來,再一次await Promise.all(...),可大幅縮短總執行時間。
// 範例 4:串行 vs 並行
async function fetchAllSerial() {
const a = await fetch('https://api.example.com/a');
const b = await fetch('https://api.example.com/b');
const c = await fetch('https://api.example.com/c');
return [a, b, c];
}
async function fetchAllParallel() {
const pA = fetch('https://api.example.com/a');
const pB = fetch('https://api.example.com/b');
const pC = fetch('https://api.example.com/c');
// 同時等待三個請求完成
return Promise.all([pA, pB, pC]);
}
技巧:若你只需要其中的某些結果,仍可使用
Promise.allSettled、Promise.race等方法,配合await取得更彈性的行為。
5. 內建的 async 函式與第三方套件
許多 Node.js 原生模組(如 fs.promises、crypto.promises)已提供 Promise 版 API,直接搭配 await 使用即可。對於尚未支援 Promise 的套件,可使用 util.promisify 轉換:
// 範例 5:利用 util.promisify 包裝回呼函式
import { promisify } from 'util';
import fs from 'fs';
const readFileAsync = promisify(fs.readFile);
async function readConfig() {
const data = await readFileAsync('./config.json', 'utf8');
return JSON.parse(data);
}
常見陷阱與最佳實踐
| 陷阱 | 說明 | 建議的解決方式 |
|---|---|---|
忘記加 await |
呼叫 async 函式卻未使用 await,會得到未解決的 Promise,導致程式提前結束或錯誤難以捕捉。 |
Always 用 await 或 Promise.then 處理回傳值。 |
在迴圈內直接 await |
會造成串行執行,效能大幅下降。 | 把所有 Promise 收集起來,最後一次 await Promise.all(...)。 |
未處理 Promise 錯誤 |
await 內的錯誤若未被 try/catch 捕捉,會導致未捕獲的例外,程式崩潰。 |
在最外層的 async 函式加入 try/catch,或使用 process.on('unhandledRejection') 監控。 |
混用回呼與 async/await |
會產生不一致的錯誤處理邏輯,難以維護。 | 盡量統一使用 Promise/async,必要時使用 promisify 轉換。 |
忘記回傳 Promise |
在 async 函式內忘記 return,會得到 undefined 包裝成 Promise<void>。 |
確認每條分支都有正確的 return。 |
最佳實踐
- 保持函式純粹:
async函式應盡量只負責一件事,避免同時處理多個不同層面的邏輯。 - 型別安全:使用 TypeScript 的
Awaited<T>取得await後的型別,提升編譯期檢查。type ApiResult = Awaited<ReturnType<typeof fetchUser>>; // 自動推斷結果型別 - 資源釋放:對於需要關閉的資源(如資料庫連線、檔案描述符),使用
try/finally確保釋放。const client = await db.connect(); try { const rows = await client.query('SELECT * FROM users'); return rows; } finally { client.release(); // 永遠會執行 } - 限制同時併發數:大量並行請求時,可使用
p-limit、p-queue等套件控制併發度,避免過度佔用系統資源。 - 紀錄與監控:在
catch區塊內加入日誌(如 Winston、Pino),以及適當的錯誤上報,方便後續排錯。
實際應用場景
1. 建立 RESTful API(Express + TypeScript)
import express, { Request, Response, NextFunction } from 'express';
import { getUserById, createUser } from './services/userService';
const app = express();
app.use(express.json());
// 取得單一使用者
app.get('/users/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
const user = await getUserById(parseInt(req.params.id, 10));
if (!user) return res.status(404).json({ message: 'User not found' });
res.json(user);
} catch (err) {
next(err); // 交給全域錯誤處理中介軟體
}
});
// 新增使用者
app.post('/users', async (req: Request, res: Response, next: NextFunction) => {
try {
const newUser = await createUser(req.body);
res.status(201).json(newUser);
} catch (err) {
next(err);
}
});
// 全域錯誤處理
app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => {
console.error(err);
res.status(500).json({ message: err.message });
});
app.listen(3000, () => console.log('Server listening on :3000'));
說明
- 每個路由都使用
async,讓資料庫或遠端 API 的呼叫寫成同步風格。 try/catch搭配 Express 的錯誤中介軟體,確保例外不會直接崩潰伺服器。
2. 與資料庫的交易(Transaction)
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function transferFunds(
fromId: number,
toId: number,
amount: number
): Promise<void> {
await prisma.$transaction(async (tx) => {
const from = await tx.account.update({
where: { id: fromId },
data: { balance: { decrement: amount } },
});
if (from.balance < 0) {
throw new Error('餘額不足');
}
await tx.account.update({
where: { id: toId },
data: { balance: { increment: amount } },
});
});
}
說明
- Prisma 的
$transaction接受async回呼,內部所有await操作會在同一個交易中執行。 - 若任何一步拋出例外,整筆交易自動回滾,保證資料一致性。
3. 大量檔案處理(並行 I/O)
import { promises as fs } from 'fs';
import path from 'path';
import pLimit from 'p-limit';
const limit = pLimit(5); // 同時最多 5 個檔案操作
async function processDirectory(dir: string) {
const entries = await fs.readdir(dir, { withFileTypes: true });
const tasks = entries.map(entry => limit(async () => {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
await processDirectory(fullPath); // 遞迴
} else if (entry.isFile() && entry.name.endsWith('.txt')) {
const content = await fs.readFile(fullPath, 'utf-8');
const transformed = content.toUpperCase();
await fs.writeFile(fullPath.replace('.txt', '.out'), transformed);
}
}));
await Promise.all(tasks);
}
說明
- 使用
p-limit控制同時 I/O 數量,避免對磁碟造成過大壓力。 await Promise.all(tasks)讓所有子任務完成後才返回,保證遞迴結束。
總結
async/await為 Node.js + TypeScript 帶來了類似同步程式碼的可讀性,同時保留了非同步的效能優勢。- 透過 型別推斷、錯誤捕捉、以及 並行/串行 的概念,我們可以寫出 安全、可維護且效能友好 的程式。
- 在實務開發中,常見的陷阱(忘記
await、迴圈內串行、未捕捉錯誤)只要遵守 最佳實踐(統一使用 Promise、適當使用try/catch、控制併發)即可輕鬆避免。 - 文章最後示範的 API、交易、檔案處理 三大場景,正是
async/await在 Node.js 生態系最常見的應用,讀者可以直接套用或改寫成自己的專案需求。
掌握了這些核心概念與實務技巧,你就能在 TypeScript + Node.js 的開發旅程中,寫出更乾淨、更可靠的非同步程式碼。祝開發順利,持續探索更高階的主題吧! 🚀