确保生产环境中的异步 JavaScript 可靠性的九种承诺模式
学习实用的 Promise 模式——包括并行请求、超时处理、重试机制、并发限制以及取消功能——从而构建具备高稳定性的生产级异步 JavaScript 应用。
你不断使用 Promises,但只需掌握几种不太为人所知的模式,就能将复杂的异步代码转化为可预测且易于理解的形态。
大多数人是通过类似这样的示例来了解 Promises 的:
fetch("/api/users")
.then((res) => res.json())
.then((users) => console.log(users));
对于简单的脚本来说,确实只需要这些就够了。
但一旦涉及到生产级应用,问题就出现了。
这时你需要同时运行多个请求,而不再是一个接一个地执行。
你需要一种方法来取消那些已无关紧要的任务。
当请求失败时,你需要实现重试机制。
即使只有部分操作成功,你也需要继续执行后续步骤。
你需要防止同一个 API 调用意外被触发两次。
有时,你还需要限制同时运行的异步操作数量,以免后端同时承受过多压力。
从这一刻起,Promise不再只是初学者的话题,而真正成为一种设计工具。
以下是编写现代 JavaScript 时值得掌握的九种模式。
1. 并行执行独立的请求
异步代码中最简单的优势之一,往往也是最容易被忽视的。
假设你的页面需要三样内容:用户数据、通知以及分析信息。
常见的初步实现方式如下:
const users = await getUsers();
const notifications = await getNotifications();
const analytics = await getAnalytics();
Each request waits for the previous one.
每个 await 都会阻塞直到前一个调用完成,因此这些调用会按顺序依次执行。
假设每个调用大约需要 500 毫秒,那么这种顺序执行方式总共可能需要约 1.5 秒的等待时间。
如果这些调用之间实际上并无依赖关系,那就没有必要等待。
这正是 Promise.all() 的作用:
const [users, notifications, analytics] = await Promise.all([
getUsers(),
getNotifications(),
getAnalytics(),
]);
现在这三个请求会同时发送,而非轮流发送。
对于需要加载多个独立数据源的仪表板或任何界面,这能够显著缩短加载时间。
重要注意事项
Promise.all()会很快失败:一旦其中的任何一个 Promise 被拒绝,整个组都会随之被拒绝。
当每个请求都是必需的时候这没问题,但如果某些数据是可选的,就需要采用更灵活的处理方式。
2. 当部分失败可以接受时使用Promise.allSettled()
想象一个管理员仪表板,它显示以下内容:
- 收入
- 用户数量
- 通知信息
- 系统运行状态
如果通知服务发生故障,是否应该让整个界面变空白?
通常不必——失去一个面板不应导致页面其他部分也无法显示。
Promise.allSettled()正好解决了这个问题。
const results = await Promise.allSettled([
getRevenue(),
getUsers(),
getNotifications(),
getSystemHealth(),
]);
results.forEach((result) => {
if (result.status === "fulfilled") {
console.log("Success:", result.value);
} else {
console.error("Failed:", result.reason);
}
});
单个失败的调用不会再影响其旁边的成功结果。
这种模式在仪表板、分析界面和监控工具中非常有用,因为显示部分数据总比什么都不显示要好。
3. 为 Promise 添加超时功能
迟早你会遇到这种情况:如果请求永远没有返回会怎样?
如果没有保护措施,你的界面可能会无限挂起。
你可以创建一个小型、可重用的包装器来强制实施超时:
function withTimeout(promise, ms) {
const timeout = new Promise((_, reject) => {
setTimeout(() => {
reject(new Error("Operation timed out"));
}, ms);
});
return Promise.race([promise, timeout]);
}
然后在任何发起请求的地方使用它:
try {
const data = await withTimeout(fetch("/api/data"), 5000);
console.log(data);
} catch (error) {
console.error(error);
}
如果调用在五秒内未完成,该包装后的 Promise 会自动拒绝。
这比让用户一直看着永不结束的加载指示器要好得多。
4. 重试失败的操作
网络连接可能会中断。
服务器也会出现故障。
第三方 API 有时会表现异常。
但这些情况并不一定意味着用户就必须立即看到错误信息。
对于那些很可能是暂时性的故障,一个简单的重试机制就能起到很大作用。
async function retry(fn, attempts = 3) {
let lastError;
for (let i = 0; i < attempts; i++) {
try {
return await fn();
} catch (error) {
lastError = error;
}
}
throw lastError;
}
调用方式如下:
const data = await retry(
() => fetch("/api/data"),
3
);
这样,该操作在被视为真正失败之前会有几次额外的尝试机会。
但不要盲目地重试所有操作。
500 状态码通常表示服务器的暂时性问题。
而 401 Unauthorized 错误则不会因为再发送三次相同请求就能解决。
合理的重试逻辑能够区分哪些故障值得重试,哪些则不必。
5. 在重试之间添加延迟
在请求失败后立即触发重试并不总是明智之举。
想象一下那些已经在高负载下运转吃力的服务器。
如果成千上万的客户端同时尝试重试,只会给本就压力巨大的系统带来更多负担。
一个简单的延迟函数就能解决这个问题:
function delay(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
你可以直接将其嵌入重试循环中:
async function retry(fn, attempts = 3) {
let lastError;
for (let i = 0; i < attempts; i++) {
try {
return await fn();
} catch (error) {
lastError = error;
if (i < attempts - 1) {
await delay(1000);
}}}
throw lastError;
}
实际生产环境中的系统通常会采用指数退避策略,以这样的方式间隔执行重试:
1 second
2 seconds
4 seconds
8 seconds
通过这种方式分散重试次数,能让不堪重负的服务器有时间恢复,而非引发重试风暴。
6. 控制并发
Promise.all()可能会悄悄引入一个问题。
假设你需要处理1,000个API请求。
那种简单的处理方式看似并无危害:
await Promise.all(
items.map((item) => processItem(item))
);
But now you’re potentially starting 1,000 operations at once.
但这意味着你可能会在完全相同的时刻启动全部1,000个操作。
这通常并非你真正想要的结果。
很多时候,你更希望限制同时运行的操作数量。
想象一下这样的场景:
1000 tasks
↓
5 at a time
↓
5 at a time
↓
5 at a time
构建一个基本的并发限制器并不难:
async function runWithLimit(items, limit, fn) {
const results = [];
let index = 0;
async function worker() {
while (index < items.length) {
const currentIndex = index++;
results[currentIndex] = await fn(items[currentIndex]);
}
}
const workers = Array.from(
{ length: limit },
() => worker()
);
await Promise.all(workers);
return results;
}
你可以这样使用它:
const results = await runWithLimit(
items,
5,
(item) => processItem(item)
);
现在你可以自行决定同时执行的任务数量,而非让运行时来决定。
在处理大型数据集、外部API、文件处理或后台任务队列时,这种技术能带来巨大优势。
7. 防止重复请求
这里有一个出现频率比你想象中更高的问题。
用户加载了一个仪表板页面。
三个独立的组件都需要相同的用户资料数据。
不必发起三个独立的请求:
Component A → /api/user
Component B → /api/user
Component C → /api/user
可以让它们共享同一个 Promise 即可。
const pendingRequests = new Map();
function getUser(id) {
if (pendingRequests.has(id)) {
return pendingRequests.get(id);
}
const request = fetch(`/api/users/${id}`)
.then((res) => res.json())
.finally(() => {
pendingRequests.delete(id);
});
pendingRequests.set(id, request);
return request;
}
采用这种设置后,如果三个组件几乎同时请求同一个用户信息,它们会都附加到同一个正在处理的请求上,而不会触发三个独立的请求。
换句话说:
3个请求 → 1个请求
这种技术通常被称为请求去重。
对于规模较大的代码库,像 TanStack Query 这样的工具已经为你实现了类似的缓存和去重逻辑。
8. 当只需一个成功结果时使用 Promise.any()
有时,同一份数据可以从多个不同的地方获取。
例如:
API Server A
API Server B
API Server C
如果你的应用只需要其中任意一个来源的单一成功响应,Promise.any() 就非常适合。
const result = await Promise.any([
fetchFromServerA(),
fetchFromServerB(),
fetchFromServerC(),
]);
首先满足条件的 Promise 会赢得竞争。
请注意,这与 Promise.race() 的行为不同。
Promise.race() 会根据第一个确定状态的 Promise(无论成功还是失败)来决定是解析还是拒绝。
而 Promise.any() 则专门等待第一个成功满足条件的 Promise,除非所有 Promise 都失败,否则会忽略拒绝情况。
这种区别看似微不足道,但实际上可能会彻底改变你处理错误的方式。
9. 取消不再需要的操作
这种模式是应用起来较为令人满意的方式之一。
以实时搜索框为例:
user types: react
user types: react dashboard
user types: react dashboard ui
一旦之前的输入请求过时,你大概不希望它们继续在后台运行。
这正是AbortController被设计用来处理的场景。
const controller = new AbortController();
fetch("/api/search?q=react", {
signal: controller.signal,
});
一旦不再需要该请求,只需调用:
controller.abort();
The request can then be cancelled.
这种模式在以下场景中频繁出现:
- 搜索建议
- 自动补全字段
- 路由或页面切换
- 组件的卸载/清理
- 被新请求取代的旧请求
这里的真正技巧并非仅仅调用abort()。
而是要能够识别之前的操作何时不再有用。
更深层的启示
Promise之所以让人觉得难,不是因为.then()是个复杂的API。
而是因为实际应用中存在混乱且多层嵌套的异步行为。
你始终需要处理诸如:
这些操作应该同时运行吗?
如果其中某个失败了,有什么应对方案?
等待多久才算过久?
在这种情况下重试值得吗?
应同时运行多少个操作?
我能避免重复已经在进行中的工作吗?
这个请求还重要吗?
一旦开始思考这类问题,Promise就不再仅仅是语言特性而已。
它们会变成真正的架构工具。
我的9种Promise使用模式一览
Promise.all()最适合同时执行独立的任务。Promise.allSettled()能够优雅地处理部分失败的情况。Promise.race()则适用于超时处理或响应最先解决的请求。重试逻辑有助于从临时故障中恢复。延迟与退避策略可防止重试过于频繁。并发限制有助于控制庞大的工作量。请求去重功能可以避免重复的API调用。Promise.any()能让你获取多个请求中的第一个成功结果。AbortController则可用于取消不再需要的操作。
并非每个项目都需要使用所有这些模式。
事实上,强行全部使用反而会适得其反。
真正的技巧在于在选用特定模式之前先明确自己要解决的是什么问题。
最后思考
最出色的异步代码并非那些充斥着各种巧妙 Promise 技巧的代码。
而是那些在出现生产环境问题之前,就已经充分考虑了错误处理、时间控制、并发操作以及取消机制的代码。
先从最简单的版本开始。
关注真正会带来问题的因素。
只有这样,再引入能够解决该问题的特定模式。
因为有时候,在 JavaScript 中最高级的技巧,就是意识到何时根本不需要使用某种模式。
相关阅读
- 导致生产环境故障的常见 async/await 误区 — 阐述了九种容易被忽视的 async/await 错误理解,从竞态条件到未处理的拒绝事件,这些都会悄无声息地破坏实际的 JavaScript 应用程序。
- 在 Node.js 应用中构建生产级错误处理机制 — 了解如何对 Node.js 错误进行分类、设计自定义的错误层级结构、集中处理异步错误,以及如何保护堆栈跟踪信息以提高应用的稳定性。