← Blog
·11 blog.minutes

React 性能优化完全指南

从 memo 到服务端组件——一份让你的 React 应用在不过度工程化的情况下更快的实用指南。

React 中的性能优化有一个名声问题。问十个开发者如何让 React 应用更快,八个会说“useMemo 和 useCallback 一切”。剩下的两个会说“过早优化是万恶之源”并走开。两组人都错了,两组人都在交付要么不必要地复杂要么不必要地慢的应用。

现实更加微妙。React 的渲染模型在大多数情况下开箱即用是高效的,但有一些被充分理解的模式它表现不佳。本指南涵盖了每个重要的 React 优化技术——它们如何工作、何时有帮助,以及关键的是,何时有害。目标不是让你随处使用 memo。而是给你一个关于性能的心智模型,让你在每种情况下正确的选择变得显而易见。

理解 React 何时重新渲染

在优化任何东西之前,你需要理解是什么导致了重新渲染。React 在组件状态变化时、其父组件重新渲染时、或者它消费的 context 值变化时重新渲染一个组件。这听起来简单,但级联效应是性能问题隐藏的地方。

当父组件重新渲染时,默认情况下每个子组件都会重新渲染——即使子组件的 props 没有改变。React 这样做是因为它无法知道子组件是否依赖父组件的状态,除非执行子组件的渲染函数。这不是一个 bug;这是一个设计选择,使 React 的协调模型简单且可预测。但这意味着高层组件中的状态变化可能触发数十或数百个后代组件的重新渲染。

关键见解是重新渲染不等同于更新 DOM。React 比较虚拟 DOM 输出(JSX)与之前的输出,并只将差异提交到真实 DOM。一个组件可以重新渲染一千次而没有一次 DOM 突变。代价在于 JavaScript 执行——创建虚拟 DOM 对象、运行 hooks 和 diff 树。对于小组件树,这个代价可以忽略不计。对于大列表、复杂图形或执行昂贵计算的组件,它会迅速累积。

// Every keystroke in this input re-renders the entire tree
function SearchPage() {
  const [query, setQuery] = useState("");

  return (
    <div>
      <SearchInput value={query} onChange={setQuery} />
      <SearchResults query={query} />
      <Sidebar>
        <FilterPanel />
        <RecentSearches />
      </Sidebar>
    </div>
  );
}

// Without optimization, FilterPanel and RecentSearches
// re-render on every keystroke, even though nothing
// they depend on has changed.

任何性能调查的第一步是确定你实际上是否有问题。过早优化会增加复杂性而不带来可衡量的好处。先分析,后优化——但当你确定了瓶颈,本指南中的技术就是你使用的工具。

记忆化:memo、useMemo 和 useCallback

记忆化是讨论最多也是最容易被误用的 React 优化技术。核心思想很简单:如果一个函数对相同的输入产生相同的输出,缓存结果并在后续调用时跳过计算。React 提供了三个记忆化工具,各自服务于不同的目的。

React.memo 包裹一个组件并在其 props 没有变化时阻止重新渲染(使用浅比较)。它对接收相同 props 的频率较高的叶子组件最有效——比如列表项、图表元素或任何渲染输出仅取决于其 props 的纯组件。

import { memo } from "react";

const ExpenseRow = memo(function ExpenseRow({ label, amount }: {
  label: string;
  amount: number;
}) {
  return (
    <tr>
      <td>{label}</td>
      <td className={amount < 0 ? "text-red-500" : "text-green-500"}>
        ${amount.toFixed(2)}
      </td>
    </tr>
  );
});

// Now ExpenseRow only re-renders when label or amount changes.
// Without memo, it re-renders every time the parent re-renders.

useMemo 在渲染之间缓存计算结果。当组件在每次渲染时执行昂贵的计算——过滤大数组、格式化数据或运行复杂转换——时很有用。没有 useMemo,该工作每次重新渲染都会重复,即使输入没有变化。

function Dashboard({ transactions, filter }: Props) {
  // Without useMemo: this runs on every render
  const visibleTransactions = useMemo(
    () => transactions
      .filter(t => t.date >= filter.start && t.date <= filter.end)
      .sort((a, b) => b.amount - a.amount),
    [transactions, filter]
  );

  // Without useMemo, filtering and sorting 10,000 items
  // happens on every keystroke in any input on the page.
  return <TransactionList items={visibleTransactions} />;
}

useCallback 与 useMemo 相同,但用于函数。它返回一个回调函数的记忆化版本,仅在其依赖项变化时变化。这很重要,因为在渲染体中定义的函数在每次渲染时都被重新创建,这破坏了 memo 使用的浅比较。

