本文目录
购物车里一瓶水原价 3 元,点击优惠券后,代码已经把价格改成了 2 元。紧接着读取 JavaScript 变量,得到的是 2;可眼睛什么时候看到页面上的“2 元”,还取决于浏览器什么时候获得渲染机会。
在同步代码、Promise 回调、定时器和绘制之间,不是简单的一条队伍。
价格已经改了,眼睛为什么还没看见
先看一段故意把页面拖住的代码:
price.textContent = '2 元'
const start = performance.now()
while (performance.now() - start < 1000) {}DOM 中的文本已经被修改,但当前任务又忙了一秒。浏览器不能在这个 JavaScript 循环中间随意绘制,用户通常要等任务结束后才看到新价格。
如果任务结束时还有 Promise 回调待执行,浏览器也不是立刻绘制。它通常要先执行一次微任务检查点。这就是 .then 经常比定时器更早、也比肉眼看到的页面变化更早的原因。
先猜输出:同步、Promise、定时器
console.log('同步开始')
setTimeout(() => {
console.log('timeout')
}, 0)
Promise.resolve().then(() => {
console.log('Promise')
})
console.log('同步结束')典型浏览器中的结果是:
同步开始
同步结束
Promise
timeout当前脚本先从头运行到尾。.then 处理器不会在 Promise.resolve() 那一行同步调用,而是安排为 Promise reaction 对应的 Job;宿主把这类 Job 接入微任务处理。当前任务结束后进行微任务检查点,它先运行;定时器回调属于之后才有机会选择的任务。
下面把同一轮事件循环里的顺序画成流程(和第四章动图是同一套模型,这里强调「绘制」插在哪一步):
Promise 完成和回调执行不是同一件事
Promise 有状态,.then 注册的是状态确定后要执行的 reaction。即使 Promise 已经 fulfilled,处理器仍不能在当前同步代码中突然插队:
const ready = Promise.resolve('现货')
ready.then((value) => {
console.log('取到:', value)
})
console.log('先离开柜台')先输出“先离开柜台”。这个异步保证很重要:调用 .then 的函数不必猜处理器会同步执行还是异步执行。无论 Promise 是早已完成还是稍后完成,reaction 都通过 Job 调度。
ECMAScript 规范描述 Promise reaction Jobs,以及把 Job 交给宿主调度的抽象操作;HTML 宿主再规定浏览器的微任务队列和检查点。教学里常把两层都叫“微任务”,日常够用,但要知道 Promise 的语言语义与浏览器事件循环并非同一份规范。
queueMicrotask 则是浏览器直接提供的微任务排队 API:
queueMicrotask(() => console.log('手工微任务'))它适合需要与 Promise reaction 处在相近调度层级、但不需要创建 Promise 结果的场景。
微任务检查点会一直清到队列为空
微任务检查点不是“只处理检查开始时已有的那批”。运行一个微任务时,如果它又排入新微任务,新成员也会在同一个检查点继续被处理,直到队列为空:
queueMicrotask(() => {
console.log('微任务 A')
queueMicrotask(() => {
console.log('微任务 C')
})
})
queueMicrotask(() => {
console.log('微任务 B')
})
console.log('同步')结果是 同步、A、B、C。A 运行时 C 才进入队列,所以排在已经等待的 B 后;但 C 仍会在浏览器选择下一个普通任务之前执行。
Promise 链也利用这个规则。前一个 .then 的处理器返回后,后一个 reaction 才具备运行条件并被排入队列:
Promise.resolve()
.then(() => console.log('第一段'))
.then(() => console.log('第二段'))它们不是在一次函数调用里同步连跑,而是两个先后发生的 reaction Job。
链中返回的值决定下一段收到什么。返回普通值,后续 Promise 以该值 fulfilled;返回 Promise 或 thenable,后续会采用它最终的状态;抛出异常,后续 Promise rejected:
Promise.resolve(2)
.then((value) => value * 3)
.then((value) => {
throw new Error(`库存只有 ${value} 件`)
})
.catch((error) => {
console.log(error.message)
})每个处理器仍通过相应 reaction Job 运行。这里的 .catch 不是同步的 try...catch 穿过几次函数返回,而是 Promise 链根据 rejected 状态安排后续处理。
Promise 是状态容器,不是“一个微任务”
“Promise 是微任务”是一句容易背错的简称。Promise 是表示异步结果及其状态的对象;通常进入微任务队列的是 .then、.catch、.finally 对应的 reaction Job,而不是 Promise 对象本身。
创建 Promise 时,executor 还会立即同步执行:
const order = new Promise((resolve) => {
console.log('executor')
resolve()
})
order.then(() => console.log('reaction'))
console.log('同步末尾')输出是 executor、同步末尾、reaction。如果只会背“Promise 走微任务”,很可能错误地把 executor 也排到后面。
resolve() 也不等于立即调用所有处理器。它会改变或锁定 Promise 的后续状态处理,并让满足条件的 reaction 按规则排队。若解析的是另一个 Promise 或 thenable,中间还涉及采用其最终状态的过程。
微任务里的异常也要区分来源。queueMicrotask 回调直接抛错,宿主会按未捕获异常报告;.then 处理器抛错,则会让 .then 返回的 Promise rejected。若没人处理这个 rejection,浏览器之后可能报告未处理的 Promise rejection。两者都在微任务阶段运行,错误传播渠道却不相同。
Promise.resolve()
.then(() => {
throw new Error('商品不存在')
})
.catch((error) => {
console.log('已处理:', error.message)
})这也是不能为了少写几个字符,就把所有 Promise 用法机械替换成 queueMicrotask 的原因。
渲染是一种机会,不是每轮必做的作业
常见流程图写成:
一个任务 → 清空微任务 → 渲染 → 下一个任务作为第一张图没有问题,但“渲染”不能读成每一轮强制发生。浏览器会判断是否到了合适的渲染时机、文档是否需要更新、页面是否可见以及刷新率等条件。两个很短的任务之间可能没有实际绘制,后台标签页的渲染也可能被明显限制。
更稳妥的理解是:任务完成并执行微任务检查点后,事件循环在适当条件下可能进行渲染更新。JavaScript 能制造机会,却不能要求屏幕在每次 DOM 赋值后立即发出一帧。
这也是为什么下面的三个值未必逐个被肉眼看见:
progress.textContent = '1'
queueMicrotask(() => {
progress.textContent = '2'
})
queueMicrotask(() => {
progress.textContent = '3'
})微任务之间不会穿插渲染。检查点结束时,DOM 很可能已经是最终的 3。
requestAnimationFrame 站在什么位置
如果代码要在下一次绘制前更新动画状态,可以使用 requestAnimationFrame:
requestAnimationFrame((timestamp) => {
box.style.transform = `translateX(${timestamp % 300}px)`
})回调会在浏览器准备更新渲染时运行,通常与显示刷新节奏配合。它不是普通任务的别名,也不是 Promise 微任务的替代品。页面不可见时,回调频率还可能暂停或降低。
想让一个 DOM 中间状态确实有机会显示,常见做法是先设置状态,再把下一阶段工作放到后续渲染帧。不过一次 requestAnimationFrame 只说明回调位于某次渲染更新前,并不承诺前一行已经被用户看见一整帧。若业务对“两次绘制之间”有严格要求,需要结合两帧安排并实际测量。
用于动画时,优先依据回调提供的时间戳计算进度,而不是假定每 16.67 毫秒必定调用一次。高刷新率屏幕、后台节流和繁忙主线程都会改变间隔。
有时还会看到 requestAnimationFrame 回调里读取布局、再写入样式。这样做是为了让工作靠近渲染更新,并把读取和写入有意识地分组;它不保证任何 DOM 操作都变快。若代码交替读写会触发布局信息的属性,浏览器可能被迫提前计算布局,调度位置和具体 DOM 访问模式都要检查。
微任务太勤快,也会把页面饿住
因为检查点会持续处理新加入的微任务,下面的代码可以长期不给普通任务和渲染留下机会:
function again() {
queueMicrotask(again)
}
again()每个微任务结束前又安排下一个,队列始终无法真正清空。定时器、点击处理器和绘制可能迟迟得不到机会。这叫微任务饥饿,比“单个函数很长”更隐蔽,因为每个回调看起来都很短。
因此,不要把大批 CPU 工作拆成无穷微任务来“避免阻塞”。微任务适合在当前同步操作之后、其他任务之前完成少量一致性工作;需要把执行机会还给浏览器时,应安排后续任务、使用合适的调度 API,或把重计算移到 Worker。
框架常借助微任务批量合并状态更新,这能避免同一轮同步修改触发多次重复工作。但框架也必须控制批量过程,不能无限制造新的更新。
因此“想尽快执行就用微任务”不是好原则。微任务的优先位置意味着它有责任尽快结束。日志上报、复杂数据转换、海量列表处理若都塞进微任务,虽然比定时器早,却可能让用户更晚看到页面。调度选择应从“谁必须先看到一致状态、谁可以等待下一次机会”出发。
容易踩的坑:背口诀不等于理解调度
“同步 → 微任务 → 宏任务”只能解最小例子。它漏掉了当前代码属于哪个任务、微任务执行时还能继续排队、不同任务源、渲染机会以及环境差异。
另一个误解是“.then 总比 setTimeout 先”。如果两者不是在同一个同步阶段登记,或者定时器任务已经开始执行,不能靠这句话跨时间倒推。更准确的结论是:在前面的示例中,当前任务结束后的微任务检查点先处理已排队的 Promise reaction,之后事件循环才选择定时器任务。
还要避免把浏览器结论原样搬到所有 JavaScript 宿主。Node.js 如何安排自己的队列与阶段,需要按 Node 的文档单独分析。
最后,微任务先于定时器也不等于微任务拥有更高的操作系统线程优先级。这里讨论的是同一个事件循环中的处理顺序,不是 CPU 调度优先级。把两套概念混在一起,会误以为 Promise 能抢占正在执行的同步函数。
实际排查顺序问题时,可以给日志附上任务来源和阶段,而不是只打一个数字。例如记录“点击同步段”“点击内的 microtask”“timer task”,再在 Performance 面板中对照。日志的打印顺序只能告诉你结果,来源标签才能帮助解释为什么形成这个结果。
如果问题只在某个框架中出现,还要确认框架自己的批处理队列何时接入微任务。语言和浏览器规则决定外层顺序,框架调度器仍可能在一个微任务里合并、排序或跳过内部更新,不能把框架的 nextTick 与原生 queueMicrotask 直接画等号。
先把原生最小示例跑通,再逐层加回框架调度,通常比在完整页面里猜某个日志为何提前更有效。
验证题:在微任务里再加入一个微任务
不运行代码,先写下顺序:
console.log('A')
setTimeout(() => console.log('B'), 0)
Promise.resolve().then(() => {
console.log('C')
queueMicrotask(() => console.log('D'))
})
queueMicrotask(() => console.log('E'))
console.log('F')答案是 A、F、C、E、D、B。C 排队早于 E;C 执行时加入 D,D 在 E 后面,但仍属于同一次微任务检查点。确认答案后再自己跑一遍,并在 D 中继续加入一个微任务,观察规则是否改变。
下一步:如果不是等回调,而是主动要下一个值
事件循环解决“什么时候轮到一段代码”。另一类问题是“数据应该怎样逐个提供”。
数组可以被 for...of 遍历,自定义对象为什么默认不行?循环每一步到底向谁索取下一个值?下一章从一台不会提前打印整卷号码的取号机开始,拆开可迭代对象和迭代器这两个经常被混叫的角色。