Skip to content

性能优化-渲染优化 ​

渲染优化的本质是减少主线程的工作:能不重排就不重排,能交给合成器就不交给主线程,能少渲染一帧就少渲染一帧。

一、浏览器渲染流水线 ​

JS(可能改样式)
  → Style 计算样式
  → Layout 布局(算位置尺寸)
  → Paint 绘制(填像素)
  → Composite 合成(图层拼成最终画面)
JS(可能改样式)
  → Style 计算样式
  → Layout 布局(算位置尺寸)
  → Paint 绘制(填像素)
  → Composite 合成(图层拼成最终画面)
改动的东西从哪一步开始重做代价
宽高、位置、字体、边框Layout(重排)最贵
颜色、背景、阴影、可见性Paint(重绘)中
transform、opacity、filterComposite(合成)最便宜(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 实现「改变布局位置」的动画:

  1. First:记录元素初始位置;
  2. Last:改 DOM(让它到最终位置);
  3. Invert:用 transform 把它「视觉上」移回初始位置;
  4. 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 过大