这是大多数指南搞错的关键规则:不要把所有东西都包裹在 useMemo 和 useCallback 中。每个记忆化调用都有成本——存储依赖数组、在每次渲染时比较它、以及分配闭包内存。对廉价操作应用记忆化会让你的应用更慢,而不是更快。启发式规则很简单:只有在你已经测量到问题,或者计算明显昂贵(复杂数据转换、递归操作、大列表渲染)时才使用记忆化。

最昂贵的记忆化是那些本不应该被写的。先分析,后包裹。每个抽象都有成本,而记忆化是对渲染循环的一种抽象。

一个常见的反模式是防御性地将一切包裹在 memo 中,希望它能防止性能问题。但它不能。它会给每次渲染比较增加开销,用额外的代码膨胀包,并使代码库更难以推理。只有在分析确认有帮助之后,才将记忆化作为有针对性的优化添加,而不是作为默认模式。

使用 React.lazy 和 Suspense 进行代码分割

包大小是 React 应用中最被忽视的性能维度。开发者痴迷于渲染优化,却同时发送 500 KB 的 JavaScript——每个用户必须下载、解析和执行这些代码之后才能看到任何内容。代码分割通过将包分割成按需加载的块来解决这个问题。

React.lazy 让你像渲染普通组件一样渲染一个动态导入的组件。结合 Suspense,你可以在块加载时显示一个后备 UI。性能提升是双重的:初始包更小,所以页面加载更快,并且用户只为他们在实际使用的代码付费。

import { lazy, Suspense } from "react";

const AnalyticsDashboard = lazy(
  () => import("./AnalyticsDashboard")
);
const DataExportPanel = lazy(
  () => import("./DataExportPanel")
);

function App() {
  const [showAnalytics, setShowAnalytics] = useState(false);

  return (
    <div>
      <button onClick={() => setShowAnalytics(true)}>
        View Analytics
      </button>
      <Suspense fallback={<div>Loading...</div>}>
        {showAnalytics && <AnalyticsDashboard />}
      </Suspense>
    </div>
  );
}

// AnalyticsDashboard and its dependencies are only loaded
// when the user clicks the button, not on initial page load.

懒加载的最佳候选是路由级组件、重量级可视化库(图表、图形、地图)、富文本编辑器以及任何在首屏下方或由用户交互触发的组件。一个经验法则:如果一个组件为包增加超过 20 KB 并且在初始渲染时不可见,它应该被懒加载。

React 19 改进的 Suspense 行为使代码分割更加实用。Suspense 边界内的数据获取现在与渲染生命周期完全集成,消除了必须先加载块然后获取其数据的瀑布问题。结合新的 use() 钩子来读取 promise,加载状态之间的边界变得更加清晰和可组合。

虚拟滚动和列表优化

渲染大列表是真实 React 应用中最常见的性能问题。一个 10,000 项的列表在开发中运行良好但在生产中崩溃,因为 React 必须在每次渲染时创建和协调 10,000 个虚拟 DOM 节点。浏览器的布局引擎然后必须为 10,000 个 DOM 节点计算位置。结果是 UI 冻结和用户沮丧。

虚拟滚动通过只渲染视口中可见的项以及上方和下方的小缓冲区来解决这个问题。当用户滚动时,视口外的项被卸载,新的项被挂载。DOM 保持很小——通常 20-30 个节点,不管列表大小——并且 React 的协调成本保持不变。

import { useVirtualizer } from "@tanstack/react-virtual";
import { useRef } from "react";

function VirtualList({ items }: { items: Item[] }) {
  const parentRef = useRef<HTMLDivElement>(null);

  const virtualizer = useVirtualizer({
    count: items.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 48, // estimated row height
    overscan: 5, // render 5 extra items off-screen
  });

  return (
    <div ref={parentRef} style={{ height: "600px", overflow: "auto" }}>
      <div style={{ height: virtualizer.getTotalSize() }}>
        {virtualizer.getVirtualItems().map((virtualItem) => (
          <div
            key={virtualItem.key}
            style={{
              position: "absolute",
              top: 0,
              transform: `translateY(${virtualItem.start}px)`,
              height: virtualItem.size,
              width: "100%",
            }}
          >
            <ItemRenderer item={items[virtualItem.index]} />
          </div>
        ))}
      </div>
    </div>
  );
}

// Only ~25-30 rows are rendered regardless of list size.
// 100,000 items? Same render cost.

