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
                         │
                         ▼
              浏览器后续绘制

这里有三个容易混淆的概念:

  1. 组件函数执行:React 调用函数组件,计算 JSX。
  2. render phase:React 比较新旧 React 元素树,决定哪些组件和节点需要继续处理。
  3. commit phase:React 把最终结果应用到宿主环境,例如更新 DOM,并运行相应的提交阶段逻辑。

组件函数执行过,不代表 DOM 一定发生了变化。React 可能重新调用组件以计算结果,随后发现结果在宿主环境上没有需要应用的变化。

同样,render 也不保证一定会 commit。在并发渲染中,某次 render 可能被更高优先级更新打断,或者因为新更新到来而被丢弃。Profiler 主要记录已经完成的提交,而不是所有曾经开始过的计算。

因此,下面这个近似关系成立:

一次提交=一次完成并应用的 React 更新批次\text{一次提交} = \text{一次完成并应用的 React 更新批次}

但它不是严格的“一次 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:通常是 mountupdate;某些嵌套更新场景还可能出现 nested-update

这些数字不是用户感知延迟的完整等式:

用户感知延迟React actualDuration\text{用户感知延迟} \neq \text{React actualDuration}

更接近的分解是:

Tinteraction=Tevent+TReact render+TReact commit+Tlayout/style+Tpaint+Tother tasksT_{\text{interaction}} = T_{\text{event}} + T_{\text{React render}} + T_{\text{React commit}} + T_{\text{layout/style}} + T_{\text{paint}} + T_{\text{other tasks}}

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 通常以浏览器扩展形式使用,也有独立版本可连接桌面或移动调试环境。使用浏览器扩展时,前置条件是:

  1. 页面运行的是 React 应用;
  2. 应用没有被完全隔离在 DevTools 无法访问的环境中;
  3. 开发构建保留了可调试信息;
  4. 浏览器扩展能够连接到页面中的 React 运行时。

打开浏览器开发者工具后,通常可以看到:

  • Components:查看当前组件树、Props、State、Context 和 Hooks;
  • Profiler:录制并分析 React 提交;
  • 某些版本还会提供与性能、组件高亮或 React 服务器组件相关的附加能力。

DevTools 的具体按钮名称和布局会随扩展版本变化,因此不应把某个版本的面板位置当作 React API。核心流程基本一致:

  1. 打开 Profiler
  2. 开始录制;
  3. 执行一次可重复的交互,例如输入、切换标签页或翻页;
  4. 停止录制;
  5. 选择一个提交;
  6. 在 Flamegraph 或 Ranked 视图中定位耗时组件;
  7. 回到 Components 面板检查对应组件的 Props、State、Context 和 Hooks;
  8. 修复后用相同操作重新录制,比较提交数量、耗时和组件参与范围。

录制时应尽量只包含目标操作。把页面加载、登录、动画、轮询和用户交互全部混在一段记录中,会让提交之间的因果关系变得不清楚。


五、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 适合快速找出局部热点;
  • 只有同时知道提交触发原因,才能判断热点是否值得优化。

一个组件耗时高不一定是问题:

  • 它可能只在初始化时渲染一次;
  • 它可能是用户明确触发的复杂计算;
  • 它可能只在不可感知的后台更新中耗时;
  • 它可能耗时低但触发频率极高,累计成本反而更大。

更有价值的判断通常是:

累计成本=单次渲染成本×提交频率×受影响实例数\text{累计成本} = \text{单次渲染成本} \times \text{提交频率} \times \text{受影响实例数}

Profiler 记录提供的是观测数据;这个乘法关系需要结合交互场景自行建立。


六、“Why did this render?”:更新来源不是单一调用栈

React DevTools 某些版本会在组件详情或 Profiler 中提供类似 Why did this render? 的信息。它的目的不是显示一个万能的“罪魁祸首”,而是指出当前组件参与本次 render 的已知原因,例如:

  • Props 发生变化;
  • State 发生变化;
  • Context 发生变化;
  • 某些 Hooks 的返回值或依赖产生了变化;
  • 父组件重新渲染,导致非 memo 子组件再次执行。

这里必须区分两件事:

  1. 为什么组件函数被调用
  2. 是什么业务事件最初发起了整棵更新

DevTools 通常更擅长回答第一件事,不一定能把第二件事还原成完整的业务因果链。

例如:

function Parent() {
  const [count, setCount] = useState(0);

  return (
    <>
      <button onClick={() => setCount((value) => value + 1)}>
        {count}
      </button>
      <Child />
    </>
  );
}

function Child() {
  return <p>不依赖 count</p>;
}

点击按钮时:

  1. Parent 的 State 变化;
  2. React 重新执行 Parent
  3. Child 位于 Parent 的返回结果中;
  4. 如果 Child 没有使用 memo,它通常也会被重新执行;
  5. 这不表示 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 会重新渲染。由于 itemsoptions 都通过 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”,而是:

  1. 父组件频繁重新渲染;
  2. 子组件本来可以通过 memo 跳过;
  3. Props 引用稳定后确实能跳过;
  4. 节省的工作大于维护缓存和比较依赖的成本。

第四步:检查是否真的发生了宿主环境更新

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 的初始化组件更影响用户体验。

可以用近似值排序:

C场景=i=1nDi×FiC_{\text{场景}} = \sum_{i=1}^{n} D_i \times F_i

