本文 AI 產出,尚未審核

JavaScript – 事件循環(Event Loop)與非同步程式設計

主題:async / await


簡介

在現代的前端與 Node.js 應用程式中,非同步 已成為不可或缺的基礎概念。無論是向伺服器請求資料、讀寫檔案,或是計時器、事件處理,都必須透過非同步方式避免 UI 卡死或伺服器阻塞。

ECMAScript 2017(ES8)正式加入了 async / await 語法,讓原本需要大量 Promise 鏈或回呼函式(callback)的程式碼,變得 更直觀、易讀且易於維護。本單元將從事件循環的運作原理切入,說明 async / await 背後的機制,並提供實務範例、常見陷阱與最佳實踐,協助你在真實專案中安全、有效地使用這套語法。


核心概念

1. 事件循環與任務隊列

JavaScript 是單執行緒語言,但透過 事件循環(Event Loop),它可以同時處理多個非同步任務。簡單來說:

  1. 呼叫堆疊(Call Stack):同步程式碼依序執行,執行完畢後彈出堆疊。
  2. 任務隊列(Task Queue):setTimeout、click 之類的事件會被放入此隊列,待 Call Stack 為空時由事件循環取出執行。
  3. 微任務(Micro‑task):Promise 的 .then/.catch、queueMicrotask 等會在 同一輪事件循環 的最後、下一輪任務隊列之前執行,優先級高於普通任務。

重點:async 函式在執行時會自動回傳一個 已解決(resolved)或已拒絕(rejected)的 Promise,而 await 會暫停當前的 async 函式,把控制權交還給事件循環,待 Promise 完成後再回到函式繼續執行。

2. async 與 await 的語法

語法 說明
async function foo() { … } 宣告一個 async 函式,此函式會自動回傳 Promise。
await expression 必須寫在 async 函式內,暫停執行直到 expression(必須是 Promise)解決或拒絕。若 expression 不是 Promise,會被 立即包裝成已解決的 Promise。

注意:await 只會暫停所在的 async 函式,不會阻塞整個執行緒,其他同步程式仍會繼續執行。

3. 基本範例:從回呼到 Promise 再到 async/await

// 1️⃣ 傳統回呼(callback)寫法
function getDataCb(url, cb) {
  const xhr = new XMLHttpRequest();
  xhr.open('GET', url);
  xhr.onload = () => cb(null, xhr.responseText);
  xhr.onerror = () => cb(new Error('Network error'));
  xhr.send();
}

// 2️⃣ Promise 版
function getDataPromise(url) {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.open('GET', url);
    xhr.onload = () => resolve(xhr.responseText);
    xhr.onerror = () => reject(new Error('Network error'));
    xhr.send();
  });
}

// 3️⃣ async / await 版(最直觀)
async function getData(url) {
  const response = await fetch(url);          // fetch 內建回傳 Promise
  if (!response.ok) throw new Error('Bad response');
  const text = await response.text();         // 再次 await 取得內容
  return text;                                // 仍回傳 Promise
}

// 使用方式(同樣支援 Promise)
getData('https://example.com')
  .then(data => console.log(data))
  .catch(err => console.error(err));

小結:從回呼到 Promise 再到 async/await,程式碼的層次結構變得更像同步流程,易於除錯與維護。

4. 多個 await 的執行順序

async function sequential() {
  console.time('seq');
  const a = await fetch('/api/a'); // 第一步:等待完成
  const b = await fetch('/api/b'); // 第二步:等 a 完成後才開始
  const c = await fetch('/api/c'); // 第三步:等 b 完成後才開始
  console.timeEnd('seq');
  return [a, b, c];
}

在上述程式碼中,每一次 await 都會等前一個 Promise 完成,因此總耗時大約是三個請求的總和。

5. 並行(Parallel)執行多個非同步作業

若不需要前後相依,可使用 Promise.all 搭配 await 讓請求同時發送:

async function parallel() {
  console.time('par');
  const [a, b, c] = await Promise.all([
    fetch('/api/a'),
    fetch('/api/b'),
    fetch('/api/c')
  ]);
  console.timeEnd('par');   // 時間約等於最慢的一個請求
  return [a, b, c];
}

技巧:把 await 放在 Promise.all 之外,而不是分別寫 await fetch(...),才能真正達到 並行 效果。

6. 錯誤處理:try / catch 與 .catch 的差異

async function fetchWithTry(url) {
  try {
    const res = await fetch(url);
    if (!res.ok) throw new Error('HTTP error');
    const data = await res.json();
    return data;                     // 成功回傳
  } catch (err) {
    console.error('Fetch failed:', err);
    // 可自行拋出或回傳預設值
    throw err;                       // 讓呼叫端再次捕捉
  }
}

// 呼叫端可以使用 .catch 或 try/catch
fetchWithTry('/api/data')
  .then(d => console.log(d))
  .catch(e => console.warn('Handled at top:', e));

使用 try / catch 可以在 同一層級 捕捉多個 await 的錯誤,讓程式碼更簡潔;而直接在 Promise 鏈 上使用 .catch 則適合只想捕捉最外層錯誤的情況。

7. await 與非 Promise 值

async function demo() {
  const num = await 42;          // 立即轉成已解決的 Promise
  console.log(num); // 42
}
demo();

即使傳入的是普通值,await 仍會將其包裝成 Promise.resolve(value),因此不會拋出錯誤,也不會造成額外的非同步延遲。

8. 取消(Cancellation)與 Timeout

