在第六讲中我们厘清了回调函数模式、Error-first 约定以及回调地狱为何难以维护:当多个异步步骤串行依赖时,逻辑会一层套一层向右「长出去」,错误处理分散在每一层,可读性和可维护性都会下降。
从这一讲开始,我们转向 Node 和现代 JavaScript 中更主流的异步抽象——Promise 与 async/await。本节要讲清三件事:Promise 为何被设计出来、async/await 在底层究竟做了什么、以及在这一套模型下异步错误该如何正确处置。只有把这些弄明白,后续在写 HTTP 服务、中间件和并发控制时,才能既写出清晰的异步代码,又不会漏掉错误或误用语法。
在纯回调时代,我们表达「先做 A,A 完成后再做 B」的方式,是把 B 的逻辑写进 A 的回调里;若还有 C、D,就继续往深处嵌套。这种写法在语义上是对的,但在代码形态上把「步骤顺序」和「嵌套层级」绑在一起,读起来要顺着大括号一层层往里找,修改时也容易漏掉某一层的错误处理。
Promise 的动机,就是把「将来某一时刻会得到的结果(或失败原因)」抽象成一个对象:这个对象在创建时结果还未就绪,但我们可以在这个对象上「登记」成功时做什么、失败时做什么,并且可以链式地登记多步,从而用「then 链」的线性结构替代「回调嵌套」的金字塔结构。
从调用方的视角看,回调是「把一段逻辑交给别人,在完成时被调用」;Promise 则是「拿到一个代表将来结果的对象,在上面挂上 then/catch」。两者在事件循环层面是同一回事:都是「先登记、等 I/O 或定时器完成后由事件循环执行」。差别在于,Promise 把「成功」和「失败」两条路径收拢到同一个对象上,并且 then 会返回一个新的 Promise,这样我们就可以写「第一步 then 完再 then 第二步」,而不是「第一步的回调里再调第二步、再在第二步的回调里调第三步」。
用 Promise 可以写成:读 a 得到 Promise,在其 then 里返回读 b 的 Promise,再 then 里返回读 c,错误在链末用一个 catch 统一处理。这样,执行顺序仍然是「a 完成 → b 开始 → b 完成 → c 开始」,但代码在结构上是线性的,更容易阅读和修改。
Promise 的标准化经历了较长的演进。早期 JavaScript 没有统一的异步抽象,各库(如 jQuery 的 Deferred、Q、Bluebird)都实现了自己的「类 Promise」接口;ECMAScript 2015(ES6)将 Promise 纳入语言规范后,Node 和浏览器逐步原生支持,社区才普遍采用「返回 Promise」的 API 设计。
Node 从较新版本开始为部分内置模块提供了 Promise 版 API(例如 fs.promises.readFile),不再需要回调;对于仍只提供回调的 API,可以用 util.promisify 包一层,得到返回 Promise 的版本,本讲示例中使用的就是这种方式。理解这段背景,有助于我们明白为何今天代码里会同时看到回调和 Promise:历史 API 多为回调,新代码多用 Promise 或 async/await,二者通过 promisify 或封装互相衔接。
示例中 readFile 即用 promisify 包装后的版本;若环境支持可直接用 fs.promises。Promise 在任意时刻处于三种状态之一,且一旦落定就不可变;下面先说明状态,再说明 then/catch 如何组成链。
每一个 Promise 在任意时刻都处于三种状态之一:pending(进行中)、fulfilled(已成功)或 rejected(已失败)。刚创建时是 pending;一旦异步操作完成,要么变为 fulfilled 并带有一个结果值,要么变为 rejected 并带有一个失败原因(通常是 Error)。关键之处在于:状态一旦从 pending 变为 fulfilled 或 rejected,就不会再改变。不会出现「先成功再失败」或「先失败再成功」;也不会出现多次决议。
这种「单次、不可逆」的语义,使得我们可以放心地把一个 Promise 传给多处:谁先 then 谁就登记上,等结果出来时,所有登记的回调都会按 then 的顺序被调度,且大家看到的是同一份结果或同一份错误。
从实现角度看,Promise 内部会持有一个状态和一个「结果值或错误」。当我们在 pending 时调用 then(onFulfilled, onRejected),会把这两个回调存起来;当异步操作完成、Promise 被 resolve 或 reject 时,会把这些回调放进微任务队列,在当前宏任务结束后由事件循环执行。第三讲里我们说过,微任务会在当前宏任务末尾、下一轮宏任务之前被清空;所以 then 的回调总是在「当前同步代码和当前宏任务里的同步部分」都执行完之后才执行,这与「Promise 不会阻塞主线程」的直觉一致。
「只决议一次」在实践中很重要。例如我们可能把同一个 Promise 传给多个模块或多次 then;若 Promise 可以多次 resolve,不同观察者可能看到不同时刻的结果,逻辑会难以推理。单次决议保证了「所有观察者看到同一份结果」,便于组合和测试。
此外,若我们在 then 里又发起了新的异步操作并 return 了新的 Promise,链会等待这个新 Promise 落定,新 Promise 的 fulfilled 或 rejected 会变成当前 then 返回的 Promise 的结局,从而把「多步异步」串成一条线,而每一步的「只决议一次」都成立。有了单次决议的保证,链式 then 的行为才可预测;下面看 then 和 catch 如何组成链并传递值与错误。

