Skip to content

第17章 React 性能工程

本章要点

  • Profiler API 的内核实现:onRender 回调的触发时机与度量指标的采集原理
  • React DevTools Profiler 与 Chrome Performance 面板的协同分析方法论
  • 渲染瀑布(Render Waterfall)的识别模式与系统性消除策略
  • React Compiler 时代性能优化范式的根本性变化:从手动记忆化到编译时自动优化
  • 大列表虚拟化的工程实现:react-window 与 @tanstack/virtual 的架构对比
  • Suspense 分片加载与流式渲染的协作机制
  • 闭包陷阱与事件监听器导致的 Memory Leak 检测与修复

性能优化是一个危险的话题。

之所以说危险,是因为绝大多数"性能优化"的文章在教你做的事情,要么是过早优化,要么是在没有度量的情况下凭直觉修改代码。React 核心团队成员 Dan Abramov 曾反复强调一个观点:在你能证明存在性能问题之前,不要优化。这不是一句空话——每一次优化都引入了复杂性,而复杂性是软件系统中最昂贵的东西。

然而,当你的应用确实出现了性能问题——列表滚动卡顿、输入框响应迟钝、页面加载白屏时间过长——你需要的不是零散的技巧,而是一套系统化的性能工程方法论。这套方法论包含三个核心环节:度量(Measure)、诊断(Diagnose)、治理(Fix)。它们必须按顺序执行,跳过任何一步都可能让你在错误的方向上浪费大量时间。

React 19 和 React Compiler 的出现,让这套方法论发生了深刻的变化。过去我们花费大量精力手动添加的 useMemouseCallbackReact.memo,在编译器时代可能变得完全不必要。但与此同时,新的性能挑战也在涌现——Server Components 的瀑布请求、Suspense 边界的选择策略、大规模并发渲染下的内存压力。本章将带你建立一套适应 React 19 时代的完整性能工程体系。

17.1 性能分析工具链:从度量开始

性能优化的第一原则是:没有度量,就没有优化。React 提供了从 API 层到工具层的完整性能分析体系,我们从最底层的 Profiler API 开始。

17.1.1 Profiler 组件与 onRender 回调

React 内置的 <Profiler> 组件是性能度量的基础设施。它不是一个开发模式专属的工具——你可以在生产环境中使用它来采集真实用户的渲染性能数据。

tsx
import { Profiler, ProfilerOnRenderCallback } from 'react';

const onRender: ProfilerOnRenderCallback = (
  id,           // Profiler 树的唯一标识
  phase,        // "mount" | "update" | "nested-update"
  actualDuration,   // 本次渲染实际花费的时间(ms)
  baseDuration,     // 在没有任何优化的情况下,完整渲染子树的预估时间
  startTime,        // 本次渲染开始的时间戳
  commitTime         // 本次 commit 的时间戳
) => {
  // 发送到性能监控系统
  performanceMonitor.report({
    component: id,
    phase,
    actualDuration,
    baseDuration,
    timestamp: commitTime,
  });
};

function App() {
  return (
    <Profiler id="App" onRender={onRender}>
      <Header />
      <Profiler id="MainContent" onRender={onRender}>
        <ProductList />
        <Sidebar />
      </Profiler>
      <Footer />
    </Profiler>
  );
}

这段代码看起来简单,但要理解它的度量含义,需要深入 React 内核。

actualDurationbaseDuration 的区别是理解 React 性能模型的关键。actualDuration 是本次渲染中,这棵子树实际执行的渲染时间——如果某些子组件因为 memo 或 Compiler 优化而被跳过,它们的渲染时间不会被计入。而 baseDuration 是假设没有任何优化、所有组件都重新渲染的理论最大时间

typescript
// React 源码中 Profiler 计时的核心逻辑
// 位于 ReactFiberCommitWork.js