async/await 本身不支援取消,但可結合 AbortController 或自行包裝 Promise.race 來實作:

async function fetchWithTimeout(url, ms) {
  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), ms);

  try {
    const response = await fetch(url, { signal: controller.signal });
    clearTimeout(timeout);
    return await response.json();
  } catch (err) {
    if (err.name === 'AbortError') {
      throw new Error('Request timed out');
    }
    throw err;
  }
}

常見陷阱與最佳實踐

陷阱 說明 最佳做法
忘記 await 忽略 await 會讓 Promise 直接回傳,導致後續程式碼在未完成的狀態下執行。 務必檢查 每個需要結果的非同步呼叫是否已加上 await,或使用 Promise.all 一次處理多筆。
在迴圈內使用 await 逐一執行 會導致 序列化 執行,效能低下。 使用 Promise.all 或 for…of 搭配 await Promise.all(promises) 讓迭代 並行。
未捕捉錯誤 await 若拋出錯誤,若未包在 try/catch 中,會導致未處理的 Promise Rejection。 在每個 async 函式最外層統一捕捉,或在呼叫端使用 .catch。
在同步迴圈外部使用 await 例如 array.map(item => await foo(item)) 會產生語法錯誤。 先產生 Promise 陣列:array.map(item => foo(item)),再 await Promise.all([...])。
過度使用 async 把每個小函式都標記為 async,會產生不必要的 Promise 包裝,影響效能與可讀性。 只在需要返回 Promise或**使用 await**的函式上加 async。
在 finally 中使用 await finally 仍屬於同步區塊,await 會被忽略或產生意外行為。 若需在 finally 內執行非同步清理,改寫為 try { … } catch { … } finally { await cleanUp(); },或把清理抽成另一個 async 函式在外層 await。

建議的程式碼風格

  1. 統一使用 async / await,盡量避免混用回呼與 Promise。
  2. 命名慣例:以 fetchXxx、loadXxx 類似動詞開頭,表明其返回 Promise。
  3. 保持最小的作用域:只在需要的區塊內使用 await,減少不必要的阻塞。
  4. 避免深層嵌套:使用 await 可以把原本的 .then 鏈平鋪成直線,若仍有多層嵌套,考慮抽成子函式。
  5. 使用 Lint 規則(如 eslint-plugin-promise、eslint-plugin-no-async-promise-executor)自動偵測常見錯誤。

實際應用場景

場景 為何使用 async/await
API 串接(例如取得使用者資料、發送表單) 讓多個請求以 並行 或 序列 的方式寫出,程式碼如同同步流程,易於除錯。
伺服器端資料庫查詢(Node.js + MongoDB / PostgreSQL) await db.collection.findOne() 能直接取得結果,避免回呼地獄,且能在 try/catch 中捕捉 DB 錯誤。
檔案 I/O(Node.js fs.promises) 讀寫檔案時使用 await fs.readFile(path),讓程式在等待磁碟 I/O 時不阻塞事件循環。
串流處理(Web Streams、Node.js Stream) 透過 for await (const chunk of stream) 直接遍歷非同步資料流,語法直觀。
測試框架(Jest、Mocha) 測試非同步函式時直接寫 await,測試結構更簡潔,且能正確捕捉錯誤。
限速(Rate Limiting)或重試機制 結合 await delay(ms) 與 try/catch,實作簡潔的重試迴圈。

範例:Node.js 中的批次匯入

const fs = require('fs').promises;
const db = require('./db'); // 假設是一個 Promise 介面的 ORM

async function importCsv(filePath) {
  const data = await fs.readFile(filePath, 'utf8');
  const rows = data.split('\n').filter(Boolean);

  // 逐行插入,使用 transaction 以確保原子性
  const client = await db.connect();
  try {
    await client.query('BEGIN');
    const promises = rows.map(row => {
      const [name, email] = row.split(',');
      return client.query(
        'INSERT INTO users(name, email) VALUES($1, $2)',
        [name.trim(), email.trim()]
      );
    });
    // 並行寫入
    await Promise.all(promises);
    await client.query('COMMIT');
    console.log('匯入完成');
  } catch (err) {
    await client.query('ROLLBACK');
    console.error('匯入失敗,已回滾', err);
    throw err;
  } finally {
    client.release();
  }
}

importCsv('./data/users.csv')
  .catch(e => console.error('整體錯誤:', e));

此範例展示了 async/await 結合 transaction、錯誤回滾 與 並行寫入 的完整流程,程式碼結構清晰且易於維護。


總結

  • async / await 是 Promise 的語法糖,讓非同步流程看起來像同步程式,降低認知負擔。
  • 它的運作依賴 事件循環 與 微任務隊列,await 會暫停所在的 async 函式,並把控制權交還給事件循環,待 Promise 解決後再恢復執行。
  • 正確使用 await(避免在迴圈內序列化、適時使用 Promise.all)可大幅提升效能;錯誤處理 建議以 try / catch 包圍需要捕捉的區塊。
  • 常見陷阱包括忘記 await、在迴圈內錯誤使用 await、未捕捉異常等,遵守最佳實踐與 Lint 規則能有效避免。
  • 在實務上,從前端 API 呼叫、Node.js 資料庫操作、檔案 I/O 到測試與批次任務,async / await 都是提升程式可讀性與可靠性的關鍵工具。

掌握了這套語法,你就能在 事件循環 的框架下,寫出既 高效 又 易於維護 的非同步程式碼。祝你在 JavaScript 的非同步世界裡玩得開心、寫得順手!