除了虚拟滚动之外,列表性能取决于稳定的 key。使用数组索引作为 key 会导致 React 误识别哪些项发生了变化,导致不必要的 DOM 节点卸载和重新挂载。使用数据中的唯一标识符——项的 ID——作为 key。这让 React 在项被重新排序、插入或删除时重用 DOM 节点,这比销毁和重新创建它们要便宜得多。

  • 对任何可能超过 200-500 项的列表使用虚拟滚动。阈值取决于项复杂度,但 500 行中等复杂度的项是你开始注意到卡顿的地方。
  • 始终使用从数据派生的稳定、唯一的 key(项 ID)。索引键是永不变更的静态列表的最后手段。
  • 保持列表项组件精简。每个项应该是一个简单的展示组件,具有最少的钩子调用。列表项内的昂贵计算会在每个渲染的行上倍增。
  • 考虑对不需要同时显示所有项的列表进行分页。有时最好的优化就是根本不渲染某些东西。

性能分析、包分析和 React 19+ 模式

最重要的性能工具不是库或钩子。而是 React DevTools 性能分析器。在优化任何东西之前,在复现慢速行为时录制一个性能分析会话。分析器精确显示哪些组件渲染了、它们为什么渲染(prop 变更、state 变更、context 变更、父组件重新渲染)以及每次渲染花了多长时间。没有这些数据,你就是在猜测。

包分析是第二个必不可少的工具。一个发送 800 KB JavaScript 的快速 React 应用仍然很慢,因为浏览器必须下载和解析它。像 vite-bundle-visualizer、statoscope 或 source-map-explorer 这样的工具会生成包的视觉化表示,显示哪些包贡献了最多的字节。结果往往令人惊讶——一个单一的大的依赖(moment.js、一个图表库、一个图标集)可能占到你包的一半。

React 19 引入了改变性能优化格局的模式。服务端组件在服务器上运行并只将渲染后的 HTML 发送到客户端,消除了不需要交互性的组件的客户端 JavaScript。这不是一个小众优化——任何获取数据并渲染它而没有客户端状态或事件处理器的组件都应该是一个服务端组件。本来会发送给客户端的 JavaScript 变成了零。

服务端组件和客户端组件之间的区别创造了一个新的性能心智模型。在求助于 memo 之前,问:这个组件真的需要在客户端运行吗?如果答案是否定的,你就消除了每个重新渲染成本、每个水合成本以及该组件本来会贡献给包的每个 JavaScript 字节。服务端组件不是 React 客户端优化工具的替代方案。它们是一个更高层次的策略,使对你组件树的一大部分来说客户端的优化变得不再必要。

图片优化是另一个框架处理开发者以前手动做的事情的领域。Next.js 的 Image 组件自动以正确的大小和格式提供响应式图片,懒加载首屏下方的图片,并通过在图片加载前预留空间来防止布局偏移。如果你还在使用普通的 img 标签加载大 JPEG 文件,这个单一更改通常会产生比任何记忆化策略更大的性能改进。

  • 在做出任何更改之前,在应用中最慢的交互上运行 React DevTools 分析器。保存录制作为基线。
  • 使用 vite-bundle-visualizer 或 source-map-explorer 分析你的包。寻找可以替换为更轻量级替代品的大型依赖。
  • 如果你使用 React 19 并有一个服务端框架,将数据获取组件转换为服务端组件。这消除了它们全部的客户端成本。
  • 将 img 标签替换为框架的 Image 组件(next/image 等),以获得自动响应式图片和懒加载。
  • 使用 Chrome DevTools 的性能标签来测量交互延迟、布局偏移和长任务——而不仅仅是 React 渲染时间。

整合起来

React 性能优化遵循一个清晰的层次结构。顶层是架构:在服务器上运行的服务端组件、保持包小的代码分割、以及限制 DOM 大小的虚拟滚动。中间层是有针对性的记忆化:memo 用于不必要重新渲染的叶子组件,useMemo 用于昂贵计算,useCallback 用于稳定的回调引用。底层——而这是大多数人开始的地方——是微优化:内联函数、对象引用和过早的包裹,这些增加了复杂性而没有可衡量的好处。

从上到下工作。先分析,然后检查你的架构,然后应用有针对性的记忆化。不要从微优化开始。一个优化良好的 React 应用和一个优化不良的 React 应用之间的区别几乎从来不在于你是否在正确的地方使用了 memo。而在于你的架构是否让 React 高效地完成其工作——小的组件树、小的包、小的 DOM 大小和清晰的数据流。

本指南中的技术是全面的,但原则很简单:通过给 React 更少的工作做来使它的默认行为变快。渲染更少的组件。发送更少的 JavaScript。计算更少的值。你要求 React 做的越少,它就越快——而你也就越不需要考虑性能问题。