使用 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)