function commitProfilerUpdate(
  finishedWork: Fiber,
  current: Fiber | null,
) {
  const { onRender } = finishedWork.memoizedProps;

  if (typeof onRender === 'function') {
    // actualDuration 存储在 Fiber 节点上
    // 在 beginWork/completeWork 过程中累加
    let actualDuration = finishedWork.actualDuration;

    // baseDuration 是子树中所有 Fiber 节点的 selfBaseDuration 之和
    // 即使组件被跳过,baseDuration 也会包含它的时间
    let baseDuration = finishedWork.selfBaseDuration;
    let child = finishedWork.child;
    while (child !== null) {
      baseDuration += child.treeBaseDuration;
      child = child.sibling;
    }

    onRender(
      finishedWork.memoizedProps.id,
      current === null ? 'mount' : 'update',
      actualDuration,
      baseDuration,
      finishedWork.actualStartTime,
      commitTime,
    );
  }
}

深度洞察baseDurationactualDuration 的差值,就是你的优化"收益"。如果 baseDuration 是 50ms,actualDuration 是 5ms,说明优化措施(无论是手动 memo 还是 Compiler 自动优化)帮你跳过了 90% 的渲染工作。但如果两者几乎相等,说明几乎每个组件都在重新渲染——这时你需要检查状态提升是否正确、是否有不必要的 Context 更新。

17.1.2 生产环境的 Profiler 采样策略

在生产环境使用 Profiler 需要注意性能开销。Profiler 本身会引入大约 5-15% 的额外开销(取决于组件树的深度),因此建议使用采样策略:

typescript
// 生产环境的采样 Profiler 封装
const SAMPLE_RATE = 0.05; // 5% 采样率

function createSampledProfiler() {
  const shouldSample = Math.random() < SAMPLE_RATE;

  const onRender: ProfilerOnRenderCallback = (
    id, phase, actualDuration, baseDuration, startTime, commitTime
  ) => {
    if (!shouldSample) return;

    // 只上报超过阈值的慢渲染
    if (actualDuration > 16) { // 超过一帧(60fps)
      navigator.sendBeacon('/api/perf', JSON.stringify({
        id,
        phase,
        actualDuration,
        baseDuration,
        url: window.location.pathname,
        userAgent: navigator.userAgent,
        timestamp: Date.now(),
      }));
    }
  };

  return onRender;
}

这里有一个关键的阈值判断:actualDuration > 16。16ms 是 60fps 下每帧的时间预算。如果一次渲染超过了这个时间,意味着这次渲染至少占用了整整一帧,用户可能感知到卡顿。在实际项目中,你可能还需要区分不同的阈值等级:

typescript
type PerformanceSeverity = 'info' | 'warning' | 'critical';

function classifyRenderDuration(duration: number): PerformanceSeverity {
  if (duration > 100) return 'critical';  // 超过 100ms:严重卡顿
  if (duration > 50) return 'warning';    // 超过 50ms:可感知延迟
  if (duration > 16) return 'info';       // 超过 16ms:可能丢帧
  return 'info';
}

17.1.3 React DevTools Profiler 的使用方法论

React DevTools 的 Profiler 面板是日常开发中最常用的性能分析工具。它提供了三种视图,各有不同的分析侧重:

Flamegraph(火焰图)视图:展示组件树的渲染层次结构。每个组件显示为一个色条,宽度表示渲染耗时,颜色从绿色(快)到黄色、橙色、红色(慢)。灰色表示这个组件在本次渲染中被跳过。

Ranked(排序)视图:将所有重新渲染的组件按耗时从高到低排列。这是快速定位"最慢组件"的最佳视图——排在最顶部的组件就是你应该首先调查的对象。

Timeline(时间线)视图:展示每次 commit 的时间关系,可以看到 state 更新触发了哪些渲染、渲染之间的间隔是多少。这对于分析"连锁渲染"(cascading renders)特别有用。

分析性能问题的标准流程是:

1. 在 Profiler 面板点击录制按钮
2. 在应用中执行你认为有性能问题的操作
3. 停止录制
4. 首先查看 Ranked 视图,找到耗时最长的组件
5. 切换到 Flamegraph 视图,查看该组件在树中的位置
6. 点击该组件,查看"Why did this render?"信息
7. 根据渲染原因制定优化策略

17.1.4 Chrome Performance 面板与 React 的协作

当 React DevTools 的 Profiler 无法解释性能问题时——比如卡顿发生在 React 渲染之外(DOM 操作、布局计算、垃圾回收)——你需要使用 Chrome Performance 面板。

在 React 的开发构建中,React 会通过 performance.mark()performance.measure() API 在 Chrome 的 User Timing 轨道中留下标记:

typescript
// React 源码中的 Performance 标记(简化)
// 位于 ReactFiberWorkLoop.js

function performUnitOfWork(unitOfWork: Fiber): void {
  if (enableProfilerTimer) {
    // 在 Chrome Performance 面板中显示为一个标记
    performance.mark(`⚛️ ${getComponentName(unitOfWork.type)} [mount]`);
  }

  const next = beginWork(current, unitOfWork, renderLanes);

  if (enableProfilerTimer) {
    performance.measure(
      `⚛️ ${getComponentName(unitOfWork.type)}`,
      `⚛️ ${getComponentName(unitOfWork.type)} [mount]`
    );
  }
}

在 Chrome Performance 面板中,你可以看到:

  1. Main 轨道:JavaScript 执行的调用栈,可以看到 performSyncWorkOnRootbeginWorkcompleteWork 等 React 内部函数
  2. User Timing 轨道:React 标记的组件渲染信息,带有 ⚛️ 前缀
  3. Frames 轨道:帧率信息,红色帧表示掉帧
  4. Layout/Paint 轨道:DOM 操作触发的布局和绘制

深度洞察:很多性能问题不在 React 的渲染阶段,而在浏览器的布局和绘制阶段。一个典型的例子是读取 offsetHeight 触发的强制同步布局(Forced Synchronous Layout)。React 通过批量化 DOM 操作来避免这个问题,但如果你在 useLayoutEffect 中读取 DOM 几何属性、然后修改样式,仍然会触发布局抖动。Chrome Performance 面板中的紫色条(Layout)如果出现在 JavaScript 调用栈内部,就是强制同步布局的信号。

17.2 渲染瀑布的识别与消除

渲染瀑布(Render Waterfall)是 React 应用中最常见的性能反模式。它指的是一个组件的渲染触发了另一个组件的状态更新,而后者的渲染又触发了更多的状态更新,形成链式反应。

17.2.1 什么是渲染瀑布

考虑以下代码:

tsx
function Parent() {
  const [items, setItems] = useState<Item[]>([]);

  return (
    <div>
      <DataFetcher onData={setItems} />
      <ItemList items={items} />
    </div>
  );
}

function DataFetcher({ onData }: { onData: (items: Item[]) => void }) {
  const [query, setQuery] = useState('');

  // ❌ 渲染瀑布:useEffect 在渲染后触发状态更新
  useEffect(() => {
    fetch(`/api/items?q=${query}`)
      .then(res => res.json())
      .then(data => onData(data));
  }, [query, onData]);

  return <input value={query} onChange={e => setQuery(e.target.value)} />;
}

function ItemList({ items }: { items: Item[] }) {
  const [selectedId, setSelectedId] = useState<string | null>(null);

  // ❌ 又一个瀑布:items 变化时需要重置选中状态
  useEffect(() => {
    if (items.length > 0 && !items.find(i => i.id === selectedId)) {
      setSelectedId(items[0].id);
    }
  }, [items, selectedId]);

  return (
    <ul>
      {items.map(item => (
        <li
          key={item.id}
          className={item.id === selectedId ? 'selected' : ''}
          onClick={() => setSelectedId(item.id)}
        >
          {item.name}
        </li>
      ))}
    </ul>
  );
}

