浏览器的渲染流水线
浏览器的页面渲染不是即时的,而是经过一系列步骤:
- JavaScript 执行:修改 DOM 或样式
- Style:计算每个元素的样式
- Layout:计算每个元素的几何信息(位置、大小)
- Paint:将元素绘制到多个图层
- Composite:将多个图层合成为最终页面
这些步骤并不是每次都完整执行。如果只是修改了颜色等不影响布局的属性,浏览器可以跳过 Layout 步骤。
渲染与事件循环的关系
浏览器在每次事件循环迭代中,会在特定时机检查是否需要渲染:
- 所有微任务执行完毕后
requestAnimationFrame回调执行- 检查是否需要 Layout/Paint
- 如果需要渲染,在下一个宏任务前进行
渲染时机
console.log('1');
requestAnimationFrame(() => {
console.log('2');
});
console.log('3');
// 输出: 1, 3, 2
requestAnimationFrame 的回调在渲染前被调用,而不是在微任务之后立即调用。
为什么 setTimeout 不适合动画
// 错误的动画实现
let position = 0;
function animate() {
position += 5;
element.style.transform = `translateX(${position}px)`;
setTimeout(animate, 16);
}
问题:
setTimeout只是把回调放进宏任务队列,不保证在哪个渲染时机执行- 如果回调在两帧之间执行,会导致跳帧
- 页面不可见时(如切换标签页),动画仍然执行,浪费 CPU
requestAnimationFrame 的正确用法
function animate() {
// 更新动画状态
updateAnimation();
// 浏览器会在下一帧渲染前调用
requestAnimationFrame(animate);
}
requestAnimationFrame(animate);
requestAnimationFrame 的优势:
- 与浏览器渲染同步:回调在渲染前执行,保证每帧都能更新
- 页面不可见时自动暂停:切换标签页时,RAF 停止执行,节省资源
- 自动降频:如果设备无法达到 60fps,RAF 会自动减少调用次数
宏任务、微任务、渲染的完整时序
console.log('1');
setTimeout(() => {
console.log('2');
}, 0);
Promise.resolve().then(() => {
console.log('3');
});
requestAnimationFrame(() => {
console.log('4');
});
console.log('5');
执行顺序:
- 同步代码:
1, 5 - 微任务:
3 requestAnimationFrame回调:4(在渲染前)setTimeout回调:2(下一个宏任务)
长任务与渲染阻塞
如果 JavaScript 执行时间过长(> 50ms),会阻塞渲染,导致页面卡顿。这就是"长任务"问题。
// 长任务:阻塞渲染
function heavyComputation() {
const result = calculateLargeDataset(); // 假设需要 100ms
return result;
}
解决方案:使用 requestIdleCallback 将工作拆分到多个帧:
function performWork(deadline) {
while (deadline.timeRemaining() > 0 && workQueue.length > 0) {
processNextItem();
}
if (workQueue.length > 0) {
requestIdleCallback(performWork);
}
}
requestIdleCallback(performWork);
这一章想说的
浏览器渲染和事件循环紧密关联:
- 渲染发生在微任务执行完毕后、
requestAnimationFrame回调之后 requestAnimationFrame是动画的正确 API,与渲染同步,自动节流setTimeout不适合动画,因为它不保证渲染时机- 长任务会阻塞渲染,应该拆分到多个帧