跳到主要内容

渲染优化

问题

浏览器是如何渲染页面的?什么是重排和重绘?如何减少渲染性能问题?

面试速答版

浏览器是如何渲染页面的? 关键路径就一句话:Parse → Style → Layout → Paint → Composite

  • HTML 解析成 DOM,CSS 解析成 CSSOM,两者合成 Render Tree。
  • Layout(布局)计算节点的位置和尺寸;Paint 生成绘制记录并栅格化为像素;Composite 把图层按顺序组合成最终帧。
  • 通常越晚进入流水线,影响范围越小,但 Composite 也可能受图层数量、纹理面积、栅格化和 GPU/显存限制影响,不能简单理解为“免费”。

什么是重排和重绘? 两者都是渲染流水线被「打回去重做」:

  • 重排(Reflow/Layout):几何属性(widthtopmargindisplay 等)变化后需要重新计算布局,影响范围取决于 DOM、布局模式和 contain 边界,并不一定波及整个页面。
  • 重绘(Repaint):只是外观变(colorbackgroundborder-colorvisibility),跳过 Layout 直接重画,比重排便宜。
  • 强制同步布局(Layout Thrashing) 是大坑:在循环里读 offsetWidth 又写 style.width,浏览器被迫每次都同步算 Layout,性能直接崩。

如何减少渲染性能问题? 核心是「减少无效工作」+「缩小影响范围」+「批量读写」:

  • 动画优先 transformopacity——它们通常能避开 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)

transformopacity 在元素已经完成绘制并满足合成条件时,通常可以只更新合成阶段;这仍是浏览器实现决定的优化结果,不是 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.99filter: 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';
});
});

相关链接