JavaScript 事件循环:从微任务到页面卡顿排查

JavaScript 的“单线程”不意味着浏览器一次只做一件事。网络、计时器和渲染由宿主环境协作,但回调最终仍要进入主线程。理解任务、微任务和渲染机会的顺序,是排查输入延迟、动画掉帧和“页面明明没死却点不动”的基础。

一轮事件循环发生什么

可以简化为:执行一个 Task,清空 Microtask 队列,浏览器选择是否渲染,然后进入下一轮。setTimeout 回调属于 Task,Promise 回调和 queueMicrotask 属于 Microtask。

console.log('A')
setTimeout(() => console.log('B'), 0)
Promise.resolve().then(() => console.log('C'))
console.log('D')

// A, D, C, B

微任务会在当前 Task 结束后、渲染前被清空。如果一个微任务不断追加新微任务,浏览器可能迟迟得不到渲染和处理输入的机会。

常见卡顿来源

大循环占满主线程

处理十万条数据、复杂 Markdown 或大 JSON 时,单个任务可能执行数百毫秒。拆分工作并在批次间让出主线程:

async function processInChunks(items, size = 500) {
  for (let i = 0; i < items.length; i += size) {
    processBatch(items.slice(i, i + size))
    await new Promise(resolve => setTimeout(resolve, 0))
  }
}

如果计算与 DOM 无关,优先放入 Web Worker。拆分只能改善响应性,不会减少总计算量。

Promise 链制造微任务饥饿

大量已完成 Promise 的连续回调会在同一轮清空。不要用递归 Promise 充当无限调度器;需要给页面响应机会时,应切到下一个 Task,而不是继续 queueMicrotask

读写布局交错

循环中交替读取 offsetHeightgetBoundingClientRect 并修改样式,会触发重复布局。先批量读取,再批量写入;动画只改 transformopacity,降低布局成本。

用工具而不是猜

Chrome Performance 面板录制一次真实操作,重点看:

  • Main 线程上超过 50ms 的 Long Task。
  • Scripting、Style、Layout、Paint 各自占比。
  • 事件回调到下一帧之间是否被其他任务阻塞。
  • 是否有频繁强制同步布局。

代码中可以用 User Timing 标记业务阶段:

performance.mark('render-start')
renderLargeList()
performance.mark('render-end')
performance.measure('render-list', 'render-start', 'render-end')

列表渲染还应结合虚拟滚动、稳定 Key、分页和图片懒加载。仅把同步逻辑套上 async 不会自动移出主线程。

实用判断

  • 输入延迟高、CPU 高:优先找 Long Task。
  • CPU 不高但页面等待:检查网络瀑布、锁定中的 Promise 和串行请求。
  • 动画掉帧:检查布局抖动、图片解码和主线程脚本。
  • 数据一多就卡:减少一次渲染节点,使用虚拟列表或分批处理。

参考资料