本文 AI 產出,尚未審核

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. asyncawait 的基本語法

  • 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 / catchPromise.catch

async 函式內,任何 awaitPromise 若被拒絕,都會拋出例外。最直觀的做法是使用 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.allSettledPromise.race 等方法,配合 await 取得更彈性的行為。

5. 內建的 async 函式與第三方套件

許多 Node.js 原生模組(如 fs.promisescrypto.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,導致程式提前結束或錯誤難以捕捉。 AlwaysawaitPromise.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

最佳實踐

  1. 保持函式純粹async 函式應盡量只負責一件事,避免同時處理多個不同層面的邏輯。
  2. 型別安全:使用 TypeScript 的 Awaited<T> 取得 await 後的型別,提升編譯期檢查。
    type ApiResult = Awaited<ReturnType<typeof fetchUser>>; // 自動推斷結果型別
    
  3. 資源釋放:對於需要關閉的資源(如資料庫連線、檔案描述符),使用 try/finally 確保釋放。
    const client = await db.connect();
    try {
      const rows = await client.query('SELECT * FROM users');
      return rows;
    } finally {
      client.release(); // 永遠會執行
    }
    
  4. 限制同時併發數:大量並行請求時,可使用 p-limitp-queue 等套件控制併發度,避免過度佔用系統資源。
  5. 紀錄與監控:在 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/awaitNode.js + TypeScript 帶來了類似同步程式碼的可讀性,同時保留了非同步的效能優勢。
  • 透過 型別推斷錯誤捕捉、以及 並行/串行 的概念,我們可以寫出 安全、可維護且效能友好 的程式。
  • 在實務開發中,常見的陷阱(忘記 await、迴圈內串行、未捕捉錯誤)只要遵守 最佳實踐(統一使用 Promise、適當使用 try/catch、控制併發)即可輕鬆避免。
  • 文章最後示範的 API、交易、檔案處理 三大場景,正是 async/await 在 Node.js 生態系最常見的應用,讀者可以直接套用或改寫成自己的專案需求。

掌握了這些核心概念與實務技巧,你就能在 TypeScript + Node.js 的開發旅程中,寫出更乾淨、更可靠的非同步程式碼。祝開發順利,持續探索更高階的主題吧! 🚀