性能剖析与渲染预算
问题:一次 18ms 的回调能证明页面慢吗
不能。设备、开发模式、后台任务、热身和输入规模都会改变测量。<Profiler> 给出某次 React commit 的渲染成本,不包含网络等待,也不等于用户输入到下一帧的完整延迟。预算必须先固定场景,再收集多次样本。
完整浏览器实验
将下面保存为 src/App.jsx,在已有 Vite React 19 项目运行。例子故意做 CPU 工作,只用于制造可见差异。
import { Profiler, useMemo, useState } from 'react'
const samples = []
function recordCommit(id, phase, actualDuration, baseDuration, startTime, commitTime) {
const sample = { id, phase, actualDuration, baseDuration, startTime, commitTime }
samples.push(sample)
if (samples.length > 100) samples.shift()
console.table(samples.slice(-8))
}
function SlowList({ count, filter }) {
const rows = useMemo(() => {
const next = []
for (let index = 0; index < count; index += 1) {
let score = 0
for (let spin = 0; spin < 2_000; spin += 1) score += Math.sqrt(index + spin)
const label = `条目 ${index} / ${Math.round(score)}`
if (label.includes(filter)) next.push(<li key={index}>{label}</li>)
}
return next
}, [count, filter])
return <ul>{rows}</ul>
}
export default function App() {
const [count, setCount] = useState(300)
const [filter, setFilter] = useState('')
const [clicks, setClicks] = useState(0)
return (
<main>
<h1>渲染测量台</h1>
<button onClick={() => setClicks(value => value + 1)}>无关更新 {clicks}</button>
<button onClick={() => { samples.length = 0 }}>清空采样</button>
<label>条目数
<input
type="range"
min="100"
max="1200"
step="100"
value={count}
onChange={event => setCount(Number(event.target.value))}
/>
</label>
<label>筛选
<input value={filter} onChange={event => setFilter(event.target.value)} />
</label>
<Profiler id="results" onRender={recordCommit}>
<SlowList count={count} filter={filter} />
</Profiler>
</main>
)
}打开控制台,先点“清空采样”,固定条目数,按同样节奏输入 2、清空,重复五轮。再改变条目数重做。不要同时改输入规模和代码,否则无法判断原因。只保留最近 100 条记录,避免测量器本身无限积累数据。这个小实验不启用 React Compiler,以便手动记忆化的对照条件明确。
一次只改变一个条件
先不改变筛选条件,连续点“无关更新”。App 的点击数改变,SlowList 仍会被调用,但 count 和 filter 未变,useMemo 复用先前生成的行元素,昂贵循环不用重算。观察这些更新的 actualDuration,再与改变筛选条件的记录比较;后者依赖已变,需要重新计算。
第二组实验把 const rows = useMemo(() => { ... }, [count, filter]) 暂时改成 const rows = (() => { ... })(),其他部分不动。重复相同的无关更新。此时循环每次执行,才形成“依赖相同,是否复用计算”的有效对照。对照结束恢复 useMemo。
第三组只把 SlowList 用 memo 包装,保持 props 不变,再测无关更新。这次连子组件的函数调用也有机会跳过;但输入新筛选词时,filter 变化,依然要进入子组件。复用计算、跳过组件、降低更新优先级解决的是不同问题,不能用一个总体耗时猜测三者是否都生效。
如果没有明显差别,先检查测试规模和采样条件,不为证明优化“有效”而挑最快的一次。这个程序的瓶颈还包括创建与更新许多 DOM 节点;缓存只覆盖其中一部分。
读懂一次 commit
phase区分首次mount、普通update和嵌套更新。actualDuration是这次提交中 Profiler 子树实际花在渲染上的时间。baseDuration估算该子树不利用记忆化时的最近完整成本;actualDuration明显低于它,才说明跳过工作可能有效。- 同一 commit 内的 Profiler 共享
commitTime,可以把多个子树的样本归组。
useMemo 只在依赖不变时复用计算。本例每次改变 filter 都必须重算,所以给 SlowList 再包 memo 并不会消除这项工作。真正的优化可能是改变产品交互(延迟非紧急筛选)、减少行数(虚拟化)或把检索移到索引/服务端,而不是无限添加 memo。
不能把 commitTime - startTime 当作 actualDuration 的替代。前者是两个时间点的墙钟间隔,期间可能发生等待或其他工作;后者关注 React 在本子树中实际记录的渲染工作。baseDuration 也不是每轮重新执行一遍“未优化版本”得出的实测数,而是用组件最近的渲染成本估算。要证明优化前后差异,仍需上面的真实对照。
从样本到预算
先定义场景:“300 条数据、目标笔记本、连续输入 5 次、热身 1 轮后记录 5 轮”。再分别记录中位数和较慢样本,而不是把一次最快值当结论。16ms 可以作为一帧的粗略提醒,但不是通用通过线:浏览器还要执行事件、布局、绘制和其他脚本,不会把整帧都留给 React。
开发模式含额外检查,且 Profiler 自身有开销。React 的普通生产构建默认禁用程序化 profiling;若要采集生产等价数据,需要显式使用 profiling 构建,并评估采样成本。日常定位也可使用 React Developer Tools 的 Profiler。
React Compiler 改变哪一部分
React Compiler 是构建时的自动记忆化工具,安装 React 运行时并不等于已启用它。第三章学过的引用关系仍然有用:它解释什么结果可以在依赖相同时复用,而编译器负责为可分析的组件和 Hook 自动安排这种复用。
例如父组件只改变主题时,列表数据没有变化,编译器可能复用列表计算和 JSX;用户真正修改筛选条件时,计算仍要执行。它不会把耗时算法自动变成 Worker,也不会替你解决网络缓存失效。违反纯渲染规则、在原对象上修改数据的写法,应先修正行为,不能期待编译器补救。
已有项目接入时,先保存本节的行为与性能基线,再按工具链启用编译,并比较同一组交互。不要一次删除全项目的手动记忆化后再猜差异来源。编译器安装与构建配置属于工具链分支;本节的测量实验在未启用它时即可完成。
边界
- Profiler 不能测网络 p95、服务端响应、布局偏移或图片解码;使用对应平台指标。
- 时间断言在共享 CI 上容易波动。CI 更适合测试纯聚合逻辑、渲染次数上限或特定回归场景,把真实时间趋势留给受控基准与线上指标。
- 优化前先保存行为测试。更快地渲染错误结果不是成功。
练习:汇总 commit 样本
实现 summarize(samples):拒绝空数组或含负 actualDuration 的输入;返回 count、按从小到大排序后的中位数 median、向上取整位置的 p95,以及 maxBaseDuration。不得修改输入数组。用确定数据断言奇数、偶数、p95、非法值和输入顺序未变。
下一篇
发布、监控与升级会把测试和性能证据接到发布身份与回滚协议。