从 Event Loop 规范探究 JavaScript 异步及浏览器更新渲染时机
Category(分类): JavaScript Status: 已整理
本地原文件只保留了题目和历史链接;该链接指向一篇完整的旧文章。下面是在保留其主题后的现代整理版。本文讨论的是浏览器宿主中的事件循环;Node.js 有自己的 libuv 阶段和调度细节,不能把两者当成同一套实现。
一、JavaScript 引擎和浏览器事件循环不是一回事
JavaScript 引擎负责执行 ECMAScript 代码、维护执行上下文和 Promise reaction 等作业;浏览器还提供 DOM、网络、计时器、用户输入、渲染和事件循环。所谓“JavaScript 是单线程”,通常是对某一个窗口主线程的简化描述,不代表整个浏览器只有一个线程,也不代表 JavaScript 永远只有一个执行代理。
浏览器页面可以使用 Web Worker、Worklet 等执行环境。每个执行环境有自己的执行上下文和任务调度关系,跨环境通信通常通过消息传递完成。
二、规范中的 task、microtask 和事件循环
WHATWG HTML Standard 将浏览器事件循环中的工作区分为不同概念:
- task(任务):例如初始脚本、计时器回调、用户输入事件、网络事件等。一个 task 会从开始运行到调用栈清空才结束。
- microtask(微任务):Promise reaction、
queueMicrotask回调以及MutationObserver通知等会进入微任务队列。微任务队列不是 task queue。 - rendering opportunity(渲染机会):浏览器在适合的时候更新渲染,不保证每个 task 后都绘制一帧,也不保证固定的帧率。
一个适合面试和排查问题的近似流程是:
- 取一个 task 并执行到完成。
- 执行 microtask checkpoint,持续清空微任务队列;微任务中新增的微任务也会在本次检查点继续执行。
- 浏览器在需要且有渲染机会时,会把
update the rendering排入 rendering task source;事件循环仍可能先选择其他可运行 task,用户代理也可能跳过不必要的更新。更新渲染步骤中可能运行requestAnimationFrame回调,之后才进入具体的样式、布局、绘制等实现流程。 - 进入下一轮,选择新的 task。
这不是“宏任务队列和微任务队列严格轮流”的模型。浏览器可以有多个 task source 和 task queue,规范与浏览器实现还会考虑用户输入、页面可见性、长任务和调度优先级。
三、基础示例:同步代码、微任务和 timer
以下代码适合在浏览器控制台或经典脚本中运行:
console.log('task: start')
setTimeout(() => {
console.log('task: timer')
}, 0)
queueMicrotask(() => {
console.log('microtask: queueMicrotask')
})
Promise.resolve().then(() => {
console.log('microtask: promise')
})
console.log('task: end')
常见输出是:
task: start
task: end
microtask: queueMicrotask
microtask: promise
task: timer
同步代码先完成,当前 task 结束后才处理微任务;微任务清空后,setTimeout 的回调才有机会作为后续 task 执行。setTimeout(..., 0) 只是设置了一个最小延迟阈值,不是“立即执行”。后台页面、嵌套计时器、主线程忙碌和浏览器节流都可能增加实际延迟。
如果微任务不断给自己排队,就可能长时间阻塞后续 task 和渲染:
let count = 0
function keepBusy() {
count += 1
if (count < 1000) {
queueMicrotask(keepBusy)
}
}
queueMicrotask(keepBusy)
真实代码不要无界递归安排微任务;把大批量工作拆成多个 task,或使用 scheduler.postTask、setTimeout 等合适的调度策略,给输入和渲染留下机会。
四、requestAnimationFrame 与更新渲染
requestAnimationFrame 用于请求在下一次浏览器准备更新渲染前执行回调,适合根据最新状态计算动画帧:
const box = document.querySelector('.box')
let startTime
function animate(timestamp) {
if (startTime === undefined) startTime = timestamp
const progress = Math.min((timestamp - startTime) / 1000, 1)
box.style.transform = `translateX(${progress * 200}px)`
if (progress < 1) requestAnimationFrame(animate)
}
requestAnimationFrame(animate)
requestAnimationFrame 不是普通的 microtask,也不是一个可以任意精确预测顺序的 timer。它是否执行、执行频率和后台页面中的调度都会受到浏览器可见性、显示器刷新、节能策略和主线程负载影响。回调参数的时间戳用于计算动画进度,不要假设每次回调间隔固定为 16.67 毫秒。
在同一个 task 中多次修改样式,浏览器通常可以合并布局和绘制;但读取布局信息(如 offsetWidth、getBoundingClientRect())可能强制同步布局。性能优化时应减少读写交错,并使用 Performance 面板验证,而不是只凭事件循环图下结论。
五、MutationObserver、Promise 与渲染的关系
MutationObserver 的通知在 microtask checkpoint 中处理:
const root = document.querySelector('#root')
const observer = new MutationObserver((records) => {
console.log('mutation:', records.length)
})
observer.observe(root, { childList: true, subtree: true })
root.append(document.createElement('span'))
Promise.resolve().then(() => {
console.log('promise reaction')
})
通常可以观察到 DOM 修改后通知异步执行,但不要把具体日志顺序当作跨浏览器、跨版本的渲染保证。MutationObserver 回调本身也可能继续修改 DOM,从而产生更多通知;大量变更应批量处理并及时 disconnect()。
Promise reaction 在 ECMAScript 中通过 HostEnqueuePromiseJob 交给宿主,浏览器再把它接入 microtask checkpoint;queueMicrotask 和 MutationObserver 也属于微任务检查点相关机制,但它们的注册来源和规范处理步骤并不完全相同。DOM Standard 还规定了 MutationObserver 通知集合和记录队列的处理。不要简单说“所有异步回调都进入同一个宏任务队列”。
六、一个包含渲染请求的观察示例
const output = []
const record = (label) => {
output.push(label)
console.log(label)
}
record('task start')
requestAnimationFrame(() => record('animation frame'))
Promise.resolve().then(() => {
record('promise microtask')
requestAnimationFrame(() => record('frame requested from microtask'))
})
setTimeout(() => record('timer task'), 0)
record('task end')
这个示例可以帮助观察同步代码和微任务,但不应写成固定的跨浏览器输出题:timer task、动画帧的相对时机受当前帧、页面负载和浏览器调度影响。能确定的部分是当前 task 的同步代码先完成,当前 microtask checkpoint 会清空已经排队的 Promise reaction。
七、浏览器与 Node.js 的区别
浏览器关注 task source、microtask checkpoint 和更新渲染;Node.js 没有页面绘制阶段,事件循环通常按 timers、pending callbacks、poll、check、close callbacks 等 libuv 阶段描述,并额外提供 process.nextTick 队列。setImmediate 不是浏览器通用 API,Node 中它与 timer 的相对顺序还取决于主模块、I/O 回调、Node 版本和 libuv 版本。
如果文章讨论的是 Node.js,请使用 Node 官方事件循环文档和实际目标版本验证;如果讨论浏览器绘制,请在目标浏览器中使用 Performance、PerformanceObserver、Rendering 面板和 Long Tasks 等工具观察。
八、常见错误结论
1. “微任务一定比宏任务优先”
只有在说明“当前 task 执行完之后,浏览器进行 microtask checkpoint”时,这个说法才有一定教学价值。它不能推导出所有宿主、所有 task source 或 Node.js 阶段的统一顺序。
2. “每轮事件循环都会渲染”
错误。渲染由浏览器在有渲染机会时调度,页面不可见、没有视觉变化、主线程繁忙或浏览器策略变化时都可能跳过或延迟。
3. “setTimeout(fn, 0) 立即执行”
错误。它只表示计时器阈值尽可能短,回调仍需等待当前 task、微任务和调度条件。
4. “Promise 回调就是宏任务”
错误。Promise reaction 通常在微任务队列中调度;Promise 对象本身不是一个任务。
5. “事件循环就是一个队列”
过度简化。HTML Standard 描述了多个 task source/queue、微任务队列和渲染更新步骤;具体调度还由宿主控制。
九、排查异步和卡顿问题的方法
- 先确认运行环境:浏览器主线程、Worker、Node CommonJS 还是 Node ESM。
- 给日志标出来源:同步代码、timer、Promise、
queueMicrotask、事件回调或 rAF。 - 使用 Performance 面板查看 Long Task、帧、布局、绘制和脚本调用栈。
- 不要只根据一次日志排序判断规范;重复测试并记录浏览器、Node、操作系统和页面可见性。
- 将 CPU 密集型工作拆片、移到 Worker,或使用合适的调度器;
scheduler.postTask需要特性检测,不支持时准备setTimeout等 fallback;取消不再需要的 timer、监听器和观察器。
总结
浏览器事件循环的核心关系可以概括为:task 执行到完成,microtask checkpoint 清空微任务,浏览器在有需要时寻找渲染机会,然后选择后续 task。渲染不是 Promise 或 timer 的简单“下一步”,而是浏览器根据帧和资源情况进行的宿主行为。理解这些边界,才能避免把历史面试图误当成跨环境、跨版本的精确执行表。
参考资料: