跳到正文
前端知识库
性能

渲染与运行时性能优化

理解 DOM/CSSOM、布局绘制合成、主线程任务、Observer、长列表、Web Worker 和内存治理。

7 分钟渲染 · 回流 · 重绘 · 合成 · 长任务 · IntersectionObserver · Web Worker · 内存

渲染与运行时性能优化

1. 浏览器渲染主线

HTML -> DOM
CSS -> CSSOM
DOM + CSSOM -> Render Tree
  -> Style/Recalculate -> Layout -> Paint -> Raster -> Composite

HTML 解析、脚本执行、样式计算和布局通常竞争主线程。性能问题应先确定卡在下载、脚本(Scripting)、样式/布局(Rendering)、绘制(Painting)还是合成,而不是笼统地说“DOM 操作慢”。

2. 回流、重绘与合成

2.1 触发范围

  • 回流/布局(reflow/layout):几何尺寸或位置变化,可能使后代和兄弟重新计算。
  • 重绘(paint):颜色、阴影等视觉变化,不一定重新计算几何。
  • 合成(composite):已有图层在 GPU/合成线程上重新组合,通常比重新布局和绘制便宜。

具体触发取决于浏览器、属性和上下文,不能把属性表当绝对规律。动画通常优先使用 transformopacity,并在 Performance 面板确认是否真的只发生合成。

/* 适合多数位移动画,但仍要检查图层和内存成本 */
.drawer {
  transform: translateX(0);
  opacity: 1;
  transition: transform 180ms ease, opacity 180ms ease;
}

.drawer[data-open='false'] {
  transform: translateX(100%);
  opacity: 0;
}

2.2 批量更新和读写分离

交替读写布局信息会触发强制同步布局:

// 容易造成 layout thrashing:每次写入后马上读取布局
for (const item of items) {
  item.style.width = `${nextWidth}px`
  console.log(item.offsetWidth)
}

// 先批量读取,再批量写入
const widths = items.map((item) => item.getBoundingClientRect().width)
items.forEach((item, index) => {
  item.style.width = `${widths[index] + 8}px`
})

对大量节点可先脱离文档或使用 DocumentFragment,集中修改 class/style,再一次插入;更优先的方式是减少节点数量、缩小更新范围或让框架批量提交。contain: layout paint 可在合适的组件边界隔离影响,但会改变布局/绘制语义,应逐组件验证。

2.3 CSS 选择器和 DOM 规模

选择器匹配通常从右向左进行;深层后代、通配符和大量复杂属性选择器会增加匹配成本。实际收益取决于样式表和 DOM 规模,优先:

  • 删除无用 CSS 和重复规则;
  • 使用稳定的组件类名,减少无意义嵌套;
  • 控制首屏 DOM,非可见内容延迟渲染;
  • 避免用 !important 堆叠优先级,保持样式可维护。

已有 CSS 细节见 CSS 性能优化

3. 长任务与主线程调度

浏览器通常把连续执行超过 50ms 的 JavaScript 视为长任务。长任务会延迟输入处理和绘制,是 INP 变差的常见原因。

3.1 拆分计算

function processInChunks(list, handle, chunkSize = 100) {
  let index = 0

  function runChunk(deadline) {
    const end = Math.min(index + chunkSize, list.length)
    while (index < end && (deadline?.timeRemaining?.() ?? 1) > 0) {
      handle(list[index++])
    }

    if (index < list.length) {
      // 有空闲时间时继续;不支持 requestIdleCallback 时降级到宏任务。
      if ('requestIdleCallback' in window) {
        requestIdleCallback(runChunk)
      } else {
        setTimeout(() => runChunk(), 0)
      }
    }
  }

  runChunk()
}

拆分任务会增加调度次数和中间状态管理,不适合必须原子完成的操作。用户输入优先级高于非关键排序、统计和预取;可用 setTimeoutrequestIdleCallback 或框架调度器让出主线程,但要测量实际 INP。

3.2 requestAnimationFrame

动画更新应对齐浏览器绘制节奏,并在不可见时停止:

let frameId = 0
let running = false
let position = 0

function tick(now) {
  if (!running) return
  position += Math.min(now - (tick.last ?? now), 32) * 0.1
  tick.last = now
  element.style.transform = `translateX(${position}px)`
  frameId = requestAnimationFrame(tick)
}

function start() {
  if (running || document.hidden) return
  running = true
  tick.last = undefined
  frameId = requestAnimationFrame(tick)
}

function stop() {
  running = false
  cancelAnimationFrame(frameId)
}

function onVisibilityChange() {
  if (document.hidden) stop()
  else start()
}

document.addEventListener('visibilitychange', onVisibilityChange)
start()

// 组件卸载时执行:
// document.removeEventListener('visibilitychange', onVisibilityChange)
// stop()

不要在 scroll/resize 回调中无限执行布局和绘制;用 rAF 合帧,并在组件卸载时取消帧。

3.3 防抖与节流

  • 防抖:连续输入停止后执行,适合搜索请求和窗口调整后的重算;
  • 节流:固定时间窗口最多执行一次,适合滚动曝光或拖拽反馈;
  • 请求类操作还需取消旧请求,防止过期响应覆盖新结果。
function debounce(fn, wait) {
  let timer
  return (...args) => {
    clearTimeout(timer)
    timer = setTimeout(() => fn(...args), wait)
  }
}

const search = debounce((keyword) => fetchResults(keyword), 200)

4. IntersectionObserver 设计

IntersectionObserver 在浏览器调度阶段异步报告目标与根的交叉状态,适合懒加载、曝光和无限滚动,避免每次滚动都手动读取 getBoundingClientRect()

const observer = new IntersectionObserver((entries, current) => {
  for (const entry of entries) {
    if (!entry.isIntersecting) continue

    if (!(entry.target instanceof HTMLImageElement)) continue
    const image = entry.target
    const source = image.dataset.src
    if (!source) {
      current.unobserve(image)
      continue
    }
    image.src = source
    image.removeAttribute('data-src')
    current.unobserve(image)
  }
}, {
  root: null,
  rootMargin: '200px 0px',
  threshold: 0.01,
})

document.querySelectorAll('img[data-src]').forEach((image) => observer.observe(image))

4.1 关键参数

  • root:祖先滚动容器,默认视口;
  • rootMargin:扩展/收缩根区域,懒加载可提前触发;
  • threshold:交叉比例达到门槛时回调,可传数组做曝光分段;
  • unobserve/disconnect:一次性任务触发后立即解除,组件卸载时断开。

一个观察器可观察多个目标,通常比每个元素创建观察器更省内存。数千节点仍需分批挂载、虚拟化或分页;Observer 只减少检测成本,不会替你解决一次性渲染过多节点的问题。

4.2 React Hook 的稳定性

import { useEffect, useRef } from 'react'

export function useInView(
  onEnter: (entry: IntersectionObserverEntry) => void,
  options?: IntersectionObserverInit,
) {
  const ref = useRef<HTMLElement | null>(null)
  const callbackRef = useRef(onEnter)
  callbackRef.current = onEnter

  useEffect(() => {
    const node = ref.current
    if (!node || !('IntersectionObserver' in window)) return

    const observer = new IntersectionObserver(([entry]) => {
      if (entry?.isIntersecting) callbackRef.current(entry)
    }, options)
    observer.observe(node)
    return () => observer.disconnect()
  }, [options?.root, options?.rootMargin, JSON.stringify(options?.threshold)])

  return ref
}

实际项目应让 options 通过 useMemo 保持稳定,或显式拆出依赖;不要因为回调函数每次 render 都变而反复创建观察器。

5. 长列表与虚拟化

长列表的瓶颈不仅是 DOM 数量,还包括数据计算、布局、事件监听和可访问性。虚拟列表只渲染视口附近的窗口:

  1. 根据滚动位置计算起止索引;
  2. 用固定或测量高度计算占位容器总高度;
  3. 只挂载窗口内节点,并在滚动时复用节点;
  4. 处理键盘焦点、屏幕阅读器、动态高度和滚动跳转。

React 可使用 react-window 等成熟库,Vue 可使用对应虚拟滚动组件。不要在列表中使用数组索引作为 key(会破坏节点复用和状态对应);使用稳定业务 ID。若列表只有几百项,先测量再决定是否引入虚拟化,避免增加复杂度。

6. Web Worker 与数据复制

Worker 将 CPU 密集计算移出主线程,但通信和结构化克隆也有成本:

// main.js
const worker = new Worker(new URL('./sum.worker.js', import.meta.url), { type: 'module' })
worker.onmessage = ({ data }) => renderResult(data)
worker.postMessage({ numbers: largeArray })

// sum.worker.js
self.onmessage = ({ data }) => {
  const result = data.numbers.reduce((sum, value) => sum + value, 0)
  self.postMessage(result)
}

大块二进制数据可使用 Transferable(例如 ArrayBuffer)转移所有权,避免复制;转移后主线程不能继续使用原 buffer。Worker 不能直接访问 DOM,必须设计可取消、超时和错误处理;短小计算放进 Worker 反而可能因为通信开销变慢。

7. 内存与生命周期

7.1 常见泄漏来源

  • 全局数组、缓存或闭包长期持有页面对象;
  • 事件监听器、定时器、Observer、Worker 在卸载时未清理;
  • detached DOM 节点仍被 JS 引用;
  • 无限增长的日志、埋点队列和图片 blob;
  • SPA 路由切换后旧组件状态仍由订阅者持有。
const controller = new AbortController()
window.addEventListener('resize', handleResize, { signal: controller.signal })
const timer = setInterval(refresh, 10_000)
// 如果组件还创建了 Worker/Observer,把它们的引用也纳入同一生命周期。
let worker = null
let observer = null

function dispose() {
  controller.abort()
  clearInterval(timer)
  worker?.terminate()
  observer?.disconnect()
}

7.2 验证内存问题

  1. 固定操作路径,重复进入/离开页面 5-10 次;
  2. 在 Memory 面板做 Heap Snapshot 或 Allocation instrumentation;
  3. 比较快照中的 detached DOM、监听器、闭包和缓存引用;
  4. 确认 GC 后基线是否持续上升,而不是只看短暂峰值;
  5. 修复后重新执行同一路径,并记录快照差异。

8. 感知优化与真实性能

骨架屏、占位图、进度条可以让用户尽早获得反馈,但不能掩盖请求失败、内容不可用或布局跳动:

  • 为图片/视频/广告预留尺寸,降低 CLS;
  • 骨架结构尽量接近真实内容,避免替换时大幅重排;
  • 先呈现可交互的核心控件,再延迟非关键逻辑;
  • 进度条必须基于真实阶段或明确为不确定进度,不要伪造精确百分比;
  • 用现场 LCP/INP/CLS 和业务完成率验证感知优化,而不是只看主观截图。

9. 面试追问速答

Q: 为什么 transform 通常比 top/left 更适合动画?

A: top/left 往往会改变布局并触发后续计算,transform 在多数场景可交给合成阶段处理;但是否创建图层、是否触发重绘取决于具体样式和浏览器,必须用 Performance/Rendering 工具确认,不能机械套属性表。

Q: IntersectionObserver 一定比滚动监听快吗?

A: 它把交叉检测交给浏览器调度,避免应用在每次滚动中同步读取布局,通常更适合懒加载和曝光;但观察数千节点、回调中执行重任务仍会造成开销,需要解除观察和虚拟化。

Q: Worker 能解决所有卡顿吗?

A: 只适合可移出 DOM 的 CPU 密集计算。数据序列化、复制、Worker 启动和结果合并都有成本;网络、布局、绘制或过多 React 更新仍需在对应层面处理。

10. 资料来源与现有专题

本篇综合性能 PDF 关于 DOM/CSS/渲染、IntersectionObserver、懒加载、长任务、Web Worker、requestAnimationFrame、防抖节流和虚拟列表的内容,并补充生命周期清理与现代交互指标。相关专题:指标采集与问题诊断React 与 Vue 运行时优化CSS 性能优化