JavaScript 微任务顺序:Promise 已经完成,then 为什么还要等一会儿
状态完成与回调执行不是同一个时刻
读取缓存时,我们可能已经拿到结果,于是返回一个立即完成的 Promise。调用方给它登记 then 回调后,却发现下一行同步代码先执行。这种行为会影响初始化标志、日志顺序和事件通知,尤其容易让人误以为缓存分支与网络分支采用了两套不可预测的规则。
理解这个问题可以先只看三件事:当前同步代码是否执行完、回调何时加入队列、队列里的回调何时轮到执行。Promise 的状态已经完成,不表示 then 的回调会立刻插入当前语句之间。创建 Promise 时运行的执行器,与之后登记的成功回调,也要分开观察。
用一份日志记录实际执行顺序
下面保存为 demo.cjs,使用 Node 运行,本文在二十四点十九版本验证。示例不请求网络,不依赖文件或页面,也不使用 nextTick 和立即执行定时器。这样可以把问题限制在同步代码、Promise 反应回调、显式微任务与一个普通定时器之间。
数组只在回调真正执行时写入标签。我们先登记一个定时器,再登记多个微任务,还让一个微任务继续登记新的微任务。最后由定时器核对整份顺序。测试要求标签顺序一致,并不声称定时器会在精确的毫秒时刻触发。
AI概念配图,非真实界面
const assert = require('node:assert/strict');
const events = [];
events.push('sync:start');
const ready = new Promise((resolve) => {
events.push('executor');
resolve(7);
});
setTimeout(() => {
events.push('timer');
assert.deepEqual(events, [
'sync:start', 'executor', 'sync:end',
'then:first', 'microtask', 'then:second', 'nested', 'timer'
]);
console.log(events.join(' > '));
}, 0);
ready.then(() => {
events.push('then:first');
queueMicrotask(() => events.push('nested'));
});
queueMicrotask(() => events.push('microtask'));
ready.then(() => events.push('then:second'));
events.push('sync:end');
assert.deepEqual(events, ['sync:start', 'executor', 'sync:end']);
const chainEvents = [];
const result = Promise.resolve(4).then((value) => {
chainEvents.push('callback');
return value * 2;
});
chainEvents.push('after registration');
assert.deepEqual(chainEvents, ['after registration']);
assert.equal(result instanceof Promise, true);
result.then((value) => {
assert.equal(value, 8);
assert.deepEqual(chainEvents, ['after registration', 'callback']);
console.log('chain value:', value);
}).catch((error) => {
console.error(error);
process.exitCode = 1;
});新登记的微任务排在已经等待的任务之后
输出先出现同步开始、执行器与同步结束。接下来依次运行最先登记的 Promise 回调和显式微任务,然后是后续登记的 Promise 回调。第一个回调内部增加的微任务并没有抢到队首,而是排到当时已经等待的任务之后,这正是 nested 最后出现的原因。
因此不能只看源码缩进来判断异步顺序。一个回调写在另一个回调的函数体里,说明它要等外层执行到登记语句时才加入队列;在那之前,别处登记的任务可能已经排好了。调试时记录实际登记点与实际执行点,比把所有异步语句笼统分成一组更有帮助。
异步通知要保持一致,也要接住失败
第二个例子演示链式返回值:then 调用立即得到新的 Promise,里面最终保存的是回调返回的结果。紧接着的同步断言仍然先执行,回调稍后才把事件追加到列表。需要最终结果时,应等待这个 Promise,而不能把登记后的下一行当成“已经拿到结果”的位置。
实际接口如果命中缓存时直接调用回调,未命中时却异步调用,使用方就必须同时应付两种时序。可以在缓存分支也安排异步通知,让订阅动作有一致的机会完成。不过排入微任务只调整调用时机,不会自动提供取消、超时或任务去重,这些仍属于接口协议。
错误传递也有区别:Promise 回调抛出的异常会使对应的链进入拒绝状态,应当由后续处理或顶层等待捕获。显式微任务回调直接抛错则走宿主的异常路径。不要因为两个回调常在同一个阶段执行,就认为它们拥有相同的返回值与异常处理方式。
顺序保证不等于浏览器已经刷新
微任务会继续处理本轮过程中加入的微任务,因此不断给自己追加工作可能让其他工作迟迟得不到机会。把一大段计算拆成很多微任务,不必然能改善界面响应;需要让出执行机会时,应结合宿主的任务调度与实际耗时做设计,不能只把函数换个队列。
本例验证的是指定 Node 环境中的最小顺序,没有页面渲染,也没有输入事件。它不能证明某个回调执行后浏览器已经绘制画面,更不能据此推导所有定时器与所有输入输出回调的相对顺序。扩展实验时应一次加入一种机制,并记录文件类型与运行时版本。
验收异步流程时,优先用完成后的状态或可控事件来断言,而不是凭一个固定等待时间猜它已经结束。这里的定时器承担最终核对入口,链式例子则在对应成功回调里检查结果;两处都把“等待了”与“观察到正确结果”明确区分开。


