React 基础体系 · 第 54/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。
React DevTools 与 Profiler:提交、更新来源、火焰图和诊断
React DevTools 负责观察 React 运行时中的组件树、Props、State、Context 和 Hooks;Profiler 则负责记录一段交互期间 React 的渲染工作,并按“提交”(commit)展示这些工作花费了多少时间、哪些组件参与了更新以及可能的更新来源。
两者解决的问题不同:
- Components 面板回答“现在组件树是什么状态”。
- Profiler 面板回答“刚才发生了哪些提交,以及这些提交为什么耗时”。
- 浏览器 Performance 面板回答“从浏览器和 JavaScript 全局看,主线程到底被什么占用了”。
Profiler 不等同于完整的浏览器性能分析器。它主要描述 React 的 render 工作;网络请求、JSON 解析、第三方脚本、布局计算、绘制、长任务和事件处理器本身,需要结合浏览器工具继续分析。
一、先建立模型:React 更新不是一次不可分割的函数调用
React 更新可以抽象为下面的过程:
用户操作、网络结果、定时器或外部事件
│
▼
调度一次 React 更新
│
▼
render phase:计算新的组件树
│
┌────────┴────────┐
│ │
被中断/丢弃 完成新的树
│
▼
commit phase:提交需要应用的变更
│
▼
DOM / Native UI / layout effects
│
▼
浏览器后续绘制
这里有三个容易混淆的概念:
- 组件函数执行:React 调用函数组件,计算 JSX。
- render phase:React 比较新旧 React 元素树,决定哪些组件和节点需要继续处理。
- commit phase:React 把最终结果应用到宿主环境,例如更新 DOM,并运行相应的提交阶段逻辑。
组件函数执行过,不代表 DOM 一定发生了变化。React 可能重新调用组件以计算结果,随后发现结果在宿主环境上没有需要应用的变化。
同样,render 也不保证一定会 commit。在并发渲染中,某次 render 可能被更高优先级更新打断,或者因为新更新到来而被丢弃。Profiler 主要记录已经完成的提交,而不是所有曾经开始过的计算。
因此,下面这个近似关系成立:
但它不是严格的“一次 setState 对应一次提交”:
- 多个更新可能被批处理到同一次提交中;
- 一个事件处理器中的多个状态更新,通常可能合并;
startTransition产生的工作可能延后;- 不同根节点的提交边界不应简单理解成整个页面唯一的全局提交。
二、什么是“提交”:Profiler 记录的基本单位
Profiler 中的 commit 是某个 React 根节点完成一次更新并把结果提交到宿主环境后的记录单位。
一次提交通常包含:
- 本次更新所属的 React 根;
- 更新前后的组件树差异;
- 参与 render 的组件;
- 每个组件在本次提交中的渲染耗时;
- React 完成这次 render 的时间;
- 某些组件为什么重新渲染的提示。
提交不等于浏览器的一帧,也不等于一次 DOM 修改:
- 一次 commit 可以修改很多 DOM 节点;
- 一次 commit 也可能没有明显的 DOM 变化;
- 一次浏览器帧可能包含多个 JavaScript 任务;
- React commit 之后,浏览器还要进行样式计算、布局、绘制和合成。
Profiler 中常见的时间字段可以这样理解:
- actual duration:本次提交中,当前 Profiler 子树实际完成 render 工作的耗时。
- base duration:在不发生有效记忆化跳过的情况下,React 估算该子树重新渲染的成本。
- commit time:这次提交对应的时间点。它用于把同一提交中的多个 Profiler 记录关联起来。
- phase:通常是
mount或update;某些嵌套更新场景还可能出现nested-update。
这些数字不是用户感知延迟的完整等式:
更接近的分解是:
Profiler 重点覆盖中间的 React 部分,不能单独证明浏览器绘制、网络或服务端处理没有问题。
三、从更新到提交:一个完整的状态变化示例
考虑下面的组件:
import { useState } from "react";
export function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount((value) => value + 1)}>
{count}
</button>
);
}
点击按钮后,状态流转可以分成几步:
1. 事件处理器调用状态更新函数
setCount((value) => value + 1);
此时并不是立即把 count 变量改成新值。当前这次函数调用仍然使用当前 render 的闭包值。状态更新被放入 React 的更新队列。
2. React 调度更新
React 根据更新的优先级和当前运行环境安排工作。离散输入通常需要较快响应;过渡更新可能允许延后。
3. React 重新执行相关组件
React 重新执行 Counter,得到:
<button>1</button>
这属于 render phase。
4. React 比较新旧结果
旧结果是:
<button>0</button>
React 发现文本节点从 "0" 变成 "1"。
5. React 提交结果
React 在 commit phase 修改对应 DOM 文本,完成一次提交。Profiler 会把这次更新显示为一次 update 提交。
如果执行的是:
setCount((value) => value);
React 通常可以根据新旧状态相同而跳过后续工作。但“通常”不应被理解为组件函数在所有情况下都绝对不会再次被调用:开发模式、并发调度、更新传播和具体实现可能使某些计算仍然发生。工程代码不应依赖“相同状态绝对不执行函数组件”这一未承诺的行为。
四、安装和使用 React DevTools
React DevTools 通常以浏览器扩展形式使用,也有独立版本可连接桌面或移动调试环境。使用浏览器扩展时,前置条件是:
- 页面运行的是 React 应用;
- 应用没有被完全隔离在 DevTools 无法访问的环境中;
- 开发构建保留了可调试信息;
- 浏览器扩展能够连接到页面中的 React 运行时。
打开浏览器开发者工具后,通常可以看到:
- Components:查看当前组件树、Props、State、Context 和 Hooks;
- Profiler:录制并分析 React 提交;
- 某些版本还会提供与性能、组件高亮或 React 服务器组件相关的附加能力。
DevTools 的具体按钮名称和布局会随扩展版本变化,因此不应把某个版本的面板位置当作 React API。核心流程基本一致:
- 打开 Profiler;
- 开始录制;
- 执行一次可重复的交互,例如输入、切换标签页或翻页;
- 停止录制;
- 选择一个提交;
- 在 Flamegraph 或 Ranked 视图中定位耗时组件;
- 回到 Components 面板检查对应组件的 Props、State、Context 和 Hooks;
- 修复后用相同操作重新录制,比较提交数量、耗时和组件参与范围。
录制时应尽量只包含目标操作。把页面加载、登录、动画、轮询和用户交互全部混在一段记录中,会让提交之间的因果关系变得不清楚。
五、Profiler 的两个核心视图:Flamegraph 和 Ranked
1. Flamegraph:保留组件树结构
**火焰图(Flamegraph)**以组件树的层级关系展示一次提交。
概念上可以表示为:
App
├── Header
├── SearchPage
│ ├── SearchBox
│ ├── FilterPanel
│ └── ResultList
│ ├── ResultItem
│ ├── ResultItem
│ └── ResultItem
└── Footer
在 DevTools 的火焰图中:
- 每个矩形通常代表一个组件;
- 上层矩形是父组件,下层矩形是子组件;
- 矩形的宽度和颜色用于表达该组件在当前提交中的相对渲染成本,具体视觉含义可能随 DevTools 版本变化;
- 选中组件后,面板会显示该组件在这次提交中的耗时、所属提交和相关信息。
火焰图最适合回答:
一个慢提交集中在哪个组件子树?
但它不直接回答:
这个组件为什么被重新渲染?
这需要结合“更新原因”、组件代码和上一提交一起判断。
2. Ranked:按耗时排序
Ranked 视图把当前提交中的组件按耗时排序,而不是优先保留树结构。
它适合回答:
当前提交中,最耗时的组件是哪几个?
例如,组件树中 ResultList 很深,火焰图能说明它位于搜索结果区域;Ranked 视图则可能直接把其中耗时最高的 ResultItem 排到前面。
两者的关系是:
- Flamegraph 适合追踪父子传播路径;
- Ranked 适合快速找出局部热点;
- 只有同时知道提交触发原因,才能判断热点是否值得优化。
一个组件耗时高不一定是问题:
- 它可能只在初始化时渲染一次;
- 它可能是用户明确触发的复杂计算;
- 它可能只在不可感知的后台更新中耗时;
- 它可能耗时低但触发频率极高,累计成本反而更大。
更有价值的判断通常是:
Profiler 记录提供的是观测数据;这个乘法关系需要结合交互场景自行建立。
六、“Why did this render?”:更新来源不是单一调用栈
React DevTools 某些版本会在组件详情或 Profiler 中提供类似 Why did this render? 的信息。它的目的不是显示一个万能的“罪魁祸首”,而是指出当前组件参与本次 render 的已知原因,例如:
- Props 发生变化;
- State 发生变化;
- Context 发生变化;
- 某些 Hooks 的返回值或依赖产生了变化;
- 父组件重新渲染,导致非 memo 子组件再次执行。
这里必须区分两件事:
- 为什么组件函数被调用;
- 是什么业务事件最初发起了整棵更新。
DevTools 通常更擅长回答第一件事,不一定能把第二件事还原成完整的业务因果链。
例如:
function Parent() {
const [count, setCount] = useState(0);
return (
<>
<button onClick={() => setCount((value) => value + 1)}>
{count}
</button>
<Child />
</>
);
}
function Child() {
return <p>不依赖 count</p>;
}
点击按钮时:
Parent的 State 变化;- React 重新执行
Parent; Child位于Parent的返回结果中;- 如果
Child没有使用memo,它通常也会被重新执行; - 这不表示
Child的 DOM 必然发生了变化。
如果改成:
import { memo } from "react";
const Child = memo(function Child() {
return <p>不依赖 count</p>;
});
当 Child 没有 Props、State 或 Context 变化时,React 可以通过浅比较跳过它的重新渲染。
但是,memo 不是“组件永远不更新”:
- 组件自己的 State 变化时仍然会更新;
- 组件读取的 Context 变化时仍然会更新;
- 父组件传入的新 Props 不再相等时仍然会更新;
- 自定义比较函数写错时可能显示过期内容。
Props 变化的引用陷阱
下面的代码每次父组件渲染都会创建新对象:
const options = { pageSize: 20 };
return <ResultList options={options} />;
即使对象内部内容相同,前后两次引用也不同:
previousOptions !== nextOptions; // true
因此 memo(ResultList) 仍可能认为 Props 变化。
如果对象确实应该在依赖不变时复用,可以写成:
const options = useMemo(() => ({ pageSize: 20 }), []);
return <ResultList options={options} />;
但 useMemo 不是语义正确性的保证,也不应为了让 DevTools 中的数字变小而到处添加。它的合理前提是:
- 子组件确实使用引用相等判断避免了实际工作;
- 对象创建或下游计算是可识别的成本;
- 依赖数组准确表达了对象的逻辑依赖。
七、Context 更新为什么会绕过普通 Props 优化
Context 的传播是另一个常见误判来源:
const ThemeContext = createContext("light");
function ThemeProvider() {
const [theme, setTheme] = useState<"light" | "dark">("light");
return (
<ThemeContext.Provider value={theme}>
<Toolbar />
<button onClick={() => setTheme("dark")}>Dark</button>
</ThemeContext.Provider>
);
}
const Toolbar = memo(function Toolbar() {
const theme = useContext(ThemeContext);
return <div data-theme={theme}>Toolbar</div>;
});
即使 Toolbar 使用 memo,当 Provider 的 value 从 "light" 变为 "dark" 时,读取该 Context 的组件仍然需要更新。原因是 Context 不是通过普通 Props 传入的,memo 的 Props 比较不能阻止它接收新的 Context 值。
如果 Provider 的值是对象,还会出现引用变化问题:
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
父组件每次执行都会创建新对象,导致所有读取该 Context 的消费者都可能收到新的值。可以在确认这确实是热点后使用:
const contextValue = useMemo(
() => ({ theme, setTheme }),
[theme]
);
return (
<ThemeContext.Provider value={contextValue}>
{children}
</ThemeContext.Provider>
);
这仍然不能替代更细粒度的数据建模。如果一个页面中大量组件只关心 Context 对象中的一个字段,直接把整个大对象放入单一 Context,更新传播范围可能本身就过大。此时应考虑拆分 Context、下移 Provider 或使用适合场景的外部状态选择器。
八、一个可运行的端到端 Profiler 示例
下面的示例可以放入 Vite 的 React + TypeScript 项目中运行。创建项目:
npm create vite@latest react-profiler-demo -- --template react-ts
cd react-profiler-demo
npm install
npm run dev
将 src/App.tsx 替换为:
import {
Profiler,
memo,
useMemo,
useState,
type ProfilerOnRenderCallback,
} from "react";
const ExpensiveList = memo(function ExpensiveList({
items,
options,
}: {
items: number[];
options: { multiplier: number };
}) {
const result = items.map((item) => {
let value = item * options.multiplier;
// 故意制造可观察的 CPU 工作,便于在 Profiler 中看到差异。
for (let i = 0; i < 20_000; i++) {
value = (value + i) % 100_003;
}
return value;
});
return (
<ul>
{result.map((value, index) => (
<li key={index}>{value}</li>
))}
</ul>
);
});
function App() {
const [count, setCount] = useState(0);
const [multiplier, setMultiplier] = useState(2);
const items = useMemo(
() => Array.from({ length: 100 }, (_, index) => index + 1),
[]
);
const options = useMemo(
() => ({ multiplier }),
[multiplier]
);
const onRender: ProfilerOnRenderCallback = (
id,
phase,
actualDuration,
baseDuration,
startTime,
commitTime
) => {
console.table({
id,
phase,
actualDuration,
baseDuration,
startTime,
commitTime,
});
};
return (
<Profiler id="AppTree" onRender={onRender}>
<main>
<h1>React Profiler Demo</h1>
<button onClick={() => setCount((value) => value + 1)}>
unrelated count: {count}
</button>
<button onClick={() => setMultiplier((value) => value + 1)}>
multiplier: {multiplier}
</button>
<ExpensiveList items={items} options={options} />
</main>
</Profiler>
);
}
export default App;
这个示例中的关键点是:
Profiler是 React 提供的运行时组件;id用于标识被测子树;onRender在该子树完成一次 mount 或 update 提交后被调用;actualDuration反映本次实际完成的 render 工作;baseDuration可用于观察“如果没有有效跳过,子树大致有多贵”。
运行后打开浏览器控制台,点击两个按钮:
点击 unrelated count
App 的 State 变化,App 会重新渲染。由于 items 和 options 都通过 useMemo 保持引用稳定,ExpensiveList 的 Props 没有变化,memo 可以跳过它的大部分工作。
预期现象是:
AppTree产生一次update记录;actualDuration通常明显低于第一次 mount;ExpensiveList可能不再作为实际变化的热点出现。
点击 multiplier
options 的引用随着 multiplier 变化,ExpensiveList 的 Props 变化,因此它需要重新渲染。
预期现象是:
- 产生一次
update; ExpensiveList出现在火焰图和 Ranked 视图的耗时区域;actualDuration相比只更新count更高;baseDuration可以帮助观察这棵子树的潜在重新渲染成本。
这里的循环只是为了让机制容易观察,不代表真实应用应该通过增加无意义计算来测试性能。生产诊断应使用接近真实数据规模和交互路径的场景。
Profiler 回调的边界
onRender 只对包裹在 Profiler 内的子树生效。它不是全局 React 事件监听器,也不会自动记录整个页面。
回调耗时本身也会影响测试。如果在回调中同步发送网络请求、序列化巨大对象或打印大量日志,测量结果会被诊断代码污染。示例中的 console.table 适合开发阶段观察,不应无条件保留在高频生产路径中。
九、如何从一次慢提交推导出根因
假设用户点击搜索按钮后,Profiler 记录到:
提交 1:actualDuration = 4 ms
提交 2:actualDuration = 87 ms
提交 3:actualDuration = 5 ms
不能直接得出“提交 2 的某个组件一定有问题”。应按以下顺序推导。
第一步:确认提交边界
先看提交 2 发生在什么操作之后:
- 是输入框每个字符都触发?
- 是点击搜索后只触发一次?
- 是定时器周期性触发?
- 是数据请求返回后触发?
- 是开发模式下初始化产生的额外记录?
如果 87 ms 只发生一次,并且是用户明确点击后进行的大型列表构建,它和每次键盘输入都花费 87 ms 的性质不同。
第二步:看 Flamegraph 的树路径
假设路径是:
App
└── SearchPage
└── ResultList
└── ResultItem
再看 Ranked 视图。如果 ResultList 和大量 ResultItem 都占据高位,说明成本可能来自:
- 列表实例数量过多;
- 每个项都有较重的 render 计算;
- 父组件更新导致整棵列表重新执行;
- Props 引用不稳定,使
memo无法跳过; - 列表的 key 使用不正确,引发额外的卸载和挂载。
第三步:查看更新来源
如果 DevTools 显示 ResultItem 的 Props 变化,应比较前后 Props:
<ResultItem
item={item}
onSelect={() => select(item.id)}
/>
这里每次父组件渲染都会创建新的函数:
previousOnSelect !== nextOnSelect; // true
即使 item 没有变化,memo(ResultItem) 仍可能失效。
可以改为:
const handleSelect = useCallback((id: string) => {
select(id);
}, [select]);
但这还不够,因为如果子项需要不同的 id,仍然可能在 map 中创建闭包:
<ResultItem onSelect={() => handleSelect(item.id)} />
此时应先确认函数创建是否真的构成热点,再考虑调整子组件 API,例如让子组件接收稳定的选择处理器和自身 id:
<ResultItem id={item.id} onSelect={handleSelect} />
由子组件在事件发生时调用:
onSelect(id);
优化成立的条件不是“用了 useCallback”,而是:
- 父组件频繁重新渲染;
- 子组件本来可以通过
memo跳过; - Props 引用稳定后确实能跳过;
- 节省的工作大于维护缓存和比较依赖的成本。
第四步:检查是否真的发生了宿主环境更新
Profiler 显示某组件参与 render,不代表它更新了 DOM。若需要确认浏览器层面的成本,应同时打开 Performance 面板,观察:
- React JavaScript 执行时间;
- Layout;
- Recalculate Style;
- Paint;
- Long Task;
- 事件处理器;
- 其他脚本任务。
如果 React actualDuration 很低,但页面仍卡顿,问题很可能在 React 之外,例如同步布局读取、复杂 CSS、Canvas 绘制、第三方脚本或大量序列化。
十、火焰图中的“宽度”和“颜色”不能机械解读
火焰图视觉上很直观,但存在几个边界。
1. 父组件耗时可能包含子树成本
父组件的耗时统计通常覆盖其子树处理的一部分,因此不能简单把所有父子矩形相加后当作总耗时,否则会重复计算。
正确的阅读方式是:
- 先用 Ranked 找局部热点;
- 再回到 Flamegraph 看热点位于哪条组件树路径;
- 不要把父组件和每个子组件的时间简单求和;
- 对同一提交只比较相同层级、相同类型的候选项。
2. 低耗时组件可能是高频问题
一个组件每次只耗时 1 ms,但每次输入都触发,可能比一个只执行一次、耗时 30 ms 的初始化组件更影响用户体验。
可以用近似值排序:
其中:
- 是第 类更新的单次 React 成本;
- 是该更新在目标场景中的发生次数;
- 是该场景的累计成本。
3. 开发模式的数据不能直接当生产数字
开发构建可能包含:
- Strict Mode 的额外检查;
- 更丰富的警告和校验;
- 未压缩代码;
- Source Map;
- DevTools 连接开销;
- 开发服务器的模块运行方式。
因此,Profiler 中的绝对毫秒数只能在同一环境、同一操作、同一数据规模下比较。不要把本地开发机的一次 20 ms 当成所有生产设备上的固定性能结论。
十一、Strict Mode 为什么会让你看到额外渲染
React 的开发模式 Strict Mode 会主动进行额外调用和检查,以帮助发现不纯的组件逻辑和不安全的副作用。不同 React 版本和具体生命周期的表现有所差异,但一个可靠原则是:
开发模式中观察到的额外函数执行,不应直接等同于生产用户会看到的同样执行次数。
例如:
function Component() {
console.log("render");
return <div />;
}
在 Strict Mode 下,开发控制台可能出现比预期更多的 "render"。这不是 Profiler 失真,而是开发检查改变了可观察的执行次数。
诊断时应:
- 先确认当前是否是开发构建;
- 确认根节点是否启用了 Strict Mode;
- 对比相同版本的生产构建或接近生产的测试构建;
- 不要为了让日志次数变少而删除 Strict Mode;
- 检查组件 render 是否纯、Effect 是否可重复执行。
Strict Mode 额外暴露的问题通常是真问题,例如在 render 中修改全局变量、在 Effect 中重复注册监听却没有清理。它会影响“次数”判断,但不会让纯组件凭空产生真实的业务副作用。
十二、memo、useMemo 和 Profiler 之间的因果关系
这三个工具经常被同时提及,但它们处在不同层级:
memo:允许 React 在 Props 没有变化时跳过组件重新渲染;useMemo:在依赖不变时缓存一个计算结果;- Profiler:观察实际发生了什么。
例如:
const visibleItems = useMemo(
() => filterItems(items, query),
[items, query]
);
return <List items={visibleItems} />;
这个 useMemo 可能有两个作用:
- 避免每次父组件更新都执行
filterItems; - 保持
visibleItems引用稳定,使memo(List)有机会跳过。
但如果:
function List({ items }: { items: Item[] }) {
return items.map(renderItem);
}
List 没有 memo,那么仅仅缓存 visibleItems 不一定减少 List 函数执行;它只减少了数组计算,并保持了传入引用。
反过来,仅使用:
const List = memo(function List(...) { ... });
也不一定有用。如果父组件每次都传入新数组、新对象和新函数,浅比较仍然会失败。
诊断顺序应是:
- Profiler 证明某组件频繁且昂贵;
- DevTools 显示它因 Props 或父组件更新而重新渲染;
- 找到导致引用变化的具体值;
- 判断是否可以稳定引用、拆分组件或缩小更新范围;
- 修复后用同一交互重新录制。
“看到组件重新渲染,所以全部加 memo”缺少第 2 至第 4 步,容易增加比较成本、依赖复杂度和维护负担,却没有改善真实场景。
十三、状态位置决定更新传播范围
状态更新的传播范围与状态放置位置密切相关。
状态过高
function App() {
const [query, setQuery] = useState("");
return (
<>
<Header />
<SearchBox query={query} onChange={setQuery} />
<LargeDashboard />
</>
);
}
如果 query 只被搜索区域使用,却放在 App,每次输入都可能让 Header 和 LargeDashboard 参与父树更新。
状态下移
function App() {
return (
<>
<Header />
<SearchSection />
<LargeDashboard />
</>
);
}
function SearchSection() {
const [query, setQuery] = useState("");
return <SearchBox query={query} onChange={setQuery} />;
}
现在查询状态变化主要限制在 SearchSection 子树中。这个改变不是微优化,而是改变了更新图:
实际 React 还会考虑 Props、Context、Memo 和调度优先级,但状态位置仍是第一层次的结构性因素。
Profiler 中,如果每次输入都能看到整个页面的大面积子树参与提交,应先检查状态是否放得过高,再考虑缓存和组件记忆化。
十四、批处理、Transition 和提交数量的误读
React 可能把多个状态更新合并处理:
function SearchButton() {
const [status, setStatus] = useState("idle");
const [items, setItems] = useState<string[]>([]);
function handleClick() {
setStatus("loading");
setItems(["a", "b", "c"]);
}
return null;
}
这两个更新发生在同一个事件处理器中,可能被批处理为一次提交。Profiler 中看到一次提交,不意味着只执行了一个状态更新。
另一方面,使用 Transition 时,更新可能拆成不同优先级:
import { startTransition, useState } from "react";
function Search() {
const [query, setQuery] = useState("");
const [result, setResult] = useState<string[]>([]);
function handleChange(nextQuery: string) {
setQuery(nextQuery);
startTransition(() => {
setResult(searchLargeDataSet(nextQuery));
});
}
return null;
}
这里的意图是让输入状态优先响应,把结果更新标记为较低优先级。Profiler 可能记录不同的提交或不同时间点,具体表现取决于调度、数据量和设备负载。
需要注意:
startTransition不会让同步计算searchLargeDataSet自动变快;- 如果计算在调用
startTransition的同步函数中立即执行,CPU 成本仍然存在; - 它主要改变更新优先级和可中断性;
- 过渡也不能替代列表虚拟化、合理分页或降低单次计算量。
十五、开发模式、生产构建与生产 Profiling
开发构建
开发构建适合:
- 查看组件名;
- 查看 Props、State、Hooks;
- 使用更新原因提示;
- 捕获警告和错误;
- 录制相对性能差异。
但开发构建的绝对耗时通常不能代表生产。
普通生产构建
生产构建通常会移除开发警告和部分调试代码,压缩 JavaScript,并改变模块加载方式。它更接近用户实际运行环境,但调试信息可能减少,组件名也可能因为压缩而不完整。
生产 Profiling
如果需要测量真实生产负载,应根据 React 官方文档和所用构建工具启用支持 profiling 的生产配置。不同 React 版本和构建链对 profiling 包、打包入口和环境变量的处理方式可能不同,不应把某个旧版 webpack 配置直接复制到现代框架中。
启用生产 profiling 会带来额外开销,因此更适合:
- 临时诊断构建;
- 内部测试环境;
- 经过授权的线上采样;
- 对特定用户或会话进行低比例观测。
不要在没有成本评估的情况下,把 profiling 版本作为所有生产用户的默认包。
十六、服务端渲染和 Server Components 的边界
React DevTools 的 Profiler 主要观察浏览器中运行的 React 客户端树。服务端相关场景需要分开理解。
SSR
服务端渲染时:
- 服务端执行组件或渲染框架;
- 生成 HTML;
- 浏览器接收 HTML;
- 客户端 React 进行 hydration;
- 后续交互在客户端触发更新和提交。
浏览器 Profiler 通常不能直接告诉你:
- 服务端生成 HTML 花了多少时间;
- 服务端数据库查询花了多少时间;
- 服务端组件序列化花了多少时间;
- 网络传输和缓存命中情况如何。
它可以帮助观察第 4 步之后的客户端 hydration 和交互更新,但这不等于完整的首屏性能。服务端耗时应使用框架的服务端日志、请求追踪、数据库监控和浏览器 Navigation Timing 一起分析。
React Server Components
在使用 React Server Components 的框架中,服务端组件执行发生在服务器,客户端组件执行发生在浏览器。客户端 DevTools 看到的组件树和 Profiler 记录不能被解释为整个 RSC 请求链路。
当服务端数据变化导致新的 RSC 负载发送到客户端时,客户端可能出现一次更新提交。这个提交只能说明客户端接收并应用了新的 React 数据和 UI 结果,不能直接证明:
- 服务端生成负载很慢;
- 网络传输很慢;
- 客户端 render 很慢。
应分别测量:
服务端生成时间
+ 网络传输时间
+ 客户端解析/调度时间
+ 客户端 React render/commit 时间
+ 浏览器布局/绘制时间
“Profiler 里看到一个慢提交”与“服务端接口慢”之间没有必然因果关系。
十七、常见失败表现及诊断路径
1. Profiler 中没有记录
可能原因包括:
- 没有真正开始录制;
- 交互没有触发 React 更新;
- 操作发生在 Profiler 包裹范围之外;
- 页面使用了无法连接的 iframe 或隔离环境;
- React DevTools 扩展版本与页面环境不兼容;
- 当前构建或运行时没有提供所需的 profiling 信息。
验证方式:
- 在 Components 面板确认能看到组件树;
- 在 Profiler 中录制一个明确会改变 State 的按钮;
- 确认
Profiler组件的onRender是否收到回调; - 检查浏览器控制台是否有扩展连接或运行时错误;
- 使用最小页面排除框架插件和业务代码干扰。
2. 所有组件看起来都重新渲染
先确认“重新渲染”是指:
- 函数组件被调用;
- React 完成了 render;
- DOM 被修改;
- 浏览器重新绘制。
这四者不是同一个事件。
随后检查:
- 父组件是否更新;
- 子组件是否使用
memo; - Props 是否包含每次新建的对象、数组或函数;
- 是否读取了变化的 Context;
- 是否处于 Strict Mode 开发环境;
- 是否有高频外部订阅或 Store 更新。
3. actualDuration 很高,但页面不一定卡
可能是:
- 录制设备很慢;
- 开发构建放大了计算成本;
- 交互发生在不影响当前视口的后台树;
- 浏览器仍能在下一帧前完成全部工作;
- React 时间高,但用户没有同步等待这次更新。
反过来,页面明显卡顿而 actualDuration 不高,也可能是:
- layout 或 paint 成本高;
- 事件处理器先做了大量同步工作;
- 第三方脚本占用主线程;
- 网络结果处理和 JSON 解析不在被测 Profiler 子树内;
- 发生了长任务但不主要来自 React。
4. 更新原因显示不完整
“Why did this render?” 依赖 React DevTools 能够从当前运行时观察到足够信息。它不是稳定的业务审计 API,也不是所有重新执行路径的完整证明。
对于复杂问题,应补充应用层诊断:
function useLoggedState<T>(
name: string,
value: T
): T {
console.log(`[state] ${name}`, value);
return value;
}
或者在事件边界记录:
function handleSubmit() {
performance.mark("search-submit-start");
// 触发请求或状态更新
performance.mark("search-submit-end");
performance.measure(
"search-submit-handler",
"search-submit-start",
"search-submit-end"
);
}
这些日志用于关联“业务事件”和“React 提交”,不能替代 Profiler 的组件级数据。
十八、用 performance.mark 把业务事件和 React 提交关联起来
Profiler 的 commitTime 只描述 React 提交时间。若要知道一次业务交互从开始到完成经历了什么,可以同时记录浏览器 Performance 标记:
import { Profiler, type ProfilerOnRenderCallback } from "react";
const onRender: ProfilerOnRenderCallback = (
id,
phase,
actualDuration,
baseDuration,
startTime,
commitTime
) => {
performance.measure(`react:${id}:${phase}`, {
start: startTime,
end: commitTime,
detail: {
actualDuration,
baseDuration,
},
});
};
不过这里有一个实现和兼容性边界:performance.measure 的对象参数和 detail 支持取决于目标浏览器。若要兼容更老的浏览器,可以使用传统的起止标记方式,或只将数据发送到自定义日志系统。
更稳妥的生产采样策略是:
- 只在少量会话启用;
- 只记录聚合数据,不记录敏感 Props;
- 限制提交频率和日志大小;
- 用版本、设备类别、路由和交互名称做分组;
- 让用户数据与组件树调试信息保持隔离。
十九、错误路径:Profiler 记录不等于错误监控
Profiler 可以告诉你某次提交耗时,但它不是错误边界,也不会自动替代错误监控。
例如组件在 render 中抛出异常:
function BrokenComponent() {
throw new Error("render failed");
}
React 可能中止当前 render,并交给最近的错误边界处理。由于这次工作没有正常完成提交,Profiler 不一定产生一条代表“成功提交”的记录。
错误处理应使用错误边界:
import { Component, type ErrorInfo, type ReactNode } from "react";
type Props = {
children: ReactNode;
};
type State = {
hasError: boolean;
};
export class ErrorBoundary extends Component<Props, State> {
state: State = { hasError: false };
static getDerivedStateFromError(): State {
return { hasError: true };
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.error("UI render error", error, info.componentStack);
}
render() {
if (this.state.hasError) {
return <p>页面区域暂时无法显示。</p>;
}
return this.props.children;
}
}
实际系统中,componentDidCatch 应接入错误监控,并注意脱敏。Profiler 用于性能因果,错误边界用于渲染失败恢复;二者的数据模型不同。
二十、诊断一个真实列表问题的完整流程
假设产品反馈“输入搜索条件时页面卡顿”,可以建立如下验证过程。
1. 固定场景
记录以下变量:
- 浏览器和设备;
- 数据条数;
- 搜索词长度;
- 是否开发构建;
- 是否启用 Strict Mode;
- 是否存在网络请求;
- 输入频率和防抖策略。
没有固定场景,就不能比较修复前后的数据。
2. 录制输入过程
在 Profiler 中只录制连续输入 "react" 的过程,观察:
- 每个字符是否产生一次提交;
- 每次提交是否都重新计算过滤结果;
- 列表和列表项是否参与每次更新;
- 组件更新原因是 State、Props、Context 还是父级传播。
3. 区分计算和渲染
如果过滤函数本身很慢:
const visibleItems = filterItems(items, query);
Profiler 可能把成本归到拥有这段计算的组件。此时 useMemo 只有在 items 和 query 不变时才有帮助:
const visibleItems = useMemo(
() => filterItems(items, query),
[items, query]
);
如果每次输入 query 都变化,useMemo 不能减少每次搜索的计算;它只能避免其他无关父更新重复执行过滤。
4. 降低每次工作量
如果每个字符都必须搜索完整数据集,可能需要:
- 输入防抖;
- 将非紧急结果更新放入 Transition;
- 服务端搜索;
- 分页或虚拟化;
- 更高效的数据索引;
- 将搜索状态和大列表状态分离。
这些方案解决的是不同瓶颈:
- 防抖减少更新频率;
- Transition 改变优先级;
- 虚拟化减少当前实际渲染的实例数;
- 索引减少单次搜索计算;
- 服务端搜索转移计算位置,但引入网络延迟和服务端成本。
Profiler 能帮助确认客户端 React 是否是瓶颈,但不会自动选择架构方案。
5. 重新录制并验证副作用
修复后必须使用相同输入、相同数据规模和尽量相同的设备重新录制。重点比较:
- 提交次数;
- 每次提交的
actualDuration; - 列表子树参与提交的范围;
- 首次显示结果的延迟;
- 浏览器 Performance 中是否出现新的网络、布局或长任务问题。
如果只看到某个组件的耗时数字下降,却没有改善输入响应,可能优化错了层级。
二十一、什么时候不应继续优化 React render
以下情况中,继续添加 memo 或 useMemo 往往不是正确方向:
React 只占总耗时很小部分
如果一次交互中:
事件处理器:90 ms
React render:5 ms
浏览器布局绘制:20 ms
把 React 的 5 ms 再优化 50%,对 115 ms 的整体交互只减少约 2.5 ms。应先处理事件处理器或布局问题。
组件只渲染一次且成本可接受
初始化阶段一个组件耗时 10 ms,但用户操作不会再次触发它。为了避免这一次成本而引入复杂缓存,可能增加代码复杂度却没有改善核心体验。
通过错误缓存导致数据过期
错误的 memo 比没有 memo 更危险:
const View = memo(
function View({ user }: { user: User }) {
return <span>{user.name}</span>;
},
() => true // 永远认为 Props 相同
);
这会阻止合法更新,导致界面显示旧数据。性能优化不能牺牲 UI 正确性。
把诊断代码当成业务代码
console.log、对象深拷贝、序列化 Props 和同步上报都会改变执行成本。诊断逻辑必须可关闭、可采样,并在测量前后保持一致。
二十二、如何区分规范保证、实现细节和经验判断
在使用 DevTools 和 Profiler 时,需要明确三类信息。
React API 的规范能力
React 官方 API 提供了:
Profiler组件;onRender回调;memo;useMemo;useCallback;useTransition;- State、Context 和 Hooks 等运行机制。
这些 API 的参数和语义应以当前 React 19 官方文档为准。
DevTools 的实现和界面
以下内容可能随 DevTools 版本变化:
- 更新原因的具体文案;
- 火焰图的颜色规则;
- 面板按钮位置;
- 是否显示某些 Hook 细节;
- 对框架和服务器组件的展示方式;
- 某些组件名称和源码定位能力。
因此,不能把某个 DevTools 版本中显示的标签当成 React 应用可以依赖的运行时契约。
工程经验
以下属于诊断经验,而不是 React 的硬性保证:
- Props 引用稳定通常有利于
memo; - 状态下移通常能缩小更新范围;
- 高提交频率通常比单次低频耗时更值得优先处理;
- 生产 profiling 应采用采样或专门构建;
- React Profiler 应与浏览器 Performance、错误监控和服务端追踪配合使用。
只有把这三类信息分开,才能避免把调试工具的视觉提示误解成框架保证。
React DevTools 的价值不在于给每个组件贴上“慢”或“快”的标签,而在于把一次更新拆成可验证的链路:
业务事件
→ 状态 / Props / Context / 外部订阅变化
→ 哪些组件参与 render
→ 哪个提交真正完成
→ 哪条组件树路径消耗最多
→ React 成本还是浏览器、网络、服务端成本
Profiler 的提交记录提供时间边界,更新来源提示提供局部因果,火焰图保留组件树结构,Ranked 视图帮助排序热点。将这几类信息与可重复的交互、生产近似环境和浏览器全链路数据结合,才能把“页面感觉卡”转化为可验证、可回归的性能诊断结论。
系列导航与关联阅读
- 系列入口:React 完整学习路线:从渲染与 Hooks 到服务端组件和生产架构
- 上一篇:React Storybook:Story、交互测试、文档、主题和评审
- 下一篇:React Bundle 分析:Tree Shaking、分包、预加载和性能预算
官方资料
本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论