本文 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)setTimeoutclick 之類的事件會被放入此隊列,待 Call Stack 為空時由事件循環取出執行。
  3. 微任務(Micro‑task)Promise.then/.catchqueueMicrotask 等會在 同一輪事件循環 的最後、下一輪任務隊列之前執行,優先級高於普通任務。

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

2. asyncawait 的語法

語法 說明
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.allfor…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. 命名慣例:以 fetchXxxloadXxx 類似動詞開頭,表明其返回 Promise
  3. 保持最小的作用域:只在需要的區塊內使用 await,減少不必要的阻塞。
  4. 避免深層嵌套:使用 await 可以把原本的 .then 鏈平鋪成直線,若仍有多層嵌套,考慮抽成子函式。
  5. 使用 Lint 規則(如 eslint-plugin-promiseeslint-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 / awaitPromise 的語法糖,讓非同步流程看起來像同步程式,降低認知負擔。
  • 它的運作依賴 事件循環微任務隊列await 會暫停所在的 async 函式,並把控制權交還給事件循環,待 Promise 解決後再恢復執行。
  • 正確使用 await(避免在迴圈內序列化、適時使用 Promise.all)可大幅提升效能;錯誤處理 建議以 try / catch 包圍需要捕捉的區塊。
  • 常見陷阱包括忘記 await、在迴圈內錯誤使用 await、未捕捉異常等,遵守最佳實踐與 Lint 規則能有效避免。
  • 在實務上,從前端 API 呼叫、Node.js 資料庫操作、檔案 I/O 到測試與批次任務,async / await 都是提升程式可讀性與可靠性的關鍵工具。

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