渲染与运行时性能优化
理解 DOM/CSSOM、布局绘制合成、主线程任务、Observer、长列表、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/合成线程上重新组合,通常比重新布局和绘制便宜。
具体触发取决于浏览器、属性和上下文,不能把属性表当绝对规律。动画通常优先使用 transform 和 opacity,并在 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()
}
拆分任务会增加调度次数和中间状态管理,不适合必须原子完成的操作。用户输入优先级高于非关键排序、统计和预取;可用 setTimeout、requestIdleCallback 或框架调度器让出主线程,但要测量实际 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 数量,还包括数据计算、布局、事件监听和可访问性。虚拟列表只渲染视口附近的窗口:
- 根据滚动位置计算起止索引;
- 用固定或测量高度计算占位容器总高度;
- 只挂载窗口内节点,并在滚动时复用节点;
- 处理键盘焦点、屏幕阅读器、动态高度和滚动跳转。
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 验证内存问题
- 固定操作路径,重复进入/离开页面 5-10 次;
- 在 Memory 面板做 Heap Snapshot 或 Allocation instrumentation;
- 比较快照中的 detached DOM、监听器、闭包和缓存引用;
- 确认 GC 后基线是否持续上升,而不是只看短暂峰值;
- 修复后重新执行同一路径,并记录快照差异。
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 性能优化。