其中:

  • DiD_i 是第 ii 类更新的单次 React 成本;
  • FiF_i 是该更新在目标场景中的发生次数;
  • C场景C_{\text{场景}} 是该场景的累计成本。

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 失真,而是开发检查改变了可观察的执行次数。

诊断时应:

  1. 先确认当前是否是开发构建;
  2. 确认根节点是否启用了 Strict Mode;
  3. 对比相同版本的生产构建或接近生产的测试构建;
  4. 不要为了让日志次数变少而删除 Strict Mode;
  5. 检查组件 render 是否纯、Effect 是否可重复执行。

Strict Mode 额外暴露的问题通常是真问题,例如在 render 中修改全局变量、在 Effect 中重复注册监听却没有清理。它会影响“次数”判断,但不会让纯组件凭空产生真实的业务副作用。


十二、memouseMemo 和 Profiler 之间的因果关系

这三个工具经常被同时提及,但它们处在不同层级:

  • memo:允许 React 在 Props 没有变化时跳过组件重新渲染;
  • useMemo:在依赖不变时缓存一个计算结果;
  • Profiler:观察实际发生了什么。

例如:

const visibleItems = useMemo(
  () => filterItems(items, query),
  [items, query]
);

return <List items={visibleItems} />;

这个 useMemo 可能有两个作用:

  1. 避免每次父组件更新都执行 filterItems
  2. 保持 visibleItems 引用稳定,使 memo(List) 有机会跳过。

但如果:

function List({ items }: { items: Item[] }) {
  return items.map(renderItem);
}

List 没有 memo,那么仅仅缓存 visibleItems 不一定减少 List 函数执行;它只减少了数组计算,并保持了传入引用。

反过来,仅使用:

const List = memo(function List(...) { ... });

也不一定有用。如果父组件每次都传入新数组、新对象和新函数,浅比较仍然会失败。

诊断顺序应是:

  1. Profiler 证明某组件频繁且昂贵;
  2. DevTools 显示它因 Props 或父组件更新而重新渲染;
  3. 找到导致引用变化的具体值;
  4. 判断是否可以稳定引用、拆分组件或缩小更新范围;
  5. 修复后用同一交互重新录制。

“看到组件重新渲染,所以全部加 memo”缺少第 2 至第 4 步,容易增加比较成本、依赖复杂度和维护负担,却没有改善真实场景。


十三、状态位置决定更新传播范围

状态更新的传播范围与状态放置位置密切相关。

状态过高

function App() {
  const [query, setQuery] = useState("");

  return (
    <>
      <Header />
      <SearchBox query={query} onChange={setQuery} />
      <LargeDashboard />
    </>
  );
}

如果 query 只被搜索区域使用,却放在 App,每次输入都可能让 HeaderLargeDashboard 参与父树更新。

状态下移

function App() {
  return (
    <>
      <Header />
      <SearchSection />
      <LargeDashboard />
    </>
  );
}

function SearchSection() {
  const [query, setQuery] = useState("");

  return <SearchBox query={query} onChange={setQuery} />;
}

现在查询状态变化主要限制在 SearchSection 子树中。这个改变不是微优化,而是改变了更新图:

受影响组件集合=状态所在节点的可传播依赖集合\text{受影响组件集合} = \text{状态所在节点的可传播依赖集合}

实际 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

服务端渲染时:

  1. 服务端执行组件或渲染框架;
  2. 生成 HTML;
  3. 浏览器接收 HTML;
  4. 客户端 React 进行 hydration;
  5. 后续交互在客户端触发更新和提交。

浏览器 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 信息。

验证方式:

  1. 在 Components 面板确认能看到组件树;
  2. 在 Profiler 中录制一个明确会改变 State 的按钮;
  3. 确认 Profiler 组件的 onRender 是否收到回调;
  4. 检查浏览器控制台是否有扩展连接或运行时错误;
  5. 使用最小页面排除框架插件和业务代码干扰。

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 只有在 itemsquery 不变时才有帮助:

const visibleItems = useMemo(
  () => filterItems(items, query),
  [items, query]
);

如果每次输入 query 都变化,useMemo 不能减少每次搜索的计算;它只能避免其他无关父更新重复执行过滤。

4. 降低每次工作量

如果每个字符都必须搜索完整数据集,可能需要:

  • 输入防抖;
  • 将非紧急结果更新放入 Transition;
  • 服务端搜索;
  • 分页或虚拟化;
  • 更高效的数据索引;
  • 将搜索状态和大列表状态分离。

这些方案解决的是不同瓶颈:

  • 防抖减少更新频率;
  • Transition 改变优先级;
  • 虚拟化减少当前实际渲染的实例数;
  • 索引减少单次搜索计算;
  • 服务端搜索转移计算位置,但引入网络延迟和服务端成本。

Profiler 能帮助确认客户端 React 是否是瓶颈,但不会自动选择架构方案。

5. 重新录制并验证副作用

修复后必须使用相同输入、相同数据规模和尽量相同的设备重新录制。重点比较:

  • 提交次数;
  • 每次提交的 actualDuration
  • 列表子树参与提交的范围;
  • 首次显示结果的延迟;
  • 浏览器 Performance 中是否出现新的网络、布局或长任务问题。

如果只看到某个组件的耗时数字下降,却没有改善输入响应,可能优化错了层级。


二十一、什么时候不应继续优化 React render

以下情况中,继续添加 memouseMemo 往往不是正确方向:

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 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。