性能优化-渲染优化
渲染优化的本质是减少主线程的工作:能不重排就不重排,能交给合成器就不交给主线程,能少渲染一帧就少渲染一帧。
一、浏览器渲染流水线
JS(可能改样式)
→ Style 计算样式
→ Layout 布局(算位置尺寸)
→ Paint 绘制(填像素)
→ Composite 合成(图层拼成最终画面)JS(可能改样式)
→ Style 计算样式
→ Layout 布局(算位置尺寸)
→ Paint 绘制(填像素)
→ Composite 合成(图层拼成最终画面)| 改动的东西 | 从哪一步开始重做 | 代价 |
|---|---|---|
| 宽高、位置、字体、边框 | Layout(重排) | 最贵 |
| 颜色、背景、阴影、可见性 | Paint(重绘) | 中 |
transform、opacity、filter | Composite(合成) | 最便宜(GPU) |
关键:只有 transform / opacity 这类能交给合成器线程的属性,才不会重新走 Layout + Paint。
二、重排(Reflow)vs 重绘(Repaint)
| 操作 | 触发条件 | 性能影响 |
|---|---|---|
| 重排 | 改变元素几何属性(宽高、位置、字体) | ❌ 高 |
| 重绘 | 改变外观属性(颜色、背景、阴影) | ⚠️ 中 |
| 合成 | transform、opacity | ✅ 低(GPU 加速) |
会强制同步重排的读取(Force Reflow)
js
el.offsetHeight / offsetWidth
el.clientHeight / clientWidth
el.scrollTop / scrollLeft
el.getBoundingClientRect()
window.getComputedStyle(el)
el.focus() // 某些情况下el.offsetHeight / offsetWidth
el.clientHeight / clientWidth
el.scrollTop / scrollLeft
el.getBoundingClientRect()
window.getComputedStyle(el)
el.focus() // 某些情况下注意:这些属性本身不贵,贵的是在「刚写完样式」之后立刻读 —— 浏览器必须立刻算一次布局来回答你。
合并写操作
js
// ❌ 每次都触发一次样式计算 + 布局
el.style.width = '100px'
el.style.height = '100px'
el.style.margin = '10px'
// ✅ 一次搞定
el.style.cssText = 'width:100px;height:100px;margin:10px'
el.classList.add('box') // 更好:交给 class
el.style.setProperty('--size', '100px')// ❌ 每次都触发一次样式计算 + 布局
el.style.width = '100px'
el.style.height = '100px'
el.style.margin = '10px'
// ✅ 一次搞定
el.style.cssText = 'width:100px;height:100px;margin:10px'
el.classList.add('box') // 更好:交给 class
el.style.setProperty('--size', '100px')三、布局抖动(Layout Thrashing)
读写交替是最常见的性能杀手:
js
// ❌ 读一次写一次,每次「读」都强制重排
for (let i = 0; i < items.length; i++) {
const width = items[i].offsetWidth // 读 → 强制 Layout
items[i].style.width = width * 2 + 'px' // 写 → 让 Layout 失效
}
// ✅ 批量读,再批量写
const widths = items.map(el => el.offsetWidth)
items.forEach((el, i) => { el.style.width = widths[i] * 2 + 'px' })// ❌ 读一次写一次,每次「读」都强制重排
for (let i = 0; i < items.length; i++) {
const width = items[i].offsetWidth // 读 → 强制 Layout
items[i].style.width = width * 2 + 'px' // 写 → 让 Layout 失效
}
// ✅ 批量读,再批量写
const widths = items.map(el => el.offsetWidth)
items.forEach((el, i) => { el.style.width = widths[i] * 2 + 'px' })规律:一帧内,读和写要分开。读之前把所有写做完,写之前把所有读做完。
四、长任务拆分
主线程一个任务超过 50ms 就算「长任务」,期间点击、滚动都会排队 —— 这是 INP 变差的主因。
js
// ❌ 一个任务处理一万条,输入直接卡住
function processAll(items) {
items.forEach(item => heavyWork(item))
}
// ✅ 时间分片:每帧处理一小批
function processChunked(items, chunk = 50) {
let i = 0
function step() {
const end = Math.min(i + chunk, items.length)
for (; i < end; i++) heavyWork(items[i])
if (i < items.length) setTimeout(step, 0) // 或 requestAnimationFrame
}
step()
}
// ✅ Chrome 95+:scheduler.yield() 主动让出主线程
async function processWithYield(items) {
for (const item of items) {
heavyWork(item)
await scheduler.yield()
}
}
// ✅ 纯计算型:扔给 Web Worker(主线程完全不参与)
const worker = new Worker('worker.js')
worker.postMessage(largeData)
worker.onmessage = e => render(e.data)// ❌ 一个任务处理一万条,输入直接卡住
function processAll(items) {
items.forEach(item => heavyWork(item))
}
// ✅ 时间分片:每帧处理一小批
function processChunked(items, chunk = 50) {
let i = 0
function step() {
const end = Math.min(i + chunk, items.length)
for (; i < end; i++) heavyWork(items[i])
if (i < items.length) setTimeout(step, 0) // 或 requestAnimationFrame
}
step()
}
// ✅ Chrome 95+:scheduler.yield() 主动让出主线程
async function processWithYield(items) {
for (const item of items) {
heavyWork(item)
await scheduler.yield()
}
}
// ✅ 纯计算型:扔给 Web Worker(主线程完全不参与)
const worker = new Worker('worker.js')
worker.postMessage(largeData)
worker.onmessage = e => render(e.data)| 手段 | 适用 |
|---|---|
| 时间分片(setTimeout/rAF) | 需要渐进渲染的列表 |
scheduler.yield() | 支持新 API 的浏览器,让出更及时 |
| Web Worker | 纯计算(不涉及 DOM) |
React useTransition / Vue 的调度 | 框架内的低优先级更新 |
五、CSS 层的渲染优化
| 手段 | 作用 | 注意 |
|---|---|---|
contain: layout paint | 告诉浏览器「这块内部变化不影响外部」 | 隔离布局计算,效果明显 |
content-visibility: auto | 屏幕外的内容跳过渲染 | 超长页面利器,需配 contain-intrinsic-size 防止滚动条跳动 |
will-change: transform | 提前提升为合成层 | 不要滥用:每层都占显存,滥用会「层爆炸」 |
| 避免深层选择器 | 减少样式匹配成本 | 现代浏览器已优化很多,不是重点 |
css
/* 长列表/长文档的性能利器 */
.section {
content-visibility: auto;
contain-intrinsic-size: 500px; /* 占位高度,避免滚动条抖动 */
}/* 长列表/长文档的性能利器 */
.section {
content-visibility: auto;
contain-intrinsic-size: 500px; /* 占位高度,避免滚动条抖动 */
}六、动画优化
css
/* ❌ 改 top 触发重排,每帧都算布局 */
@keyframes move {
from { top: 0 }
to { top: 100px }
}
/* ✅ 只用 transform,交给合成器 */
@keyframes move {
from { transform: translateY(0) }
to { transform: translateY(100px) }
}/* ❌ 改 top 触发重排,每帧都算布局 */
@keyframes move {
from { top: 0 }
to { top: 100px }
}
/* ✅ 只用 transform,交给合成器 */
@keyframes move {
from { transform: translateY(0) }
to { transform: translateY(100px) }
}FLIP 技巧(First, Last, Invert, Play):想用便宜的 transform 实现「改变布局位置」的动画:
- First:记录元素初始位置;
- Last:改 DOM(让它到最终位置);
- Invert:用
transform把它「视觉上」移回初始位置; - Play:移除 transform,加过渡 —— 浏览器只做合成,不重排。
七、框架层的渲染优化
React
| 手段 | 作用 | 坑 |
|---|---|---|
React.memo | 浅比较 props,跳过重渲染 | props 里有每次新建的对象/函数就失效 |
useMemo / useCallback | 稳定引用 | 滥用反而更慢(比较也有成本) |
key 用稳定 id | 复用 DOM,减少重建 | 别用 index 做 key(插入/删除时全错位) |
| 拆 Context | 避免无关组件跟着重渲染 | 一个 Context 一变,所有消费者都重渲染 |
| 虚拟列表 | 只渲染可见区域 | react-window / react-virtuoso |
jsx
// ❌ index 作为 key:在头部插入时,所有项都「变了一个位置」
{list.map((item, i) => <Row key={i} {...item} />)}
// ✅ 用业务 id
{list.map(item => <Row key={item.id} {...item} />)}// ❌ index 作为 key:在头部插入时,所有项都「变了一个位置」
{list.map((item, i) => <Row key={i} {...item} />)}
// ✅ 用业务 id
{list.map(item => <Row key={item.id} {...item} />)}Vue
| 手段 | 作用 |
|---|---|
v-memo="[dep]" | 依赖不变就跳过这块的更新 |
v-once | 只渲染一次(静态内容) |
shallowRef / shallowReactive | 只代理第一层,适合大对象整体替换 |
computed 缓存 | 依赖不变不重算 |
八、怎么测(别靠感觉)
| 工具 | 看什么 |
|---|---|
| Performance 面板 | 长任务(红色)、Layout/Paint 耗时、FPS |
| Rendering 面板 | 勾选 Paint flashing(看哪些区域在重绘)、Layer borders(看合成层) |
| Performance Monitor | 实时看 Layouts/sec、JS heap、CPU |
| React DevTools Profiler | 哪个组件重渲染了、为什么(props 变了 / 父组件重渲染) |
判断标准:Layouts/sec 应该很低(正常情况下每秒个位数),如果滚动时飙到几十上百,就是布局抖动。
九、速查
| 问题 | 答案 |
|---|---|
| 重排重绘合成的代价 | 重排 > 重绘 > 合成 |
| 哪些属性只触发合成 | transform、opacity、filter |
| 什么是强制重排 | 写完样式立刻读 offsetHeight 之类的属性 |
| 布局抖动怎么解 | 一帧内批量读、批量写,别交替 |
| 长任务的标准 | > 50ms |
| 长任务怎么拆 | 时间分片 / scheduler.yield / Web Worker |
| 屏幕外内容怎么省 | content-visibility: auto |
will-change 能滥用吗 | 不能,会层爆炸、占显存 |
| React 里最容易被忽略的坑 | 用 index 做 key、Context 过大 |
Cao Note