把回调的控制权、错误出口和单次完成约定清楚以后,代码终于不会因为“多回调一次”随机失控。但流程一长,结果仍然散落在不同闭包里,错误也很难沿着同一条路径向上传递。Promise 和 async/await 解决的,正是如何把一个未来结果重新变成可以组合、返回和捕获的值。
不过,语法变直并不代表时序自动正确。我们把上一种错误从“回调两次”修掉后,很快又遇到了另一种更隐蔽的错误:异步工作根本没有进入主流程。
有一次,我处理一个批量导出接口。请求里有 120 个用户 ID,接口几乎瞬间返回了“导出完成”,下载文件里却只有表头。更古怪的是,响应已经发出去几秒后,进程才打印数据库查询失败;同一时段连接池占满,其他接口也开始超时。
我最先怀疑数据库抖动。慢查询、连接池大小、网络延迟查了一圈,都解释不了“空文件为什么先成功返回”。直到我在每次查询前后和发送响应前各打了一条带序号的日志,时间线才暴露真相:响应日志排在所有查询完成日志之前。
出问题的代码大致是这样:
async function collectEmails(userIds) {
const emails = [];
userIds.forEach(async (id) => {
const user = await findUser(id);
emails.push(user.email);
});
return emails;
}forEach 没有等待 async 回调。collectEmails() 在循环刚把任务发出去时就返回了空数组,那些查询则变成无人等待的 Promise:它们晚一点成功,只会悄悄改动一个已经没用的数组;它们晚一点失败,错误也到不了接口原本的 try/catch。
这次事故让我换了一种检查异步代码的方式。我不再只看代码是不是写了 async 和 await,而是追问三件事:任务何时启动,代表结果的 Promise 被谁接住,失败最终流向哪里。Promise 的状态、链式返回、异常传播、并发与取消,都是这三件事的不同侧面。
读文件、查数据库或发送 HTTP 请求的是具体 API。Promise 只代表那项工作将来会给出的结果:成功时保存值,失败时保存原因,并通知已经登记的处理器。
这个区别能纠正一个常见误会:Promise.all() 通常不会替我们“启动并发”。调用异步函数时,工作往往就开始了;Promise.all() 接到的是一组已经创建的 Promise,它负责把这些结果组合起来。
import { readFile } from 'node:fs/promises';
const configPromise = readFile('./config.json', 'utf8');
// readFile 被调用时,读取就已经开始;这里拿到的是结果的 Promise。
configPromise.then((text) => {
console.log(JSON.parse(text));
});这也解释了为什么不该给本来就返回 Promise 的 API 再套一层 new Promise()。多包一层不会让任务更异步,只会增加漏掉 rejection、重复落定或忘记清理资源的机会。手写 Promise 更适合把回调、事件或一次性完成信号转换为 Promise,而且要同时封装成功、失败与清理路径。
Promise 只有三种互斥状态:
pending:结果还没落定;fulfilled:操作成功,保存了兑现值;rejected:操作失败,保存了拒绝原因,工程代码里通常应是 Error 对象。fulfilled 和 rejected 合称 settled。Promise 一旦落定便不可逆,之后再调用 resolve 或 reject 都不会改写它。
还有一个排查链路时很有用的细节:resolved 不一定等于 fulfilled。如果 resolve() 接收的是另一个尚未落定的 Promise,外层 Promise 会采用它的最终状态。外层此时已经被决议,不能再换一个结果,但仍可能保持 pending,直到内层落定。
const inner = new Promise((resolve) => {
setTimeout(() => resolve('数据库已就绪'), 100);
});
const outer = new Promise((resolve, reject) => {
resolve(inner); // outer 采用 inner
reject(new Error('无效')); // 已经决议,这次调用不起作用

“采用”正是异步步骤能够自然衔接的基础:在 .then() 里返回 Promise,链上的下一环会等待它;从 async 函数里返回 Promise,函数返回的 Promise 也会跟随它的结局。
我排查那次空文件问题时,曾经把“写进 Promise”误当作“代码稍后运行”。其实 new Promise(executor) 的 executor 在构造时同步执行。被安排到 Promise Job(在日常 Node.js 语境里常称微任务)的,是 .then()、.catch() 和 .finally() 登记的处理器。
console.log('A');
const promise = new Promise((resolve) => {
console.log('B:executor 同步执行');
resolve('结果');
});
promise.then((value) => console.log('D:', value));
console.log('C');
即便 Promise 已经兑现,后来登记的 .then() 也不会插进当前调用栈,而会异步排入队列。这种保证避免了回调“有时同步、有时异步”的不稳定行为。
Promise 构造器不会把 CPU 密集计算搬到后台。大循环写进 executor,仍会同步占住 JavaScript 线程。Promise 管理未来结果,不会凭空创建工作线程。
.then(onFulfilled, onRejected) 不修改原 Promise,而是立刻返回一个新 Promise。新 Promise 的结局由本次真正执行的处理器决定:
undefined,以 undefined 兑现。
所以阅读 Promise 链时,我会逐个圈出处理器的 return。下面的链依次读取、解析并校验配置;JSON.parse() 或 validatePort() 即使同步抛错,也会让 .then() 返回的 Promise 进入拒绝状态。
import { readFile } from 'node:fs/promises';
function validatePort(config) {
const port = Number(config.port);
if (!Number.isInteger(port) || port < 1 || port > 65_535) {
throw new TypeError('port 必须是 1 到 65535 之间的整数');
}
return { ...config, port };
真正把步骤串起来的不是缩进,也不是 .then() 这个单词,而是处理器把自己的结果返回给下一环。
那次事故里还有一段类似的代码。它看起来已经发起保存,外层也有 .catch(),但 saveUser() 的 Promise 没有返回:
findUser('u-42')
.then((user) => {
saveUser(user); // 错误:浮空 Promise 没有接回这条链
})
.then(() => {
console.log('这里可能早于保存完成');
})
.catch(handleError);第一个处理器立即以 undefined 兑现,下一环便继续运行。稍后 saveUser() 若失败,链末尾的 .catch() 也接不到。正确做法是返回它:
findUser('u-42')
.then((user) => saveUser(user))
.then((savedUser) => {
console.log('保存完成:', savedUser.id);
})
.catch(handleError);这种已经创建、却没有被 await、return 或显式附加错误处理的结果,常被称为“浮空 Promise”。代码审查时,看到一个返回 Promise 的函数被单独调用,我会要求调用方明确表达意图:它是主流程的一部分,还是经过设计、拥有独立错误处理的后台任务。
Promise 把“抛异常”和“返回 rejected Promise”统一到了同一条失败路径。某一环失败后,没有拒绝处理器的环会被跳过,直到遇到最近的 .catch(),或 .then() 的第二个参数。
.catch(handler) 等价于 .then(undefined, handler),同样会返回新 Promise。如果 handler 正常返回,链就恢复为成功;如果它重新抛错,失败才会继续传播。
getProfile(userId)
.catch((error) => {
if (error.code === 'PROFILE_NOT_FOUND') {
return { id: userId, nickname: '新用户' }; // 有明确兜底,恢复成功路径
}
throw error; // 无法处理,保留原错误继续传播
})
.then((profile) => renderProfile(profile));只打印日志却不再抛出,语义上就是“错误已经处理完,可以继续”。如果业务不能继续,这便是吞错。能在当前层恢复的错误才捕获;不能恢复的错误保留原对象继续抛,才能留下错误类型、错误码、cause 和调用栈。
还有一个边界值得记住:.then(success, failure) 中的 failure 只处理传入这个 .then() 的 Promise 的拒绝,接不住同一个 success 内刚抛出的异常。那个异常会拒绝 .then() 返回的新 Promise,只能被后续处理器捕获。多数业务链写成 .then(success).catch(failure) 更容易读对。
.finally() 无论前一步兑现还是拒绝都会执行,适合关闭连接、释放锁、停止计时器或恢复状态。它通常是透明的:正常返回值会被忽略,原成功值或失败原因继续向后传。
let connection;
connectDatabase()
.then((db) => {
connection = db;
return db.runMigration();
})
.finally(() => connection?.close())
.then(() => console.log('迁移完成'))
.catch((error) => console.如果 finally 自己抛错,或返回 rejected Promise,新错误会覆盖原来的结局。因此清理逻辑也要可控,尤其不要让次要的日志失败遮住真正的业务错误。
在函数前加 async 后,调用它一定会得到 Promise:
return value:返回的 Promise 以 value 兑现;throw error:返回的 Promise 以 error 拒绝;return promise:返回的 Promise 采用它的结局;return undefined。async function parseConfig(text) {
const config = JSON.parse(text);
if (!config.databaseUrl) {
throw new Error('缺少 databaseUrl');
}
return config;
}
const resultPromise = parseConfig('{"databaseUrl":"mongodb://127.0.0.1/app"}');
console.log(resultPromise instanceof Promiseasync 也不表示函数从第一行起就在后台运行。调用 async 函数时,函数开头到第一个 await 之前仍同步执行;遇到 await,它才把执行权交还调用方。
执行 const value = await expression 时,引擎先计算右侧表达式,再按照类似 Promise.resolve() 的规则把结果转换为 Promise。当前 async 函数暂停,调用方和事件循环可以继续。Promise 落定后,引擎安排微任务恢复函数:兑现值成为 await 表达式的值,拒绝原因则在这一行表现为 throw。

因此,“await 会阻塞”必须说明主语。它会暂停当前 async 函数里依赖结果的后续步骤,却不会冻结 JavaScript 线程;服务器仍可处理其他连接、定时器和已就绪的 I/O 回调。
即便右侧只是普通值或已经兑现的 Promise,await 后面的代码也会让出当前执行权:
async function showOrder() {
console.log('2:进入函数');
await 0;
console.log('4:微任务中恢复');
}
console.log('1:调用前');
showOrder();
console.log('3:调用后');
// 1:调用前
// 2:进入函数
// 3:调用后
// 4:微任务中恢复这意味着 await 也是调度点:它会把函数拆成前后两段,其他微任务可能在中间穿插。不要为了让代码“看起来异步”而给同步值加 await;只在确实需要消费 Promise 结果时使用它。
回调模式下,外层 try/catch 接不住未来回调里抛出的错误,因为原调用栈早已退出。await 会让 rejected Promise 在暂停点重新表现为抛错,因此包围该行的 try/catch 能处理失败。
import { readFile } from 'node:fs/promises';
async function loadConfig(path) {
try {
const text = await readFile(path, 'utf8');
return JSON.parse(text);
} catch (error) {
throw new Error(`无法加载配置 ${path}`, { cause: error });
}
}这里用 cause 保留底层错误,又补上了“加载哪个配置”的业务上下文。但 try 的范围不宜无限扩大:错误边界越靠近可恢复动作,处理越精确;无法恢复的错误应继续交给统一错误层。
export async function listUsers(req, res, next) {
try {
const users = await req.services.userRepository.findAll();
return res.json({ data: users });
} catch (error) {
return next(error);
}
}路由只负责成功响应,错误交给统一中间件,可以避免几十处重复拼接 500 响应,也能避免同一个错误既发送响应又继续传播。
但要注意,try/catch 只会捕获在其动态执行过程中抛出的错误,以及真正被 await 的 rejection。开头事故里的 forEach(async () => {}) 没有把回调 Promise 返回给外层,所以外层 try/catch 根本不知道还有工作在继续。
调用 async 函数却不 await、不 return,也不附加 .catch(),就放走了它返回的 Promise。若它拒绝,运行时会报告未处理拒绝;具体处置还可能受 Node.js 版本与启动参数影响,因此业务正确性绝不能依赖进程最终是告警还是退出。
进程入口要明确接住启动 Promise:
async function main() {
await startServer();
}
main().catch((error) => {
console.error('服务启动失败:', error);
process.exitCode = 1;
});unhandledRejection 监听器适合补充观测,不适合作为业务兜底。可靠的目标仍是:每条 Promise 在清晰的边界被等待、返回或显式处理。
修复批量导出时,最直接的正确写法是 for...of:
const emails = [];
for (const id of userIds) {
const user = await findUser(id);
emails.push(user.email);
}它会等待每次查询,结果和错误都不会丢,但 120 次查询完全串行,响应明显变慢。这里不能机械地把“连续 await”判成问题;关键要看任务是否有依赖。
const user = await findUser(userId);
const team = await findTeam(user.teamId);
const permissions = await findPermissions(team.id);后一步需要前一步的 ID,串行正是业务事实。反过来,两个互不依赖的读取若连续 await,第二个直到第一个完成才启动:
const profile = await fetchProfile(userId);
const notices = await fetchNotices(userId);这时可以先同时启动,再一次性等待:
const [profile, notices] = await Promise.all([
fetchProfile(userId),
fetchNotices(userId),
]);
不建议先创建多个 Promise,再逐个 await。虽然任务会同时启动,但后面的 Promise 可能在轮到它被等待之前就拒绝,形成短暂的未处理拒绝。Promise.all() 在组合时会一次性为所有输入接上处理器,错误边界更完整。
事故代码的关键转折,就是我在调试器里看到 map 结果的每一项都是 Promise。数组方法不会因为回调带 async 就自动理解异步语义:
const jobs = userIds.map(async (id) => {
const user = await findUser(id);
return user.email;
});
const emails = await Promise.all(jobs);map 会保留每次回调的返回值,所以能得到 Promise[] 再统一组合;forEach 会忽略回调返回值,因此外层没有任何东西可等。await jobs 也无效,因为普通数组不是 Promise,await 不会递归等待数组里的元素。
需要串行时,用 for...of 配合 await;需要并发且要汇总结果时,用 map 创建 Promise,再交给组合器。
我把 120 个查询改成 Promise.all(userIds.map(...)) 后,空文件消失了,连接池峰值却更尖。这是第二个转折:并发语义正确,不代表并发规模安全。
当数组有几千项时,一次性映射会立刻启动几千个操作。数据库连接池、文件描述符、内存和下游限流都可能先扛不住。此时需要队列、分批或带上限的 worker pool,让“同时在途”的任务数与系统容量匹配。Promise 组合器定义完成条件,不提供并发限流。
判断串行、全并发还是限流并发,我会依次问:任务之间有无数据依赖?下游能承受多少在途请求?是否允许部分失败?每项操作是否有副作用?这四个问题比“哪种写法更短”重要得多。

空数组能把语义照得很清楚:Promise.all([]) 与 Promise.allSettled([]) 兑现为空数组;Promise.any([]) 没有候选可能成功,会以 AggregateError 拒绝;Promise.race([]) 没有选手能落定,会一直保持 pending。候选列表可能为空时,要在组合前决定这是否符合业务预期。
Promise.all() 的返回值顺序与输入顺序一致,不按完成先后排列。它会快速失败,但组合 Promise 拒绝并不等于其余底层任务被取消,那些任务通常仍会继续。
const [user, orders, balance] = await Promise.all([
getUser(userId),
getOrders(userId),
getBalance(userId),
]);如果这些任务包含写入副作用,某一项失败后,其他写入仍可能完成。业务需要“要么全成、要么全不成”时,应使用数据库事务或补偿机制;Promise.all() 不提供原子性和回滚。
allSettled() 等所有输入落定。结果项要么是 { status: 'fulfilled', value },要么是 { status: 'rejected', reason },适合批量通知或数据导入这类允许部分失败的操作。
const results = await Promise.allSettled(
recipients.map((recipient) => sendEmail(recipient)),
);
const summary = results.reduce(
(acc, result, index) => {
if (result.status === 'fulfilled') {
acc.succeeded.push(recipients[index]);
}
它不会自动记录失败、重试或转换业务错误。选择 allSettled(),就意味着调用方承诺逐项检查 status;否则失败只是被装进数组后悄悄忽略。
Promise.any() 会忽略较早的拒绝,继续等待第一个兑现值;只有全部输入失败,才以 AggregateError 拒绝。它适合从多个等价镜像读取同一份内容。
try {
const document = await Promise.any([
fetchFromMirror('https://mirror-a.example/doc'),
fetchFromMirror('https://mirror-b.example/doc'),
]);
console.log(document);
} catch (error) {
for (const reason of error.errors) {
console.error('镜像失败:', reason);
}
}只有结果可互换时,“第一个成功”才成立。若同时向多个支付通道提交同一笔扣款,再取第一个成功,可能产生重复交易。组合器不理解业务副作用。
Promise.race() 跟随第一个落定的输入:最先成功就兑现,最先失败就拒绝。它常被用来表达等待截止时间:
const result = await Promise.race([
slowOperation(),
new Promise((_, reject) => {
setTimeout(() => reject(new Error('操作超时')), 1_000);
}),
]);这只让调用方停止等待,slowOperation() 仍可能在后台占连接、写数据或产生副作用。要真正终止工作,取消信号必须传到支持取消的底层 API。
Promise 只描述最终结果,不知道背后的工作是 HTTP 请求、数据库查询、文件读取还是子进程,因此没有通用的 promise.cancel()。
Node.js 与 Web API 常用 AbortController 和 AbortSignal 传递取消意图:上层创建控制器,把 signal 交给底层;用户取消、连接断开或截止时间到达时触发信号;真正工作的 API 监听信号并尽快停止。

支持 AbortSignal.timeout(ms) 与 AbortSignal.any(signals) 的 Node.js 运行环境里,可以把手动取消和超时合成同一个信号,再交给内置 fetch:
async function fetchJson(url, { signal, timeoutMs = 2_000 } = {}) {
const timeoutSignal = AbortSignal.timeout(timeoutMs);
const combinedSignal = signal
? AbortSignal.any([signal, timeoutSignal])
: timeoutSignal;
try {
const response = await fetch(url, { signal: combinedSignal });
if (
取消是一种协作协议。只有底层 API 接收并响应 signal,工作才可能真正停下;即使 API 支持中止,已经交给操作系统的某些操作也未必能立刻撤回。
自己封装支持取消的函数时,要先检查信号是否已中止,监听 abort 时使用一次性监听器,并在成功、失败时清理其他资源:
function wait(ms, { signal } = {}) {
return new Promise((resolve, reject) => {
signal?.throwIfAborted();
const timer = setTimeout(() => {
signal?.removeEventListener('abort', onAbort);
resolve();
}, ms);
function onAbort() {
能使用已经支持 signal 的 API 时,优先使用它们,例如 node:timers/promises 提供的计时器 Promise。自行封装时,要特别检查监听器泄漏、重复清理和取消原因是否完整传播。
那次批量导出事故最后不是靠换数据库解决的,而是靠把时间线和所有权画清楚:forEach 同步结束,async 回调返回的 Promise 被丢掉,接口提前响应;随后无上限并发又把连接池推满。
现在遇到异步问题,我会沿着一条固定路径排查:
await、return、组合,还是有意地附加了独立错误处理?catch?那里是在恢复、转换,还是误吞?Promise.all()。Promise 让结果和错误沿同一条链传播,async/await 让这条链更像业务步骤。真正决定正确性的不是语法风格,而是三个时刻:任务何时启动、结果何时落定、调用方何时接上处理器。
Promise 和 async/await 让依赖、返回值与错误传播终于落在一条可读的路径上,但它们并不负责资源调度。Promise.all() 可以表达“等待全部完成”,却不会替数据库决定连接池大小,也不会在调用方超时后自动停止剩余工作。
因此,异步编程的下一层不再是语法,而是容量:哪些任务应该串行,哪些可以并行,并行到多少,队列满了怎么办,结果失去接收者以后怎样取消。只有回答这些问题,Promise 才会从好看的代码变成可控的系统。