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。
读写布局交错
循环中交替读取 offsetHeight、getBoundingClientRect 并修改样式,会触发重复布局。先批量读取,再批量写入;动画只改 transform 和 opacity,降低布局成本。
用工具而不是猜
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 和串行请求。
- 动画掉帧:检查布局抖动、图片解码和主线程脚本。
- 数据一多就卡:减少一次渲染节点,使用虚拟列表或分批处理。

评论
0 条讨论