JavaScript 的 async/await 语法本应将我们从回调地狱(callback hell)中解救出来。然而,它却引入了一个更隐蔽、更棘手的问题:代码看起来逻辑正确,但行为却不可预测。你看到函数体中写着 await,便以为一切都会像礼貌地逐行停顿那样运行。但事实往往并非如此。循环会提前“超车”;单个请求失败会导致整个批处理崩溃;入口文件会莫名其妙地长出丑陋的异步包装器。如果你遇到过这些问题,这三种模式将帮你拨乱反正。
停止在 forEach 中使用 await
这是一个看起来无伤大雅的常见错误:
const urls = ['/api/user', '/api/posts', '/api/comments'];
urls.forEach(async (url) => {
const res = await fetch(url);
const data = await res.json();
console.log(data);
});
console.log('All done!');
运行这段代码,'All done!' 会在任何响应返回之前就打印出来。为什么?因为 forEach 会立即为每个元素执行回调函数,它不会等待每次迭代中的 Promise。async 关键字将每个回调都变成了 Promise,而 forEach 会毫不留情地忽略它们。你的循环在微秒内就结束了,而网络请求则在后台自行其是。如果你需要按顺序处理错误,或者需要保证一个请求完成后再开始下一个,这种模式会悄无声息地破坏这两项保证。
请将其替换为 for...of 循环:
const urls = ['/api/user', '/api/posts', '/api/comments'];
for (const url of urls) {
const res = await fetch(url);
const data = await res.json();
console.log(data);
}
console.log('All done!');
现在,循环会在每个 await 处真正停顿。第二个请求会等待第一个请求完成。只有在一切尘埃落定后,'All done!' 才会打印。
当顺序至关重要时,请使用 for...of——例如为了遵守频率限制而逐个上传文件、按特定顺序写入数据库行,或者在下一个请求需要前一个响应数据时进行 API 调用链。如果你确实想要并行执行,不要在 forEach 上瞎折腾。请明确使用 Promise.all,这样你的意图对后续开发者来说才是清晰可见的。但千万不要试图通过混合使用 await 和 forEach 来期望同步行为,那是不可能实现的。
当“全盘皆输”不可接受时,请使用 Promise.allSettled
Promise.all 在语义上是诚实的。你给它一个 Promise 数组,它就返回一个结果数组。问题在于,一旦任何一个 Promise 失败(reject),整个操作会立即失败。其他所有正在进行的 Promise 会被留在原地自行完成,但你却无法获取它们的结果。在生产环境中,这种“全有或全无”的行为非常致命。
想象一下,你的应用程序从四个独立的微服务中获取仪表盘组件:流量分析、收入数据、用户反馈和服务器健康状况。如果收入 API 发生了短暂的超时,在 Promise.all 的作用下,你的整个仪表盘都会报错。那三个健康的响应会消失在虚无中。用户只会看到一个加载动画,然后是一个错误页面,仅仅因为四分之一的数据出了问题。
Promise.allSettled 提供了一个更合理的契约。它会等待每一个 Promise 都执行完毕,无论成功还是失败。其解析值(resolved value)是一个描述每个结果的对象数组:
const requests = [
fetch('/api/traffic'),
fetch('/api/revenue'),
fetch('/api/feedback'),
fetch('/api/health')
];
const results = await Promise.allSettled(requests);
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
renderWidget(index, result.value);
} else {
renderError(index, result.reason);
}
});
没有任何响应会被丢弃。你可以渲染能获取到的数据,并将失败的部分隔离出来。当你处理互不相关的操作时,这种模式非常重要——例如批量发送通知、第三方 Webhook 分发或从多个 CSV 流中导入记录。你仍然需要集中化的错误追踪,但你的应用程序能够保持稳定运行。
一个实用的注意事项:allSettled 返回的是完整的结果集,因此你仍需要筛选结果,并决定对于你的功能来说,“部分成功”意味着什么。不要将返回的数组视为全部成功的“完美数据”。在将任何数据推送到状态层(state layer)之前,请务必检查那些 status 字段。
使用 Top-level await,告别 IIFE 包装器
多年来,如果你想在文件的根部使用 await,你必须将其包装在一个立即调用异步函数(IIFE)中:
(async () => {
const config = await loadConfig();
startServer(config);
})();
这样做虽然可行,但显得很冗余。ES 模块原生支持的 Top-level await 可以让你省去这些仪式性的样板代码:
const config = await loadConfig();
startServer(config);
请在应用程序的入口点或专门的配置模块中使用它,在这些地方,初始化必须在其他任何操作运行之前完成。加载环境文件、建立数据库连接池或获取远程功能标志(feature flags)都是非常合适的场景。由于 Top-level await 会阻塞模块图(module graph)的执行——导入该模块的其他文件会等待你的 Promise 解析完成——因此你可以获得一个确定的状态。你代码库中的其他部分在导入 db 时,可以确信连接已经建立。
有两个注意事项。首先,你的运行时或打包工具必须支持 ES modules。在 Node.js 中,这意味着要么使用 .mjs 扩展名,要么在 package.json 中设置 "type": "module"。其次,由于在模块层级进行等待会延迟每一个导入者,因此请保持被等待的任务集中且专注。在一个频繁被导入的工具文件顶部进行沉重的顺序 fetch 操作,会减慢整个应用程序的冷启动速度。请将 top-level await 留给其他模块真正依赖的真实引导任务(bootstrap tasks)。
采用这些模式后究竟会发生什么变化
可预测性是第一个回报。当你阅读一个 for...of 循环时,你确切地知道下方的代码块何时结束。不会有在幕后竞速的“幽灵 promise”,也不会有脱离了错误处理程序的 foreach 回调。你的控制流与屏幕上代码的形态完全一致。
其次是韧性(Resilience)。Promise.allSettled 迫使你思考部分失败的情况,而不是寄希望于每个外部系统都保持完美。生产环境中的软件并非非黑即白。某些端点会不稳定,某些文件读取会遇到权限错误。针对零散失败的现实进行设计,可以让你的应用程序保持稳健,而不会掩盖合法的错误数据。
清晰度将这一切联系在一起。for...of 读起来就像普通的英语逻辑推进。allSettled 的意图就在其名称中。Top-level await 移除了晦涩难懂的 IIFE 包装,使你的入口文件以业务逻辑而非语法杂技开头。下一个接触该文件的工程师——无论是六个月后的你自己,还是赶进度的队友——都会感谢你。
真正的收获
不要将 async/await 视为一种可以随意撒在现有代码上的全局修复方案。审计你当前的项目,查找这三种特定的反模式。查找 forEach 块内部的 await,并将其替换为 for...of 或有意识地使用 Promise.all。审查每一个与外部服务通信的 Promise.all,并思考单个失败是否真的应该摧毁整个操作;如果不是,请切换到 Promise.allSettled 并处理混合结果。最后,从你的 ES module 入口点中剥离 async IIFEs,让 top-level await 直接处理你的引导序列(bootstrap sequence)。这些是微小的机械性改动,但它们共同作用,能将脆弱的异步脚本转化为真正值得信赖的代码。