这段代码的渲染流程如下:

用户输入 → setQuery → Parent 渲染 → DataFetcher 渲染 →
  useEffect 触发 fetch → fetch 返回 → onData(data) → setItems →
    Parent 再次渲染 → ItemList 渲染 →
      useEffect 发现 selectedId 无效 → setSelectedId →
        ItemList 再次渲染

用户的一次输入触发了至少 3 轮渲染。在 React DevTools 的 Timeline 视图中,你会看到多次紧密排列的 commit,这就是渲染瀑布的典型特征。

17.2.2 瀑布的四种常见模式

模式一:useEffect 中的状态同步

这是最常见的瀑布来源。开发者习惯用 useEffect 来"响应" prop 变化并更新 state:

tsx
// ❌ 反模式:useEffect 做 prop → state 同步
function SearchResults({ query }: { query: string }) {
  const [results, setResults] = useState<Result[]>([]);
  const [page, setPage] = useState(1);

  // query 变化时重置页码
  useEffect(() => {
    setPage(1); // 触发额外渲染!
  }, [query]);

  // ...
}

// ✅ 正确做法:用 key 重置组件
function SearchPage() {
  const [query, setQuery] = useState('');
  return <SearchResults key={query} query={query} />;
}

function SearchResults({ query }: { query: string }) {
  const [page, setPage] = useState(1); // query 变化时组件重新挂载,state 自然重置
  // ...
}

模式二:派生状态的冗余存储

tsx
// ❌ 反模式:把派生数据存在 state 里
function FilteredList({ items, filter }: Props) {
  const [filteredItems, setFilteredItems] = useState<Item[]>([]);

  useEffect(() => {
    setFilteredItems(items.filter(item => item.category === filter));
  }, [items, filter]);

  return <List items={filteredItems} />;
}

// ✅ 正确做法:直接在渲染中计算
function FilteredList({ items, filter }: Props) {
  // 在 Compiler 时代,这会被自动记忆化
  const filteredItems = items.filter(item => item.category === filter);
  return <List items={filteredItems} />;
}

模式三:请求瀑布(Request Waterfall)

这在 Server Components 和 Suspense 场景中尤为严重:

tsx
// ❌ 请求瀑布:子组件的请求依赖父组件的请求结果
function UserProfile({ userId }: { userId: string }) {
  const user = use(fetchUser(userId)); // 第一个请求

  return (
    <div>
      <h1>{user.name}</h1>
      {/* 第二个请求必须等第一个完成后才能开始 */}
      <Suspense fallback={<Skeleton />}>
        <UserPosts userId={user.id} />
      </Suspense>
    </div>
  );
}

function UserPosts({ userId }: { userId: string }) {
  const posts = use(fetchPosts(userId)); // 要等 UserProfile 渲染完才开始
  return <PostList posts={posts} />;
}
时间线:
[========= fetchUser =========]
                                [========= fetchPosts =========]
                                                                 → 渲染完成
总时间 = fetchUser + fetchPosts(串行)

解决方案是将请求提升到同一层级,并行发起:

tsx
// ✅ 并行请求:在父组件预先发起所有请求
function UserProfile({ userId }: { userId: string }) {
  // 同时发起两个请求(Promise 在创建时就开始执行)
  const userPromise = fetchUser(userId);
  const postsPromise = fetchPosts(userId);

  const user = use(userPromise);

  return (
    <div>
      <h1>{user.name}</h1>
      <Suspense fallback={<Skeleton />}>
        <UserPosts postsPromise={postsPromise} />
      </Suspense>
    </div>
  );
}
时间线:
[========= fetchUser =========]
[========= fetchPosts =========]
                                 → 渲染完成
总时间 = max(fetchUser, fetchPosts)(并行)

基于 VitePress 构建