渲染优化
问题
浏览器是如何渲染页面的?什么是重排和重绘?如何减少渲染性能问题?
浏览器是如何渲染页面的? 关键路径就一句话:Parse → Style → Layout → Paint → Composite:
- HTML 解析成 DOM,CSS 解析成 CSSOM,两者合成 Render Tree。
- Layout(布局)计算节点的位置和尺寸;Paint 生成绘制记录并栅格化为像素;Composite 把图层按顺序组合成最终帧。
- 通常越晚进入流水线,影响范围越小,但 Composite 也可能受图层数量、纹理面积、栅格化和 GPU/显存限制影响,不能简单理解为“免费”。
什么是重排和重绘? 两者都是渲染流水线被「打回去重做」:
- 重排(Reflow/Layout):几何属性(
width、top、margin、display等)变化后需要重新计算布局,影响范围取决于 DOM、布局模式和contain边界,并不一定波及整个页面。 - 重绘(Repaint):只是外观变(
color、background、border-color、visibility),跳过 Layout 直接重画,比重排便宜。 - 强制同步布局(Layout Thrashing) 是大坑:在循环里读
offsetWidth又写style.width,浏览器被迫每次都同步算 Layout,性能直接崩。
如何减少渲染性能问题? 核心是「减少无效工作」+「缩小影响范围」+「批量读写」:
- 动画优先
transform和opacity——它们通常能避开 Layout/Paint,但是否独立合成由浏览器决定。 - 分层提示:动画前短暂使用
will-change,结束后移除;不要依赖translateZ(0)作为“开启 GPU”的固定写法。 - 避免 Layout Thrashing:先批量读、再批量写;或用
requestAnimationFrame把写操作合到下一帧。 - 长列表用虚拟滚动减少 DOM、复杂场景考虑
content-visibility: auto跳过屏外渲染。
答案
渲染优化的核心是减少浏览器的样式、布局、绘制、栅格和合成工作,并把必要更新控制在尽可能小的范围。帧预算取决于设备刷新率:60Hz 约 16.67ms,120Hz 约 8.33ms。
浏览器渲染流程
| 阶段 | 说明 | 触发条件 |
|---|---|---|
| Parse | 解析 HTML/CSS | 文档加载 |
| Style | 计算样式 | CSS 变化 |
| Layout(重排) | 计算几何信息 | 尺寸/位置变化 |
| Paint(重绘) | 绘制像素 | 颜色/背景变化 |
| Composite | 图层和纹理合成 | transform/opacity 等属性满足条件时可能只更新此阶段 |
重排(Reflow)
重排是浏览器重新计算元素几何属性(位置、尺寸)的过程。它通常比单纯绘制或合成更昂贵,但成本取决于布局树规模、影响范围和布局算法。
触发重排的属性
// 几何属性
const layoutProperties = [
'width', 'height',
'padding', 'margin', 'border',
'top', 'left', 'right', 'bottom',
'position', 'display', 'float',
'font-size', 'line-height',
'text-align', 'vertical-align',
'overflow', 'white-space'
];
// 触发重排的操作
const layoutOperations = [
'offsetWidth/Height', // 读取
'clientWidth/Height',
'scrollWidth/Height',
'getComputedStyle()',
'getBoundingClientRect()'
];
强制同步布局
// ❌ 强制同步布局(Layout Thrashing)
function bad() {
const elements = document.querySelectorAll('.item');
elements.forEach(el => {
// 读取导致强制布局
const width = el.offsetWidth;
// 写入触发重排
el.style.width = width + 10 + 'px';
});
}
// ✅ 读写分离
function good() {
const elements = document.querySelectorAll('.item');
// 批量读取
const widths = Array.from(elements).map(el => el.offsetWidth);
// 批量写入
elements.forEach((el, i) => {
el.style.width = widths[i] + 10 + 'px';
});
}
重绘(Repaint)
重绘是重新绘制元素外观的过程,不涉及布局计算,成本低于重排。
只触发重绘的属性
const paintOnlyProperties = [
'color',
'background',
'border-color',
'border-style',
'border-radius',
'box-shadow',
'outline',
'visibility'
];
合成(Composite)
transform 和 opacity 在元素已经完成绘制并满足合成条件时,通常可以只更新合成阶段;这仍是浏览器实现决定的优化结果,不是 CSS 规范保证。
触发合成的属性
// 通常合成友好;filter 和 will-change 不能简单视为“只合成属性”
const compositeProperties = [
'transform',
'opacity'
];
合成层与分层提示
浏览器如何决定是否分层
/* 下面写法可能影响浏览器分层,但不会保证创建独立层 */
.accelerated {
transform: translateZ(0);
/* 或 */
transform: translate3d(0, 0, 0);
/* 或 */
will-change: transform;
}
/* 动画使用 transform 而非 left/top */
.animation {
/* ❌ 触发重排 */
/* left: 100px; */
/* ✅ 通常可避免布局;是否只合成要用 DevTools 验证 */
transform: translateX(100px);
}
will-change 优化
/* 提前告知浏览器将要变化的属性 */
.will-animate {
will-change: transform, opacity;
}
/* 动画结束后移除 */
.will-animate.done {
will-change: auto;
}
// JavaScript 动态管理 will-change
element.addEventListener('mouseenter', () => {
element.style.willChange = 'transform';
});
element.addEventListener('animationend', () => {
element.style.willChange = 'auto';
});
过度使用 will-change 会消耗大量内存,应在需要时添加,不需要时移除。
优化技巧
批量 DOM 操作
// ❌ 多次操作 DOM
function bad() {
for (let i = 0; i < 1000; i++) {
document.body.appendChild(createItem(i));
}
}
// ✅ 使用 DocumentFragment
function good() {
const fragment = document.createDocumentFragment();
for (let i = 0; i < 1000; i++) {
fragment.appendChild(createItem(i));
}
document.body.appendChild(fragment);
}
// ✅ 使用 innerHTML(简单场景)
function better() {
const html = Array.from({ length: 1000 })
.map((_, i) => `<div class="item">${i}</div>`)
.join('');
container.innerHTML = html;
}
innerHTML 不是通用性能捷径只有模板完全可信、无需保留节点状态和事件时才考虑使用。包含用户输入时会有 XSS 风险;现代浏览器也会合并部分 DOM 工作,因此应先测量,再选择 Fragment、框架批处理或模板解析。
离线 DOM 操作
// 方式1:display: none
element.style.display = 'none';
// 多次修改...
element.style.display = 'block';
// 方式2:cloneNode
const clone = element.cloneNode(true);
// 修改 clone...
element.parentNode.replaceChild(clone, element);
// 方式3:DocumentFragment
const fragment = document.createDocumentFragment();
// 操作 fragment...
container.appendChild(fragment);
避免频繁读取布局属性
// ❌ 循环中读取布局属性
function bad() {
const width = element.offsetWidth; // 每次都触发重排
// ...
}
// ✅ 缓存布局属性
function good() {
const width = element.offsetWidth; // 只读一次
for (let i = 0; i < 100; i++) {
// 使用缓存的 width
}
}
使用 CSS 类批量修改
// ❌ 多次修改样式
element.style.width = '100px';
element.style.height = '100px';
element.style.background = 'red';
// ✅ 使用 CSS 类
element.classList.add('active');
.active {
width: 100px;
height: 100px;
background: red;
}
用 containment 缩小渲染范围
.feed-card {
/* 内部布局和绘制尽量不影响外部 */
contain: layout paint;
}
.below-fold-section {
/* 跳过屏外内容的样式、布局和绘制 */
content-visibility: auto;
/* 为未渲染内容提供估算高度,减少滚动条和 CLS 变化 */
contain-intrinsic-size: auto 600px;
}
content-visibility: auto 适合相对独立、屏外占比较高的长页面区块。它不是虚拟列表的完全替代:DOM 仍存在,浏览器查找、焦点、可访问性和动态高度都要在真实页面中验证。
控制 DOM 规模和单次提交量
- 对超长列表使用分页、虚拟化或分段渲染,不要只优化单个组件。
- 避免一次交互同步挂载成百上千个复杂节点;可先提交视觉反馈,再分批完成非关键内容。
- 支持时可用
scheduler.yield()主动让出主线程,并提供setTimeout等降级;不要在循环中无条件每项都 yield,任务过碎也有调度成本。 - React/Vue 的 memo、computed、组件拆分都应以 Profiler 证据为前提,缓存本身也有比较和内存成本。
动画优化
使用 transform 和 opacity
/* ❌ 触发重排的动画 */
@keyframes move-bad {
from { left: 0; }
to { left: 100px; }
}
/* ✅ 通常可以避免 Layout/Paint */
@keyframes move-good {
from { transform: translateX(0); }
to { transform: translateX(100px); }
}
/* visibility 适合语义切换,但不能产生连续淡入效果 */
@keyframes fade-bad {
from { visibility: hidden; }
to { visibility: visible; }
}
/* opacity 通常合成友好;隐藏后还要处理 pointer-events 和可访问性 */
@keyframes fade-good {
from { opacity: 0; }
to { opacity: 1; }
}
requestAnimationFrame
// ❌ setTimeout 动画
function animateBad() {
element.style.left = parseInt(element.style.left) + 1 + 'px';
setTimeout(animateBad, 16);
}
// ✅ requestAnimationFrame 动画:按时间推进,适配 60Hz/120Hz 和偶发掉帧
const startTime = performance.now();
const duration = 300;
function animateGood(timestamp: number) {
const progress = Math.min((timestamp - startTime) / duration, 1);
element.style.transform = `translateX(${progress * 100}px)`;
if (progress < 1) {
requestAnimationFrame(animateGood);
}
}
requestAnimationFrame(animateGood);
CSS 动画 vs JS 动画
// 简单动画用 CSS
// 复杂控制用 JS + Web Animations API
// Web Animations API
element.animate([
{ transform: 'translateX(0)', opacity: 1 },
{ transform: 'translateX(100px)', opacity: 0 }
], {
duration: 1000,
easing: 'ease-in-out',
fill: 'forwards'
});
React 渲染优化
// 1. React.memo 避免不必要的重渲染
const MemoizedComponent = React.memo(({ data }) => {
return <div>{data}</div>;
});
// 2. useMemo 缓存计算结果
function Component({ items }) {
const expensiveResult = useMemo(() => {
return items.reduce((acc, item) => acc + item.value, 0);
}, [items]);
return <div>{expensiveResult}</div>;
}
// 3. useCallback 缓存函数
function Parent() {
const handleClick = useCallback(() => {
console.log('clicked');
}, []);
return <Child onClick={handleClick} />;
}
// 4. 列表 key
function List({ items }) {
return (
<ul>
{items.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
);
}
Vue 渲染优化
<script setup lang="ts">
import { computed, shallowRef } from 'vue';
// 1. computed 缓存
const filteredList = computed(() => {
return list.value.filter(item => item.active);
});
// 2. shallowRef 浅响应
const largeData = shallowRef<LargeData[]>([]);
// 3. v-once 只渲染一次
// <div v-once>{{ staticContent }}</div>
// 4. v-memo 缓存子树
// <div v-memo="[item.id]">{{ item.name }}</div>
</script>
<template>
<!-- 5. key 优化 -->
<div v-for="item in list" :key="item.id">
{{ item.name }}
</div>
<!-- 6. 组件懒加载 -->
<Suspense>
<AsyncComponent />
</Suspense>
</template>
性能检测
Chrome DevTools
// Performance 面板
// 1. 录制性能
// 2. 查看 Layout/Paint 时间
// 3. 分析长任务
// Rendering 面板
// 1. Paint flashing - 查看重绘区域
// 2. Layout Shift Regions - 查看布局偏移
// 3. FPS meter - 查看帧率
// Layers 面板
// 查看合成层
Performance API
// 测量渲染时间
performance.mark('render-start');
// 渲染操作...
renderComponent();
performance.mark('render-end');
performance.measure('render', 'render-start', 'render-end');
const measure = performance.getEntriesByName('render')[0];
console.log(`渲染耗时: ${measure.duration}ms`);
// 支持时观察完整的长动画帧;比只看 Long Task 更容易定位渲染阶段开销
if (PerformanceObserver.supportedEntryTypes.includes('long-animation-frame')) {
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.warn('Long animation frame:', entry);
}
}).observe({ type: 'long-animation-frame', buffered: true });
}
常见面试问题
Q1: 什么是重排和重绘?如何避免?
答案:
| 概念 | 说明 | 触发条件 | 性能影响 |
|---|---|---|---|
| 重排 | 重新计算布局 | 尺寸、位置变化 | 高 |
| 重绘 | 重新绘制外观 | 颜色、背景变化 | 中 |
| 合成 | 图层与纹理组合 | transform、opacity 满足条件时 | 通常较低,层过多时仍可能昂贵 |
避免方法:
- 批量修改 DOM
- 使用 CSS 类
- 避免读取触发重排的属性
- 使用 transform/opacity 做动画
- 使用 will-change 提示
Q2: 如何优化动画性能?
答案:
/* 1. 使用 transform 而非 left/top */
.animate {
transform: translateX(100px);
}
/* 2. 需要连续淡入时使用 opacity;完全隐藏时同步处理交互和可访问性 */
.fade {
opacity: 0;
pointer-events: none;
}
/* 3. 动画前短暂提示即将变化;是否分层由浏览器决定 */
.gpu {
will-change: transform;
}
// 4. 使用 requestAnimationFrame
function animate(now: number) {
updateByTimestamp(now);
requestAnimationFrame(animate);
}
// 5. 使用 Web Animations API
element.animate([...keyframes], options);
Q3: 什么是合成层?开发者能强制创建吗?
答案:
合成层是浏览器为了独立栅格和组合部分内容而建立的层。它可以缩小后续更新范围,但会增加纹理内存、栅格和管理成本。开发者不能通过标准 CSS 强制浏览器创建某个层,只能表达属性变化意图并用 DevTools 验证结果。
/* 推荐:在动画即将开始时使用提示 */
.composite-layer {
will-change: transform;
}
translateZ(0)、opacity: 0.99、filter: blur(0) 属于历史上的分层技巧,不应作为生产代码模板。
Q4: 如何检测渲染性能问题?
答案:
// 1. Chrome DevTools
// - Performance 录制
// - Rendering > Paint flashing
// - Layers 面板查看合成层
// 2. Performance API
const observer = new PerformanceObserver((list) => {
list.getEntries().forEach(entry => {
if (entry.duration > 50) {
console.warn('Long task:', entry);
}
});
});
observer.observe({ entryTypes: ['longtask'] });
// 新版 Chromium 中还可结合 long-animation-frame 分析脚本、样式、布局和绘制
// 3. requestAnimationFrame 检测帧率
let lastTime = performance.now();
let frames = 0;
function checkFPS() {
frames++;
const now = performance.now();
if (now - lastTime >= 1000) {
console.log('FPS:', frames);
frames = 0;
lastTime = now;
}
requestAnimationFrame(checkFPS);
}
Q5: 强制同步布局是什么?如何避免?
答案:
强制同步布局(Forced Synchronous Layout)是在 JavaScript 中交替读写样式导致的性能问题。
// ❌ 强制同步布局
elements.forEach(el => {
const width = el.offsetWidth; // 读取 → 强制布局
el.style.width = width + 10; // 写入 → 标记需要重排
});
// ✅ 读写分离
const widths = elements.map(el => el.offsetWidth); // 批量读
elements.forEach((el, i) => {
el.style.width = widths[i] + 10; // 批量写
});
// ✅ 使用 requestAnimationFrame 分离
requestAnimationFrame(() => {
// 批量写入,下一帧统一处理
elements.forEach(el => {
el.style.width = '100px';
});
});