技术知识文章集合TECHNICAL ARCHIVE · 457 DOCUMENTS

显示模式

登录
ARCHIVE DOCUMENTJS

从 Event Loop 规范探究 JavaScript 异步及浏览器更新渲染时机

所属馆藏
JavaScript
文件格式
Markdown
原始路径
JavaScript/79-从event loop规范探究javaScript异步及浏览器更新渲染时机
本文目录10 个章节
  1. 一、JavaScript 引擎和浏览器事件循环不是一回事
  2. 二、规范中的 task、microtask 和事件循环
  3. 三、基础示例:同步代码、微任务和 timer
  4. 四、requestAnimationFrame 与更新渲染
  5. 五、MutationObserver、Promise 与渲染的关系
  6. 六、一个包含渲染请求的观察示例
  7. 七、浏览器与 Node.js 的区别
  8. 八、常见错误结论
  9. 九、排查异步和卡顿问题的方法
  10. 总结

从 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 后都绘制一帧,也不保证固定的帧率。

一个适合面试和排查问题的近似流程是:

  1. 取一个 task 并执行到完成。
  2. 执行 microtask checkpoint,持续清空微任务队列;微任务中新增的微任务也会在本次检查点继续执行。
  3. 浏览器在需要且有渲染机会时,会把 update the rendering 排入 rendering task source;事件循环仍可能先选择其他可运行 task,用户代理也可能跳过不必要的更新。更新渲染步骤中可能运行 requestAnimationFrame 回调,之后才进入具体的样式、布局、绘制等实现流程。
  4. 进入下一轮,选择新的 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.postTasksetTimeout 等合适的调度策略,给输入和渲染留下机会。

四、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 中多次修改样式,浏览器通常可以合并布局和绘制;但读取布局信息(如 offsetWidthgetBoundingClientRect())可能强制同步布局。性能优化时应减少读写交错,并使用 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、微任务队列和渲染更新步骤;具体调度还由宿主控制。

九、排查异步和卡顿问题的方法

  1. 先确认运行环境:浏览器主线程、Worker、Node CommonJS 还是 Node ESM。
  2. 给日志标出来源:同步代码、timer、Promise、queueMicrotask、事件回调或 rAF。
  3. 使用 Performance 面板查看 Long Task、帧、布局、绘制和脚本调用栈。
  4. 不要只根据一次日志排序判断规范;重复测试并记录浏览器、Node、操作系统和页面可见性。
  5. 将 CPU 密集型工作拆片、移到 Worker,或使用合适的调度器;scheduler.postTask 需要特性检测,不支持时准备 setTimeout 等 fallback;取消不再需要的 timer、监听器和观察器。

总结

浏览器事件循环的核心关系可以概括为:task 执行到完成,microtask checkpoint 清空微任务,浏览器在有需要时寻找渲染机会,然后选择后续 task。渲染不是 Promise 或 timer 的简单“下一步”,而是浏览器根据帧和资源情况进行的宿主行为。理解这些边界,才能避免把历史面试图误当成跨环境、跨版本的精确执行表。

参考资料:

原文参考:从 event loop 规范探究 JavaScript 异步及浏览器更新渲染时机

457 DOCUMENTS · 10 COLLECTIONS
ARCHIVE SEARCH457 篇文章

SEARCH GUIDE

输入关键词开始搜索

支持搜索文章标题、所属分类和原始文档路径。

按分类浏览

10 COLLECTIONS