Promise 與鏈式呼叫
簡介
在 JavaScript 裡,非同步程式碼的管理一直是開發者面臨的挑戰。從最早的 callback 到現在的 async/await,Promise 扮演了承上啟下的關鍵角色。它不僅讓非同步流程更具可讀性,也提供了 鏈式呼叫 (chaining) 的機制,讓我們可以把多個非同步步驟依序組合成一條清晰的執行路徑。
掌握 Promise 與鏈式呼叫,意味著能夠:
- 避免「回呼地獄」,讓程式結構更平坦。
- 統一錯誤處理,只要在最後一個
catch就能捕獲整條鏈的錯誤。 - 靈活組合非同步任務,例如串接 API、併行執行或延遲執行等。
本篇文章將從概念、實作範例、常見陷阱到最佳實踐,完整說明 Promise 與鏈式呼叫的使用方式,適合 初學者到中級開發者 參考。
核心概念
1. Promise 基礎
Promise 是一個代表未來完成或失敗的物件。它有三種狀態:
| 狀態 | 說明 |
|---|---|
| pending | 尚未完成或失敗,仍在等待中 |
| fulfilled | 任務成功完成,會傳遞一個值 |
| rejected | 任務失敗,會傳遞一個錯誤原因 |
// 建立一個簡單的 Promise
const delay = (ms) => {
return new Promise((resolve, reject) => {
if (ms < 0) {
// 直接 reject,示範錯誤情況
reject(new Error('延遲時間不能為負數'));
} else {
setTimeout(() => resolve(`等待了 ${ms} 毫秒`), ms);
}
});
};
2. then / catch / finally
then(onFulfilled, onRejected):當 Promise fulfilled 時呼叫第一個回呼,若 rejected 且提供第二個回呼則執行之。catch(onRejected):等同於then(undefined, onRejected),專門處理錯誤。finally(onFinally):不論成功或失敗,都會在最後執行,常用於清理資源。
delay(1000)
.then(msg => {
console.log('成功:', msg); // 成功: 等待了 1000 毫秒
return '下一步的資料';
})
.catch(err => {
console.error('錯誤:', err);
})
.finally(() => {
console.log('不論結果,都會執行這裡');
});
3. 鏈式呼叫的原理
每一次呼叫 then、catch 或 finally 都會 回傳一個新的 Promise。這個新 Promise 的狀態取決於回呼函式的回傳值:
| 回呼回傳值 | 新 Promise 的結果 |
|---|---|
| 普通值 (非 Promise) | 立即 fulfilled,值為回傳值 |
| Promise | 會 跟隨 該 Promise 的狀態,等同於「扁平化」 |
| 拋出錯誤 | 新 Promise 立即 rejected,錯誤會傳到下一個 catch |
因此,我們可以把多個非同步步驟串成一條鏈,每一步只需要關注自己的邏輯,而不必手動管理回呼層級。
程式碼範例
範例 1:基本的 Promise 鏈
// 假設有三個非同步 API,分別回傳字串
const fetchA = () => Promise.resolve('A');
const fetchB = (prev) => Promise.resolve(prev + 'B');
const fetchC = (prev) => Promise.resolve(prev + 'C');
fetchA()
.then(res => {
console.log(res); // A
return fetchB(res);
})
.then(res => {
console.log(res); // AB
return fetchC(res);
})
.then(res => {
console.log('最終結果:', res); // 最終結果: ABC
})
.catch(err => console.error(err));
重點:每一次
then都回傳新的 Promise,使得後續的then能取得前一步的結果。
範例 2:錯誤傳遞與捕獲
const riskyTask = (num) => {
return new Promise((resolve, reject) => {
if (num % 2 === 0) resolve(num);
else reject(new Error('奇數不允許'));
});
};
riskyTask(2)
.then(v => {
console.log('第一步成功:', v); // 2
return riskyTask(v + 1); // 3 -> 會 reject
})
.then(v => console.log('不會到這裡', v))
.catch(err => {
console.error('捕獲錯誤:', err.message); // 捕獲錯誤: 奇數不允許
// 仍然回傳一個值,讓後面的 then 可以繼續執行
return 0;
})
.then(v => console.log('恢復後的值:', v)); // 恢復後的值: 0
技巧:在
catch中回傳值會把錯誤「轉換」為成功狀態,讓鏈可以繼續。
範例 3:Promise.all 並行執行
const api1 = () => delay(500).then(() => '結果1');
const api2 = () => delay(300).then(() => '結果2');
const api3 = () => delay(700).then(() => '結果3');
Promise.all([api1(), api2(), api3()])
.then(results => {
console.log('全部完成:', results);
// => 全部完成: [ '結果1', '結果2', '結果3' ]
})
.catch(err => console.error('其中一個失敗:', err));
說明:
Promise.all會等所有 Promise 都 fulfilled 才 resolve,任一 reject 則立即 reject。
範例 4:把傳統 Callback 轉成 Promise
// 典型的 Node.js 風格 callback
const fs = require('fs');
function readFilePromise(path) {
return new Promise((resolve, reject) => {
fs.readFile(path, 'utf8', (err, data) => {
if (err) reject(err);
else resolve(data);
});
});
}
// 使用方式
readFilePromise('./data.txt')
.then(content => console.log('檔案內容:', content))
.catch(err => console.error('讀檔失敗:', err));
實務:在舊有程式庫中,將 callback 包裝成 Promise,讓它可以無縫加入鏈式呼叫。
範例 5:結合 async/await 的混合寫法
async function processData() {
try {
const a = await fetchA(); // 等同於 Promise
const b = await fetchB(a);
const c = await fetchC(b);
console.log('最終結果 (await):', c);
} catch (e) {
console.error('發生錯誤:', e);
} finally {
console.log('任務結束');
}
}
processData();
提示:
async/await本質上仍是 Promise,了解鏈式呼叫的原理能幫助你在兩者之間自由切換。
常見陷阱與最佳實踐
| 陷阱 | 為何會發生 | 解決方式 |
|---|---|---|
忘記 return |
在 then 內部沒有回傳 Promise,導致下一個 then 收到 undefined |
Always return the inner Promise or value |
| 錯誤被吞掉 | 在 then 中自行捕獲錯誤但未重新拋出,會讓外層 catch 收不到 |
在 catch 後 throw 或 return Promise.reject |
| 過度鏈式 | 鏈太長難以維護,且每一步都要寫 return |
使用 async/await 把邏輯拆成小函式 |
| 混用同步例外 | 在 then 內部直接拋出同步錯誤,若未被捕獲會變成 rejected,但有時不易意識到 |
用 try...catch 包住同步程式碼,或直接 return Promise.reject(err) |
| 忘記處理未捕獲的 Rejection | Node.js 會在未捕獲的 Promise rejection 時拋出警告,甚至終止程式 | 為所有最外層的 Promise 加上 .catch,或全局監聽 process.on('unhandledRejection') |
最佳實踐
- 始終返回 Promise:每個
then/catch都應回傳值或 Promise,讓鏈保持可預測。 - 集中錯誤處理:盡量在鏈的最後放一個
catch,或使用Promise.allSettled取得每個任務的成功/失敗結果。 - 使用
finally清理資源:如關閉檔案、取消計時器或隱藏 loading UI。 - 避免過度嵌套:如果同一層需要多個非同步操作,考慮
Promise.all或async/await。 - 保持可讀性:適度加入註解,並把長鏈拆成具名函式,例如
fetchUserData()、processOrder()。
實際應用場景
| 場景 | 為什麼適合使用 Promise 鏈 | 範例簡述 |
|---|---|---|
| 前端表單送出 | 需要依序驗證、上傳檔案、呼叫 API,且每一步若失敗都要中止 | validate() → uploadFile() → submitData() |
| 多階段資料處理 | 從資料庫抓取原始資料 → 轉換格式 → 寫入快取 → 回傳結果 | db.get() → transform() → cache.set() |
| 併行下載多個資源 | 多個下載任務可同時執行,最後一次性處理結果 | Promise.all([download1, download2, download3]) |
| 輪詢/重試機制 | 必須在失敗後延遲再重試,且每次重試都返回 Promise | retry(() => fetch(url), 3, 2000) |
| 舊有 Callback API 包裝 | 把第三方庫的 callback 轉成 Promise,統一錯誤處理 | 上述「readFilePromise」範例 |
總結
Promise 為 JavaScript 的非同步模型帶來了可組合、可預測的特性,而鏈式呼叫則是將多個非同步步驟像流水線一樣串起來的關鍵技巧。掌握以下要點,即可在實務開發中得心應手:
- 每個
then/catch必須回傳(值或 Promise),才能保證鏈的正確傳遞。 - 集中錯誤處理:最後的
catch能捕獲整條鏈的例外,減少重複程式碼。 - 適時使用
Promise.all、Promise.race、finally,以支援併行、競賽與資源清理。 - 遇到過長的鏈,考慮改寫成
async/await或拆成小函式,以提升可讀性。
只要熟練這套「Promise + 鏈式呼叫」的思維模式,你就能在前端與後端的非同步開發中,寫出 乾淨、可維護、錯誤可控 的程式碼。祝你寫程式愉快!