使用 Promise
一個 Promise 是一個物件,表示一個非同步操作的最終完成或失敗。由於大多數人都是已建立 Promise 的使用者,本指南將先解釋如何消費返回的 Promise,然後再解釋如何建立它們。
本質上,Promise 是一個返回的物件,你可以將回調函式附加到它上面,而不是將回調函式作為引數傳遞給函式。想象一個函式 createAudioFileAsync(),它根據配置記錄和兩個回撥函式非同步生成一個聲音檔案:一個在音訊檔案成功建立時呼叫,另一個在發生錯誤時呼叫。
以下是一些使用 createAudioFileAsync() 的程式碼:
function successCallback(result) {
console.log(`Audio file ready at URL: ${result}`);
}
function failureCallback(error) {
console.error(`Error generating audio file: ${error}`);
}
createAudioFileAsync(audioSettings, successCallback, failureCallback);
如果 createAudioFileAsync() 被重寫為返回一個 Promise,你將把回撥函式附加到它上面,而不是
createAudioFileAsync(audioSettings).then(successCallback, failureCallback);
這種約定有幾個優點。我們將逐一探討。
鏈式呼叫
一個常見的需求是執行兩個或更多非同步操作,其中每個後續操作在前一個操作成功後開始,並接收前一步驟的結果。在過去,連續執行多個非同步操作會導致經典的回撥地獄
doSomething(function (result) {
doSomethingElse(result, function (newResult) {
doThirdThing(newResult, function (finalResult) {
console.log(`Got the final result: ${finalResult}`);
}, failureCallback);
}, failureCallback);
}, failureCallback);
使用 Promise,我們透過建立 Promise 鏈來實現這一點。Promise 的 API 設計使其非常出色,因為回撥函式是附加到返回的 Promise 物件上的,而不是作為引數傳遞給函式。
神奇之處在於:then() 函式返回一個**新的 Promise**,與原始 Promise 不同
const promise = doSomething();
const promise2 = promise.then(successCallback, failureCallback);
這個第二個 Promise (promise2) 不僅代表 doSomething() 的完成,也代表你傳入的 successCallback 或 failureCallback 的完成——它們可以是返回 Promise 的其他非同步函式。在這種情況下,新增到 promise2 的任何回撥函式都會排隊在由 successCallback 或 failureCallback 返回的 Promise 之後。
注意:如果你想要一個可用的示例來嘗試,你可以使用以下模板來建立任何返回 Promise 的函式
function doSomething() {
return new Promise((resolve) => {
setTimeout(() => {
// Other things to do before completion of the promise
console.log("Did something");
// The fulfillment value of the promise
resolve("https://example.com/");
}, 200);
});
}
具體實現將在下面的圍繞舊回撥 API 建立 Promise 部分討論。
透過這種模式,你可以建立更長的處理鏈,其中每個 Promise 代表鏈中一個非同步步驟的完成。此外,then 的引數是可選的,catch(failureCallback) 是 then(null, failureCallback) 的簡寫形式——所以如果你的錯誤處理程式碼對所有步驟都相同,你可以將其附加到鏈的末尾
doSomething()
.then(function (result) {
return doSomethingElse(result);
})
.then(function (newResult) {
return doThirdThing(newResult);
})
.then(function (finalResult) {
console.log(`Got the final result: ${finalResult}`);
})
.catch(failureCallback);
你可能會看到使用箭頭函式來表達
doSomething()
.then((result) => doSomethingElse(result))
.then((newResult) => doThirdThing(newResult))
.then((finalResult) => {
console.log(`Got the final result: ${finalResult}`);
})
.catch(failureCallback);
注意:箭頭函式表示式可以有隱式返回;因此,() => x 是 () => { return x; } 的簡寫。
doSomethingElse 和 doThirdThing 可以返回任何值——如果它們返回 Promise,那麼這個 Promise 會首先等待直到它解決,下一個回撥函式會接收到履行值,而不是 Promise 本身。重要的是始終從 then 回撥中返回 Promise,即使該 Promise 總是解析為 undefined。如果前一個處理程式啟動了一個 Promise 但沒有返回它,就無法再跟蹤它的解決狀態,該 Promise 被稱為“浮動”的。
doSomething()
.then((url) => {
// Missing `return` keyword in front of fetch(url).
fetch(url);
})
.then((result) => {
// result is undefined, because nothing is returned from the previous
// handler. There's no way to know the return value of the fetch()
// call anymore, or whether it succeeded at all.
});
透過返回 fetch 呼叫的結果(它是一個 Promise),我們既可以跟蹤它的完成,又可以在它完成時接收到它的值。
doSomething()
.then((url) => {
// `return` keyword added
return fetch(url);
})
.then((result) => {
// result is a Response object
});
如果你有競態條件,浮動 Promise 可能會更糟——如果上一個處理程式中的 Promise 沒有返回,下一個 then 處理程式將提前呼叫,它讀取的任何值可能都不完整。
const listOfIngredients = [];
doSomething()
.then((url) => {
// Missing `return` keyword in front of fetch(url).
fetch(url)
.then((res) => res.json())
.then((data) => {
listOfIngredients.push(data);
});
})
.then(() => {
console.log(listOfIngredients);
// listOfIngredients will always be [], because the fetch request hasn't completed yet.
});
因此,作為經驗法則,每當你的操作遇到 Promise 時,就返回它並將其處理推遲到下一個 then 處理程式。
const listOfIngredients = [];
doSomething()
.then((url) => {
// `return` keyword now included in front of fetch call.
return fetch(url)
.then((res) => res.json())
.then((data) => {
listOfIngredients.push(data);
});
})
.then(() => {
console.log(listOfIngredients);
// listOfIngredients will now contain data from fetch call.
});
更好的是,你可以將巢狀鏈扁平化為單個鏈,這更簡單,並且使錯誤處理更容易。詳細內容將在下面的巢狀部分討論。
doSomething()
.then((url) => fetch(url))
.then((res) => res.json())
.then((data) => {
listOfIngredients.push(data);
})
.then(() => {
console.log(listOfIngredients);
});
使用async/await 可以幫助你編寫更直觀、更像同步程式碼的程式碼。下面是使用 async/await 的相同示例
async function logIngredients() {
const url = await doSomething();
const res = await fetch(url);
const data = await res.json();
listOfIngredients.push(data);
console.log(listOfIngredients);
}
請注意,除了 Promise 前面的 await 關鍵字外,程式碼看起來與同步程式碼完全一樣。唯一的權衡之一是,很容易忘記 await 關鍵字,這隻有在型別不匹配時(例如,嘗試將 Promise 用作值)才能修復。
async/await 建立在 Promise 的基礎上——例如,doSomething() 和以前是相同的函式,因此從 Promise 更改為 async/await 所需的重構很少。你可以在非同步函式和await參考中閱讀有關 async/await 語法的更多資訊。
注意:async/await 具有與普通 Promise 鏈相同的併發語義。一個非同步函式中的 await 不會停止整個程式,只會停止依賴其值的部分,因此其他非同步作業在 await 掛起時仍然可以執行。
錯誤處理
你可能會記得,在前面的回撥地獄中,failureCallback 出現了三次,而 Promise 鏈的末尾只出現了一次
doSomething()
.then((result) => doSomethingElse(result))
.then((newResult) => doThirdThing(newResult))
.then((finalResult) => console.log(`Got the final result: ${finalResult}`))
.catch(failureCallback);
如果出現異常,瀏覽器將沿著鏈查詢 .catch() 處理程式或 onRejected。這與同步程式碼的工作方式非常相似
try {
const result = syncDoSomething();
const newResult = syncDoSomethingElse(result);
const finalResult = syncDoThirdThing(newResult);
console.log(`Got the final result: ${finalResult}`);
} catch (error) {
failureCallback(error);
}
這種與非同步程式碼的對稱性最終體現在 async/await 語法中
async function foo() {
try {
const result = await doSomething();
const newResult = await doSomethingElse(result);
const finalResult = await doThirdThing(newResult);
console.log(`Got the final result: ${finalResult}`);
} catch (error) {
failureCallback(error);
}
}
Promise 解決了回撥地獄的基本缺陷,它捕獲所有錯誤,甚至是丟擲的異常和程式設計錯誤。這對於非同步操作的功能組合至關重要。所有錯誤現在都由鏈末尾的 catch() 方法處理,在不使用 async/await 的情況下,你幾乎永遠不需要使用 try/catch。
巢狀
在上面涉及 listOfIngredients 的示例中,第一個示例將一個 Promise 鏈巢狀在另一個 then() 處理程式的返回值中,而第二個示例使用完全扁平的鏈。簡單的 Promise 鏈最好保持扁平,不要巢狀,因為巢狀可能是粗心組合的結果。
巢狀是一種控制結構,用於限制 catch 語句的作用域。具體來說,巢狀的 catch 只捕獲其作用域及以下範圍內的失敗,而不捕獲鏈中巢狀作用域之外的更高層錯誤。如果使用得當,這可以在錯誤恢復中提供更高的精度
doSomethingCritical()
.then((result) =>
doSomethingOptional(result)
.then((optionalResult) => doSomethingExtraNice(optionalResult))
.catch((e) => {}),
) // Ignore if optional stuff fails; proceed.
.then(() => moreCriticalStuff())
.catch((e) => console.error(`Critical failure: ${e.message}`));
請注意,這裡的可選步驟是巢狀的——巢狀不是由縮排引起的,而是由圍繞這些步驟的外部 ( 和 ) 括號的位置引起的。
內部的錯誤靜默 catch 處理程式只捕獲 doSomethingOptional() 和 doSomethingExtraNice() 的失敗,之後程式碼會繼續執行 moreCriticalStuff()。重要的是,如果 doSomethingCritical() 失敗,其錯誤只會被最終(外部)的 catch 捕獲,而不會被內部 catch 處理程式吞噬。
在 async/await 中,這段程式碼看起來像
async function main() {
try {
const result = await doSomethingCritical();
try {
const optionalResult = await doSomethingOptional(result);
await doSomethingExtraNice(optionalResult);
} catch (e) {
// Ignore failures in optional steps and proceed.
}
await moreCriticalStuff();
} catch (e) {
console.error(`Critical failure: ${e.message}`);
}
}
注意:如果你沒有複雜的錯誤處理,你很可能不需要巢狀的 then 處理程式。相反,使用扁平鏈並將錯誤處理邏輯放在末尾。
catch 後的鏈式呼叫
在失敗(即 catch)*之後*進行鏈式呼叫是可能的,這對於即使操作在鏈中失敗後也能執行新操作很有用。請閱讀以下示例
doSomething()
.then(() => {
throw new Error("Something failed");
console.log("Do this");
})
.catch(() => {
console.error("Do that");
})
.then(() => {
console.log("Do this, no matter what happened before");
});
這將輸出以下文字
Do that Do this, no matter what happened before
注意:文字“Do this”沒有顯示,因為“Something failed”錯誤導致了拒絕。
在 async/await 中,這段程式碼看起來像
async function main() {
try {
await doSomething();
throw new Error("Something failed");
console.log("Do this");
} catch (e) {
console.error("Do that");
}
console.log("Do this, no matter what happened before");
}
Promise 拒絕事件
如果 Promise 拒絕事件未被任何處理程式處理,它會冒泡到呼叫堆疊的頂部,並且宿主需要將其浮出水面。在 Web 上,每當 Promise 被拒絕時,兩個事件中的一個會被髮送到全域性作用域(通常,這是 window,或者,如果在 Web Worker 中使用,則是 Worker 或其他基於 Worker 的介面)。這兩個事件是
unhandledrejection-
當 Promise 被拒絕但沒有可用的拒絕處理程式時傳送。
rejectionhandled-
當處理程式附加到已導致
unhandledrejection事件的被拒絕 Promise 時傳送。
在這兩種情況下,事件(型別為 PromiseRejectionEvent)都具有 promise 屬性,指示被拒絕的 Promise,以及 reason 屬性,提供 Promise 被拒絕的原因。
這些使得為 Promise 提供回退錯誤處理以及幫助除錯 Promise 管理問題成為可能。這些處理程式是每個上下文全域性的,因此所有錯誤都將傳送到相同的事件處理程式,無論來源如何。
在 Node.js 中,處理 Promise 拒絕略有不同。你可以透過為 Node.js unhandledRejection 事件(注意名稱大小寫的差異)新增處理程式來捕獲未處理的拒絕,如下所示
process.on("unhandledRejection", (reason, promise) => {
// Add code here to examine the "promise" and "reason" values
});
對於 Node.js,為了防止錯誤被記錄到控制檯(否則會發生的預設操作),只需新增那個 process.on() 監聽器即可;不需要相當於瀏覽器執行時的 preventDefault() 方法。
然而,如果你添加了 process.on 監聽器,但其中沒有處理被拒絕 Promise 的程式碼,它們將只是被忽略,默默無聞。因此,理想情況下,你應該在該監聽器中新增程式碼來檢查每個被拒絕的 Promise,並確保它不是由實際的程式碼錯誤引起的。
組合
有四個用於併發執行非同步操作的組合工具:Promise.all()、Promise.allSettled()、Promise.any() 和 Promise.race()。
我們可以同時啟動操作並等待它們全部完成,如下所示
Promise.all([func1(), func2(), func3()]).then(([result1, result2, result3]) => {
// use result1, result2 and result3
});
如果陣列中的一個 Promise 被拒絕,Promise.all() 會立即拒絕返回的 Promise。其他操作會繼續執行,但它們的結果無法透過 Promise.all() 的返回值獲得。這可能會導致意外的狀態或行為。Promise.allSettled() 是另一個組合工具,它確保所有操作在解決之前都已完成。
這些方法都併發執行 Promise——一系列 Promise 同時啟動,並且彼此不等待。使用一些巧妙的 JavaScript 可以實現順序組合
[func1, func2, func3]
.reduce((p, f) => p.then(f), Promise.resolve())
.then((result3) => {
/* use result3 */
});
在這個例子中,我們將非同步函式陣列 reduce 成一個 Promise 鏈。上面的程式碼等效於
Promise.resolve()
.then(func1)
.then(func2)
.then(func3)
.then((result3) => {
/* use result3 */
});
這可以製作成一個可重用的組合函式,這在函數語言程式設計中很常見
const applyAsync = (acc, val) => acc.then(val);
const composeAsync =
(...funcs) =>
(x) =>
funcs.reduce(applyAsync, Promise.resolve(x));
composeAsync() 函式接受任意數量的函式作為引數,並返回一個新函式,該新函式接受一個初始值,該值將透過組合管道傳遞
const transformData = composeAsync(func1, func2, func3);
const result3 = transformData(data);
順序組合也可以用 async/await 更簡潔地完成
let result;
for (const f of [func1, func2, func3]) {
result = await f(result);
}
/* use last result (i.e. result3) */
然而,在順序組合 Promise 之前,請考慮它是否真的必要——最好總是併發執行 Promise,這樣它們就不會不必要地相互阻塞,除非一個 Promise 的執行依賴於另一個 Promise 的結果。
取消
Promise 本身沒有一流的取消協議,但你可能能夠直接取消底層非同步操作,通常使用 AbortController。
圍繞舊回撥 API 建立 Promise
一個 Promise 可以使用其建構函式從頭開始建立。這應該只在包裝舊 API 時才需要。
在理想世界中,所有非同步函式都將返回 Promise。不幸的是,一些 API 仍然期望以舊方式傳遞成功和/或失敗回撥函式。最明顯的例子是 setTimeout() 函式
setTimeout(() => saySomething("10 seconds passed"), 10 * 1000);
混合舊式回撥和 Promise 會有問題。如果 saySomething() 失敗或包含程式設計錯誤,則沒有任何東西捕獲它。這是 setTimeout() 設計固有的。
幸運的是,我們可以將 setTimeout() 包裝在一個 Promise 中。最佳實踐是在最低層包裝接受回撥的函式,然後永遠不要再次直接呼叫它們
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
wait(10 * 1000)
.then(() => saySomething("10 seconds"))
.catch(failureCallback);
Promise 建構函式接受一個執行器函式,它允許我們手動解決或拒絕 Promise。由於 setTimeout() 實際上不會失敗,我們在這裡省略了拒絕。有關執行器函式如何工作的更多資訊,請參閱 Promise() 參考。
時序
最後,我們將深入探討更技術性的細節,關於何時呼叫已註冊的回撥函式。
保證
在基於回撥的 API 中,回撥函式何時以及如何被呼叫取決於 API 實現者。例如,回撥函式可以同步或非同步呼叫
function doSomething(callback) {
if (Math.random() > 0.5) {
callback();
} else {
setTimeout(() => callback(), 1000);
}
}
上述設計強烈不推薦,因為它會導致所謂的“Zalgo 狀態”。在設計非同步 API 的上下文中,這意味著回撥在某些情況下同步呼叫,而在其他情況下非同步呼叫,從而給呼叫者造成歧義。有關更多背景資訊,請參閱文章Designing APIs for Asynchrony,其中首次正式提出了該術語。這種 API 設計使得副作用難以分析
let value = 1;
doSomething(() => {
value = 2;
});
console.log(value); // 1 or 2?
另一方面,Promise 是一種控制反轉形式——API 實現者不控制何時呼叫回撥函式。相反,維護回撥佇列和決定何時呼叫回撥函式的任務被委託給 Promise 實現,API 使用者和 API 開發人員都自動獲得強大的語義保證,包括
- 使用
then()新增的回撥函式永遠不會在 JavaScript 事件迴圈的當前執行完成之前被呼叫。 - 即使在表示 Promise 的非同步操作成功或失敗*之後*添加了這些回撥函式,它們也會被呼叫。
- 可以透過多次呼叫
then()來新增多個回撥函式。它們將按照插入的順序依次呼叫。
為了避免意外,傳遞給 then() 的函式永遠不會同步呼叫,即使對於已經解決的 Promise 也是如此
Promise.resolve().then(() => console.log(2));
console.log(1);
// Logs: 1, 2
傳遞的函式不會立即執行,而是被放入微任務佇列中,這意味著它稍後執行(只有在建立它的函式退出並且 JavaScript 執行堆疊為空之後),就在控制權返回到事件迴圈之前;也就是說,很快
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
wait(0).then(() => console.log(4));
Promise.resolve()
.then(() => console.log(2))
.then(() => console.log(3));
console.log(1); // 1, 2, 3, 4
任務佇列 vs. 微任務
Promise 回撥作為微任務處理,而 setTimeout() 回撥作為任務佇列處理。
const promise = new Promise((resolve, reject) => {
console.log("Promise callback");
resolve();
}).then((result) => {
console.log("Promise callback (.then)");
});
setTimeout(() => {
console.log("event-loop cycle: Promise (fulfilled)", promise);
}, 0);
console.log("Promise (pending)", promise);
上面的程式碼將輸出
Promise callback
Promise (pending) Promise {<pending>}
Promise callback (.then)
event-loop cycle: Promise (fulfilled) Promise {<fulfilled>}
有關更多詳細資訊,請參閱任務 vs. 微任務。
當 Promise 與任務衝突時
如果你遇到 Promise 和任務(例如事件或回撥)以不可預測的順序觸發的情況,你可能會受益於使用微任務來檢查狀態或在條件建立 Promise 時平衡你的 Promise。
如果你認為微任務可能有助於解決此問題,請參閱微任務指南,瞭解如何使用 queueMicrotask() 將函式作為微任務入隊。
另見
Promiseasync functionawait- Promises/A+ 規範
- pouchdb.com 上的我們遇到了 Promise 的問題 (2015)