.then(onFulfilled, onRejected) 会在当前 Promise 变为 fulfilled 时调用 onFulfilled,传入结果值;变为 rejected 时调用 onRejected,传入失败原因。重要的是,then 会返回一个新的 Promise。这个新 Promise 的结局由我们在回调里做了什么决定:若我们 return 一个普通值,新 Promise 会以该值被 resolve;若我们 return 另一个 Promise,新 Promise 会跟随那个 Promise 的结局;若我们在回调里 throw 或调用了会抛错的函数,新 Promise 会以该错误被 reject。因此,我们可以连续写 .then(...).then(...):第一个 then 返回的新 Promise 在第一个回调执行完后被 resolve(或 reject),第二个 then 就挂在这个新 Promise 上,从而形成链式。若链中某一步 reject 或抛出错误,后续的 then 的 onFulfilled 不会执行,而是会跳到「后面最近的」onRejected 或 catch。
.catch(onRejected) 等价于 .then(null, onRejected),即只处理失败分支。catch 同样返回一个新的 Promise:若我们没在 catch 里再 throw,这个新 Promise 会以 undefined 被 resolve,于是错误在 catch 里「被消化」,链可以继续;若我们在 catch 里 throw 或 return 一个 rejected Promise,错误会继续向后传递。因此,我们可以把「一串 then + 一个 catch」理解为:整条链上任意一步出错,都会把错误「往后传」,直到被某个 then 的第二个参数或某个 catch 接住。若整条链都没有接住,就会变成「未处理的 rejected Promise」,在 Node 里会触发 unhandledRejection,见后文。
then 和 catch 的返回值决定了链的下一环。若我们在 onFulfilled 里 return 一个普通值,下一个 then 的 onFulfilled 会收到这个值;若 return 一个 Promise,下一个 then 会等这个 Promise 落定后再用其结果或错误继续。若我们在 onFulfilled 里 throw,或 return Promise.reject(...),链会立刻转入失败路径,后续的 onRejected 或 catch 会收到这个错误。
在这种「值或错误沿链传递」的规则下,Promise 链只需在链末或需要的地方写 catch,中间步骤只要正常 return 或 throw,错误会自动传到 catch,不必在每一步都写错误分支。
下面这段代码演示「读 a 再读 b 再读 c」的完整 then 链,并在末尾用一个 catch 统一处理任一步的读文件错误。注意每一步 then 里 return 的是「下一个 readFile 的 Promise」,这样下一个 then 收到的就是下一个文件的内容;若某一步失败,不会进入后续 then,直接进入 catch。
|const fs = require('fs'); const { promisify } = require('util'); const readFile = promisify(fs.readFile); readFile('a.txt', 'utf8') .then((dataA) => { console.log('a:', dataA); return readFile
与此相关的是「then 链与事件循环的配合」。当我们执行到 readFile('a.txt') 时,返回的 Promise 进入 pending,主线程继续执行到这一串 then/catch 的末尾并返回;此时 then 的回调还没有执行,它们要等 a.txt 读完后,由 readFile 内部 resolve 那个 Promise,从而把 then 里登记的回调放进微任务队列。
等当前宏任务结束,事件循环处理微任务,才会执行第一个 then 的成功回调,在里面又发起 readFile('b.txt'),如此往复。所以从宏观上看,then 链是「线性书写、按序执行」,和第三讲里「微任务在当前宏任务之后、下一轮宏任务之前执行」完全一致。
Promise 的 then 链已经能把嵌套压平,但书写上仍然是「回调风格」:每一步写在一个 then 的回调里,逻辑被拆成多块。async/await 在此基础上再进一步:让我们用看起来像同步的写法来写异步逻辑。
在函数前加上 async,这个函数就成为一个异步函数,调用它会返回一个 Promise;在 async 函数内部,我们可以在任何「返回 Promise」的表达式前加上 await,执行到 await 时,当前函数会「暂停」,等这个 Promise 落定后,再从这里「恢复」执行后面的代码,并且 await 表达式的值就是 Promise 的结果(若 Promise reject,会在这里抛出异常)。这样,我们就可以把「先读 a 再读 b 再读 c」写成从上到下的一行行代码,读起来和同步顺序执行几乎一样,而底层仍然是事件循环和微任务在调度。
需要强调的是:async 函数无论内部是否 await,调用它得到的都是 Promise;若函数里 return 了普通值,会变成 resolve(value),若抛错或 await 到 rejected Promise,返回的 Promise 会变成 rejected,因此调用后需用 .catch() 或外部 await 接住。
下面这段代码用 async/await 重写「读 a 再读 b 再读 c」,与前面 then 链版本语义一致:先读 a,再读 b,再读 c,任一步出错都会跳到 catch。区别在于逻辑是线性的,可读性更好。
|const fs = require('fs'); const { promisify } = require('util'); const readFile = promisify(fs.readFile); async function readThree() { const dataA = await readFile('a.txt', 'utf8'); console.log('a:', dataA); const
另外,await 的右侧并不要求一定是 Promise。若我们写 await x 而 x 不是 Promise,引擎会把它当作已经 resolve(x),直接继续执行下一行,不会真的「暂停」。这种设计让我们可以统一写法:无论底层是同步返回还是异步返回,都可以用 await 来拿值。
但若我们明确知道某段逻辑是同步的,不必刻意写成 await,否则会多一层微任务调度;在需要「保证某段逻辑在微任务中执行」时,才会用 await Promise.resolve() 或类似的技巧。
await 的「暂停」并不是阻塞主线程。当执行到 const dataA = await readFile('a.txt', 'utf8') 时,readFile 会返回一个 Promise,当前 async 函数会在这里挂起,但主线程不会卡住:引擎会把「await 之后的代码」包装成微任务,等 readFile 的 Promise 被 resolve 后再把这段微任务加入队列。在那之前,主线程可以去执行别的任务(例如别的请求的回调、别的定时器)。等 I/O 完成、Promise resolve,微任务被调度,async 函数从 await 之后的那一行恢复执行,此时 dataA 已有值。
所以从事件循环的角度看,await 之后的代码等价于「被放进前一个 Promise 的 then 回调里」:都是等 Promise 落定后,在微任务阶段执行。这与第三讲里「微任务在宏任务之间清空」的模型一致;我们也因此可以说,async/await 是「用同步写法表达的 then 链」,底层仍然是同一套调度机制。
与此相关的是「多个 await 的串行」。在同一个 async 函数里,若我们连续写两个 await,第二个 await 要等第一个 await 的 Promise 落定并且「第一个 await 之后的同步代码和可能触发的微任务」都执行完后,才会执行到。所以「await A; await B」的语义就是「先等 A 完成,再等 B 完成」,与「then 链里先 then A 再 then B」一致。若我们希望 A 和 B 并行(同时发起、都完成后再继续),就不能写成两个连续的 await,而要用 Promise.all 等组合方式,这会在第八讲「异步控制流与并发管理」中展开。

在实际项目中,对于「一串串行的异步步骤」和「需要清晰错误边界」的业务逻辑,用 async/await 通常更易读、也更容易用 try/catch 统一处理错误。对于「同时发起多个独立的异步操作、等全部完成再汇总」的场景,用 Promise.all 配合 await(例如 const [a, b, c] = await Promise.all([readA(), readB(), readC()]))既简洁又高效。对于「只要有一个完成就继续」或「按完成顺序逐个处理」等更复杂的控制流,我们会用到 Promise.race、Promise.allSettled 以及第八讲里的串行、并行与限流。
因此,async/await 和 then 并不是二选一:async 函数内部用 await 消费 Promise,而 Promise.all 等组合子返回的也是 Promise,同样可以用 await 来拿结果。写代码时以「可读性和错误不遗漏」为准:能写成线性步骤的就用 async/await,需要组合多个 Promise 的就用 Promise 的静态方法,再在 async 里 await 即可。在 Node 后端中,路由处理函数、中间件里的「下一层」调用、以及各类 Service 层逻辑,大多适合写成 async 函数并在内部用 await;只有在需要「同时发起多个请求、等全部完成再合并」或「谁先完成用谁」时,才在 async 里写 Promise.all 或 Promise.race,下一讲会具体展开这些控制流模式。
小结:async 函数返回 Promise;await 挂起当前函数、不阻塞主线程。串行用 await 线性书写,并行用 Promise.all 再 await,错误用 try/catch 或 .catch() 统一处理。
前面已说明 then 链中错误会向后传递并在链末用 catch 接住;实际写链时有两个常见疏漏。
其一:若在某个 then 的 onFulfilled 里我们没有 return 下一个 Promise,而是直接调用了另一个异步函数却忘了 return,那么 then 返回的新 Promise 会很快 resolve(undefined),而不是等待那个异步函数;后续 then 拿到的就是 undefined,且那个异步函数内部的错误不会传进这条链,容易造成「错误丢失」。正确写法是:只要下一步依赖上一步的异步结果,就要在 then 的回调里 return 那个 Promise,让链真正串起来。例如前文「读 a 再读 b 再读 c」的 then 链中,若第一个 then 里写成 readFile('b.txt', 'utf8'); 而不 return,链会断掉,b 的读文件错误也不会进入 catch。
另一种常见错误是「在链中间吞掉错误」。例如在某个 then 里写了 onRejected,只打印日志却没有 rethrow 或 return rejected Promise,那么错误在这里就被「消化」了,链会继续以 undefined 或某个值 resolve 下去,后面的 catch 收不到这个错误。若我们希望在中间某一步做日志或转换错误,再让错误继续往后传,应在 onRejected 里 throw 或 return Promise.reject(...)。例如在链中间写 .then(null, (err) => { logger.warn(err); throw err; }) 可以既打日志又让错误继续传到链末的 catch;若写成 .then(null, (err) => { logger.warn(err); }) 而不 throw,后面的 then 会收到 undefined,相当于错误被吞掉。
在第六讲中我们强调过:同步的 try/catch 无法捕获「在回调内部」抛出的错误,因为回调是在事件循环的后续轮次中执行的,执行时已经脱离了当初调用异步函数的那条调用栈。但在 async/await 里,await 之后的代码本质上是被引擎包装成「前一个 Promise 的 then 回调」,它们仍然运行在「当前 async 函数的执行上下文」中;因此,若我们在 async 函数里用 try 包住一段包含 await 的代码,当某个 await 的 Promise 被 reject 时,reject 会以「抛出异常」的形式在 await 那一行体现,这个异常可以被同一层 try 的 catch 捕获。这样,我们就得到了「一层 try/catch 包住整段异步逻辑」的写法,错误处理集中、不会漏掉。
从调用栈的角度看,async 函数从开始执行到所有 await 完成,虽然中间经历了「挂起 → 微任务入队 → 恢复」,但引擎会维护一条逻辑上的「async 调用链」;因此当 await 处抛出异常时,栈上仍然包含「调用该 async 函数的帧」,catch 可以正确捕获。这与「在回调里 throw、外层 try 已经退栈」形成对比:回调是全新的调用栈,而 await 的恢复是在同一 async 帧内继续执行,所以 try/catch 的边界仍然有效。
这也是为何在 Node 里写 HTTP 处理函数时,若采用 async 形式的 (req, res) 处理函数,在函数体内用 try 包住 await,就能统一接住该处理函数中所有异步步骤的错误,便于返回 500 或统一错误格式。
下面这段代码在 async 函数内用 try 包住多个 await,任一步失败都会进入 catch,可在 catch 里统一记录日志或返回错误响应。调用 readThree() 的代码不再需要 .catch(),因为错误已在函数内部处理;若希望错误继续向上冒泡,可以在 catch 里再 throw,这样 readThree() 返回的 Promise 仍然是 rejected,外部仍可用 .catch() 接住。
|const fs = require('fs'); const { promisify } = require('util'); const readFile = promisify(fs.readFile); async function readThree() { try { const dataA = await readFile('a.txt', 'utf8'); console.log('a:'
若我们希望部分步骤单独处理错误、部分步骤统一处理,可以在对应位置写 try/catch 块,或对某一步的 Promise 单独 .catch() 并返回一个默认值,这样错误不会继续向上抛,后续 await 仍能执行。这种灵活性与「一层 try 包住整段」的简单用法结合,能覆盖绝大多数业务场景。
当某个 Promise 被 reject,但在当前事件轮次中没有任何 then 的 onRejected 或 catch 来接住它,这个 Promise 就处于「未处理的拒绝」状态。在 Node.js 中,这会触发 process 的 unhandledRejection 事件。若我们既不监听这个事件,又没有在链末或 async 外接 catch,未处理的拒绝在较新 Node 版本中会导致进程退出,或至少会在控制台打出警告,不利于生产环境排查和稳定性。因此,在 Node 服务中,建议在进程入口处监听 unhandledRejection,至少做日志记录或上报,必要时做兜底处理(例如记录后 process.exit(1)),避免静默崩溃或错误被吞掉。
监听方式很简单:在启动脚本或入口文件里写 process.on('unhandledRejection', (reason, promise) => { ... }),在回调里记录 reason(错误对象或其它被 reject 的值)和 promise,便于定位是哪个异步操作没有被正确接住。例如在入口文件顶部加一段:process.on('unhandledRejection', (reason, promise) => { console.error('未处理的 Promise 拒绝:', reason); });,这样任何未被 catch 的 rejected Promise 都会至少打出日志,避免静默崩溃。
与此配套的还有 rejectionHandled:若某个 Promise 先触发了 unhandledRejection,之后又在某个 then/catch 里被接住了,会触发 rejectionHandled;在调试「错误是否被遗漏」时有时会用到。对大多数应用而言,写好 then 链末的 catch、在 async 函数外对调用处加 catch、以及入口处监听 unhandledRejection,就足以保证异步错误不丢失、不导致不可控退出。

未处理的 rejected Promise 会触发 process 的 unhandledRejection 事件,建议在 Node 进程入口监听并至少记录日志,避免错误静默导致进程异常退出。
这节课我们介绍了 Promise 的设计动机、async/await 的本质以及异步错误处理的正确方式。Promise 把「将来的结果」抽象成对象,用 then/catch 和链式调用替代回调嵌套,状态一旦变为 fulfilled 或 rejected 即不可逆;async/await 在 Promise 之上提供「同步写法」的语法糖,底层仍是微任务与事件循环。错误处理上,then 链要在链末或适当位置用 catch 接住错误并避免在中间吞掉,async 函数内用 try/catch 包住 await 可统一捕获该段逻辑中的异常,同时应在 Node 进程入口监听 unhandledRejection 以防遗漏。
下一部分我们将学习「异步控制流与并发管理」,包括串行执行、并行执行以及并发限制与限流思想,届时会大量用到这个部分中的 Promise 与 async/await,并会用到 Promise.all、Promise.race 等组合方式。