如果项目刚从 Node.js 12 升到 16,发现 async 函数里有些回调顺序变了,先不要急着给 Promise 加 polyfill。这类问题通常不是逻辑写错,而是 V8 对 Promise 微任务队列的实现有调整,导致 then 回调的触发时机在不同版本下出现可观察的差异。处理时最好按照“先定位差异类型,再选择修复方式,最后做回归”的顺序来。
升级到 Node.js 16 后,如果发现 async 函数在某些场景下执行顺序与之前不同,首先检查是否用到了 util.promisify 或自定义的 thenable。Node.js 12 对 thenable 的解析遵循旧版 Promise 规范,而 16 的 V8 引擎对 Promise 的微任务队列实现有调整,导致 then 回调的触发时机可能提前或延后。一个典型现象是:在 async 函数中 `await someThenable()` 后,紧接着的同步代码在 12 中会先执行,但在 16 中却变成后执行。此时可以用 `process.versions.node` 确认版本,并在最小复现脚本中打印执行日志,观察回调顺序。
如果确认是 thenable 兼容性问题,最简单的修复方式是避免直接 await 自定义 thenable,改用 `Promise.resolve(someThenable)` 包一层。例如:`await Promise.resolve(customThenable)`。这样会强制走标准 Promise 解析流程,消除 V8 版本间的行为差异。修改后需要回归测试所有涉及该 thenable 的异步流程,重点检查错误捕获路径,因为 `Promise.resolve` 会捕获同步 throw 并转为 rejection,与直接 await 的行为可能不同。
先确认是不是 thenable 兼容性问题
升级到 Node.js 16 后,如果发现 async 函数在某些场景下执行顺序与之前不同,首先检查是否用到了 util.promisify 或自定义的 thenable。Node.js 12 对 thenable 的解析遵循旧版 Promise 规范,而 16 的 V8 引擎对 Promise 的微任务队列实现有调整,导致 then 回调的触发时机可能提前或延后。一个典型现象是:在 async 函数中 await someThenable() 后,紧接着的同步代码在 12 中会先执行,但在 16 中却变成后执行。此时可以用 process.versions.node 确认版本,并在最小复现脚本中打印执行日志,观察回调顺序。
注意上述回调顺序变化不一定每次都能稳定复现,可能跟调用栈深度、同时存在的多个 await 有关。建议把业务代码抽成一个独立脚本,只保留触发顺序变化的最小逻辑,然后在 Node 12 和 16 各跑一次,把标准输出放在一起对比。如果差异只出现在 thenable 对象上,就可以确定是 V8 解析差异。如果项目里用了 util.promisify 包装回调函数,也按同样思路检查,因为 util.promisify 在某些情况下会返回一个自定义 thenable。
用 Promise.resolve 包一层,但注意错误捕获差异
如果确认是 thenable 兼容性问题,最简单的修复方式是避免直接 await 自定义 thenable,改用 Promise.resolve(someThenable) 包一层。例如:await Promise.resolve(customThenable)。这样会强制走标准 Promise 解析流程,消除 V8 版本间的行为差异。修改后需要回归测试所有涉及该 thenable 的异步流程,重点检查错误捕获路径,因为 Promise.resolve 会捕获同步 throw 并转为 rejection,与直接 await 的行为可能不同。
使用 Promise.resolve 包一层时要特别注意,Promise.resolve(customThenable) 不是简单的“再等一次”,它会先读取 thenable 的 then 方法,再做一次 Promise 解析。所以如果原来的 thenable 是可变对象,多次 await 会有状态问题,包一层反而会暴露这个隐患。修改后,建议对涉及该 thenable 的每个异步流程都补一条测试用例,把“正常 resolve”“同步 throw”“then 方法不存在”三种情况都跑一遍。因为 Promise.resolve 对非 thenable 的普通值会直接返回一个已 resolve 的 Promise,这可以统一兼容逻辑,但这会改变原有行为。
还有 nextTick 和微任务的顺序变化
另一个常见坑是 async 函数内部使用 process.nextTick 或 setImmediate 时,Node.js 16 对事件循环的微任务检查点做了优化。在 12 中,process.nextTick 回调会在当前微任务队列清空后执行,但在 16 中,如果 async 函数继续产生微任务(如多个 await),process.nextTick 可能会插队到某些微任务之前。修复方法是不要依赖 nextTick 与 Promise 之间的相对顺序,改用 queueMicrotask 或 await Promise.resolve() 来显式控制。若确实要保留原有顺序,可以在 async 函数末尾使用 setImmediate 包裹后续逻辑,但会增加一个宏任务延迟。
process.nextTick 和 queueMicrotask 的差异在于,nextTick 会在当前微任务队列的“早期”执行,而 queueMicrotask 会跟其他 Promise 回调一起按顺序排队。如果你只是希望某个回调在 async 函数之后尽快执行,不强制要求它排在所有 Promise 回调之后,用 queueMicrotask 就够了。但如果业务代码里依赖“nextTick 先于 Promise 回调”的顺序,那即使换成 queueMicrotask 也不能完全复原,需要重新设计。使用 setImmediate 包裹是一个明显的延迟增加,只建议在不能改动核心逻辑时作为临时方案。
升级后的检查步骤与回滚边界
处理完上述两类问题后,建议用 node --trace-promises app.js 跑一遍关键链路,确认每个 await 是否仍按预期拆分。--trace-promises 会输出大量 promise 创建和 resolve 的调用栈,输出文件可能很大,所以建议只在最小复现脚本上使用。同时,检查是否有代码用了 async_hooks 来追踪异步资源,Node 16 对 destroy 事件触发时机有微调,可能导致监听器提前触发。如果项目里用了这类代码,升级后要重新验证资源清理逻辑。
如果测试仍然失败,不要硬啃。可以先保留 Node.js 12 环境作为对照,使用 nvm 或 n 切换版本。对于大型项目,不建议一次性升级所有依赖再排查。可以先用 Node 16 跑现有测试套件,找出 async 相关失败用例,逐一隔离。如果失败用例涉及第三方库(如 bluebird 的 Promise.coroutine),优先查看该库是否有针对 Node 16 的修复版本。若没有,可尝试用 --pending-deprecation 捕获废弃 API 警告,因为 Node 16 对某些 Promise 的静态方法(如 Promise.defer)已标记废弃,这些在 12 中还能用,但 16 中可能影响异步行为。最保守的方案是在 package.json 的 scripts 中用 cross-env NODE_OPTIONS=--no-addons 禁用部分扩展,但仅作为临时绕行方案,不应作为长期修复。