事件循环不仅控制异步代码的执行顺序,还直接影响浏览器的页面渲染。理解两者的关联,是解释“为什么频繁修改 DOM 但屏幕上没及时更新”、“为什么微任务会导致页面卡顿”等问题的钥匙。
一次完整的事件循环迭代
将浏览器的事件循环简化成下面这个流程(一次迭代):
宏任务(取一个执行)
↓
微任务队列(全部清空)
↓
(可能)渲染更新
↓
下一轮宏任务
这里的“渲染更新”包括样式计算、布局、绘制等流程,也就是第 18 章要详细拆解的关键渲染路径。不是每一轮迭代都会触发渲染,浏览器会根据显示器的刷新率(通常 60Hz,约 16.6ms 一帧)和页面是否有变化来综合判断。
微任务与渲染:微任务会“插队”阻塞渲染
关键点:微任务必须在上一个宏任务之后、本轮渲染之前全部执行完毕。如果某个微任务中又不断派生出新的微任务(比如 Promise 的链式递归),微任务队列永远无法清空,渲染就会被无限推迟,页面看起来像“卡死”一样。
// 危险的递归微任务,会阻塞渲染
function loopMicro() {
Promise.resolve().then(loopMicro);
}
loopMicro();
// 渲染永远无法触发,页面失去响应
这也是为什么 React、Vue 等框架会将状态更新批处理为微任务(React 中的 queueMicrotask 或 Promise 回调),以此在渲染前合并多次修改,但又必须避免出现上述无限递归。
宏任务与渲染:一次宏任务触发一次可能渲染
宏任务(如 setTimeout、setInterval、DOM 事件回调)是事件循环的“基本节拍”。每一个宏任务执行结束后,浏览器都有机会进行一次渲染。但实际是否真的渲染,一方面取决于页面是否需要更新(如果该宏任务没有改变 DOM 或样式,就不需要渲染),另一方面也受到浏览器自身优化策略的限制——为了节电或避免无谓工作,浏览器可能合并多个宏任务的渲染。
document.body.style.background = 'red';
setTimeout(() => {
document.body.style.background = 'blue';
}, 0);
上面的代码在视觉上可能直接呈现出蓝色,而不是先红后蓝,因为两次修改很可能落在了同一帧的渲染时机内(取决于定时器的间隔和事件循环节奏)。
requestAnimationFrame:精确在渲染前执行
若希望在渲染发生前执行一段动画更新逻辑,应该使用 requestAnimationFrame(rAF)。rAF 回调的时机是在微任务清空之后、布局和绘制之前。它是浏览器为动画优化的 API,保证在一次渲染绘制前调用,而且如果页面不可见(如切到了后台标签页),回调就不会执行,从而节省资源。
典型的动画循环:
function animate() {
// 更新元素位置、样式等
element.style.transform = `translateX(${x}px)`;
requestAnimationFrame(animate);
}
requestAnimationFrame(animate);
与 setTimeout 不同,rAF 与显示器的刷新率同步,不会出现丢帧或多帧累积问题,并且能自动在页面隐藏时停下。
实践中的性能启示
- 拆分长任务:如果一个宏任务(如处理大量数据)耗时超过 50ms,就会导致该帧的渲染无法在 16.6ms 内完成,表现为掉帧、卡顿。此时应当将大任务拆分成多个小宏任务(
setTimeout切割)或使用requestIdleCallback(在空闲时执行)。 - 谨慎使用微任务:不要在微任务中执行耗时操作,也不要构造无终止的微任务链。
- 动画用 rAF:所有与视觉变化相关的连续操作都交给
requestAnimationFrame,它既保证时机正确,又能减少不必要的绘制。 - 理解异步更新机制:现代框架的批量更新正是利用了“微任务 → 渲染”的时序,在下一个微任务中合并状态变更,再触发渲染,以此获得性能优势。
简言之,事件循环决定了“JS 代码何时执行”,而它与渲染的关联则决定了“用户何时能看到变化”。掌握这层关系,你就能写出既能正确运行、又对用户流畅体验友好的前端代码。