React 基础体系 · 第 15/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。

React 性能优化:Profiler、Memo、更新边界、长列表和 Bundle

React 性能优化的核心不是“让每个组件都更快”,而是减少不必要的工作,并让必要的工作出现在正确的时间、正确的边界内。

一个更新通常包含以下几类成本:

  1. 触发更新:状态、属性、Context 或外部 store 发生变化。
  2. Render 阶段:React 调用组件函数,生成新的 React 元素树,并通过 Fiber 计算差异。
  3. Commit 阶段:React 将必要的 DOM 变化提交到浏览器,并执行相关副作用。
  4. 浏览器阶段:样式计算、布局、绘制、合成,以及 JavaScript、网络和内存带来的额外成本。
  5. 初始加载成本:下载、解析、编译和执行 JavaScript,以及服务端渲染后的 hydration。

因此,“页面慢”可能来自完全不同的问题:

  • 组件 Render 次数太多;
  • 每次 Render 的计算太重;
  • Commit 修改了过多 DOM;
  • 列表同时创建了数千个节点;
  • Bundle 太大,导致首次交互延迟;
  • 服务端输出很快,但客户端 hydration 仍然需要执行大量代码;
  • 并发更新、Suspense 或 Transition 使用不当,导致用户感知到等待或输入延迟。

Profiler 用于确认问题在哪里,memo 和更新边界用于减少传播范围,长列表技术用于减少同时存在的节点,Bundle 优化用于减少客户端必须下载和执行的代码。它们解决的是不同层次的问题,不能互相替代。


一、先建立正确的性能模型:Render 不等于 DOM 更新

1. React 的 Render 阶段

当组件更新时,React 会重新执行一部分组件函数。例如:

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

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

点击按钮后,React 至少需要重新执行 Counter,得到新的元素:

<button>1</button>

然后 React 会比较更新前后的结构,发现只有文本内容从 0 变成 1,最终只修改对应的 DOM 文本节点。

因此:

  • 组件函数被调用,表示发生了 Render 工作;
  • DOM 被修改,表示 Commit 阶段产生了实际宿主环境变化;
  • Render 发生,不代表整个子树都被重新创建成 DOM;
  • DOM 没有变化,也不代表 Render 阶段没有成本。

一个常见误判是:

“这个组件每次都重新执行,所以 DOM 一定被重新渲染了。”

实际上,React 的 Reconciliation 会尽量复用已有 Fiber 和 DOM。性能问题可能出在组件函数内部昂贵的计算,而不一定出在 DOM 操作。

2. Render 和 Commit 的因果关系

可以把一次更新抽象成:

Tupdate=Ttrigger+Trender+Tcommit+TbrowserT_{\text{update}} = T_{\text{trigger}} + T_{\text{render}} + T_{\text{commit}} + T_{\text{browser}}

其中:

  • TtriggerT_{\text{trigger}}:事件处理、状态更新排队等成本;
  • TrenderT_{\text{render}}:组件执行、元素创建、Reconciliation 成本;
  • TcommitT_{\text{commit}}:DOM 属性、文本、事件和 Ref 等提交成本;
  • TbrowserT_{\text{browser}}:布局、绘制和合成成本。

memo 主要尝试降低 TrenderT_{\text{render}},并可能间接减少 TcommitT_{\text{commit}};虚拟列表主要减少 DOM 数量,影响 TrenderT_{\text{render}}TcommitT_{\text{commit}} 和浏览器布局成本;Bundle 优化主要影响初始加载和代码执行,不会自动解决一次更新中昂贵的计算。

3. “重新渲染”的准确含义

在 React 语境中,“重新渲染”通常指组件函数再次执行,或者 Fiber 子树再次参与工作。它不等于:

  • 重新下载代码;
  • 重新创建所有 DOM;
  • 浏览器整页刷新;
  • 所有子组件都必然产生实际 DOM 变化。

后文区分“组件是否执行”“Fiber 是否继续工作”和“DOM 是否提交”,否则很容易错误使用 memo 或误读 Profiler 数据。


二、Profiler:先测量,再决定优化方向

1. Profiler 测量什么

React 提供了 <Profiler> 组件,用于测量其子树在一次提交中的渲染表现:

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

const onRender: ProfilerOnRenderCallback = (
  id,
  phase,
  actualDuration,
  baseDuration,
  startTime,
  commitTime,
) => {
  console.table({
    id,
    phase,
    actualDuration,
    baseDuration,
    startTime,
    commitTime,
  });
};

export function App() {
  return (
    <Profiler id="product-page" onRender={onRender}>
      <ProductPage />
    </Profiler>
  );
}

回调中的关键参数含义如下:

  • id:当前 Profiler 的标识;
  • phase:通常是 "mount""update"
  • actualDuration:这次提交中实际渲染该子树所花的时间;
  • baseDuration:React 对该子树在没有优化性跳过时的基准渲染成本估计;
  • startTime:本次 React 渲染开始的时间;
  • commitTime:本次提交发生的时间。

actualDuration 不是整个页面从点击到可交互的总耗时,它主要反映 React 渲染阶段的成本。网络、事件处理、浏览器布局和绘制需要通过浏览器 Performance 工具等其他手段测量。

2. actualDurationbaseDuration 的关系

假设一个页面包含搜索框和 1000 个结果项:

function SearchPage({ query }: { query: string }) {
  const results = searchProducts(query);

  return (
    <>
      <SearchInput />
      <ResultList results={results} />
    </>
  );
}

在输入框更新时:

  • 如果结果列表也参与了大量 Render,actualDuration 可能较高;
  • 如果使用 memo 或拆分更新边界,结果列表被跳过,actualDuration 可能下降;
  • baseDuration 仍可能较高,因为它表达的是在缺少跳过时的估计基线。

所以:

优化收益优化前 actualDuration优化后 actualDuration\text{优化收益} \approx \text{优化前 actualDuration} - \text{优化后 actualDuration}

不能直接把 baseDuration 当成真实耗时,也不能只看某次偶然采样得出结论。

3. Profiler 的正确测量流程

一次有意义的测量需要固定输入和操作:

  1. 使用生产构建或接近生产的环境测试;
  2. 记录初始加载和交互更新,不要只测页面首次打开;
  3. 固定数据规模,例如 100、1000、10000 条;
  4. 重复同一操作,例如连续输入同样长度的查询词;
  5. 对比优化前后的 actualDuration、交互延迟和内存;
  6. 同时观察浏览器 Performance 面板中的 Long Task、布局和绘制。

开发环境中的 StrictMode 可能为了发现副作用问题而额外调用某些逻辑,因此开发环境中的调用次数和耗时不能直接当作生产数据。开发工具本身也会增加开销。

4. 用 Profiler 定位,而不是把它当成优化器

例如,Profiler 显示:

id             phase   actualDuration
product-page   update  18.4ms
result-list    update  16.9ms
search-input   update   0.2ms

这说明:

  • 输入框本身不是主要成本;
  • ResultList 是这次 React Render 的主要成本;
  • 下一步应查看结果列表是否因为输入变化而重新计算、是否创建了大量元素、是否有昂贵的排序或格式化;
  • 不能仅凭这些数据断言 DOM 提交或浏览器布局就是瓶颈。

如果浏览器 Performance 显示 React 只花了 3ms,但一次点击任务总计 80ms,剩余时间可能来自事件处理、JSON 解析、布局、第三方库或其他脚本。

5. 服务端和客户端的边界

React <Profiler> 测量的是 React 客户端渲染树中的工作。对于服务端渲染:

  • 服务端生成 HTML 的时间需要在服务端请求、渲染和日志系统中测量;
  • 客户端 hydration 的 React 工作可以通过浏览器 Performance 和客户端 Profiler 观察;
  • Server Component 在服务端执行,不能用浏览器中的 <Profiler> 测量其服务端执行耗时;
  • 浏览器中只存在发送到客户端的 Client Component 代码和最终客户端树的一部分。

因此,SSR 页面“首字节很快”不代表客户端已经完成 hydration,也不代表用户立即可以交互。


三、Memo:只有在“跳过成本低于重算成本”时才有价值

1. memo 的基本机制

memo 接收一个组件,返回一个具有属性比较能力的组件:

import { memo } from 'react';

type RowProps = {
  id: string;
  name: string;
  selected: boolean;
  onSelect: (id: string) => void;
};

const ProductRow = memo(function ProductRow({
  id,
  name,
  selected,
  onSelect,
}: RowProps) {
  console.log('render row', id);

  return (
    <button
      aria-pressed={selected}
      onClick={() => onSelect(id)}
    >
      {name}
    </button>
  );
});

当父组件重新 Render 时,React 会比较 ProductRow 的新旧 Props。如果所有 Props 都相等,React 可以跳过该组件的重新渲染。

默认比较不是深比较,而是逐个使用类似 Object.is 的浅层比较:

Object.is(previousProps.name, nextProps.name);
Object.is(previousProps.selected, nextProps.selected);
Object.is(previousProps.onSelect, nextProps.onSelect);

因此,下面两个对象内容相同,但引用不同:

const previous = { id: '1', name: 'Keyboard' };
const next = { id: '1', name: 'Keyboard' };

Object.is(previous, next); // false

如果每次父组件 Render 都创建新的对象或函数,memo 就可能无法跳过。

2. memo 的形式化条件

设组件本次重新计算的成本为 CrC_r,比较 Props 的成本为 CcC_c,组件实际更新的概率为 pp

使用 memo 的期望成本可以粗略写成:

Cmemo=Cc+p×CrC_{\text{memo}} = C_c + p \times C_r

不使用 memo 的成本是:

Cnormal=CrC_{\text{normal}} = C_r

只有当:

Cc+pCr<CrC_c + pC_r < C_r

也就是:

Cc<(1p)CrC_c < (1-p)C_r

时,memo 才可能带来收益。

直觉是:

  • 组件足够昂贵;
  • 父组件经常更新;
  • 子组件的 Props 经常保持相同;
  • 比较 Props 的成本明显低于重新计算组件。

对于只有一个简单 <span> 的组件,比较 Props 可能比直接执行组件还贵。对于包含复杂表格、图表或大量计算的稳定子树,memo 更可能有价值。

3. 让引用稳定:useCallbackuseMemo

以下代码中,onSelect 每次父组件 Render 都是新函数:

function ProductList({ products }: { products: Product[] }) {
  const [selectedId, setSelectedId] = useState<string | null>(null);

  return products.map((product) => (
    <ProductRow
      key={product.id}
      id={product.id}
      name={product.name}
      selected={product.id === selectedId}
      onSelect={(id) => setSelectedId(id)}
    />
  ));
}

即使 ProductRow 使用了 memoonSelect 也会导致每一行 Props 变化。

可以将回调稳定下来:

function ProductList({ products }: { products: Product[] }) {
  const [selectedId, setSelectedId] = useState<string | null>(null);

  const handleSelect = useCallback((id: string) => {
    setSelectedId(id);
  }, []);

  return products.map((product) => (
    <ProductRow
      key={product.id}
      id={product.id}
      name={product.name}
      selected={product.id === selectedId}
      onSelect={handleSelect}
    />
  ));
}

useCallback 的作用不是让函数执行更快,而是让函数引用在依赖不变时保持稳定。它本身也有依赖管理和缓存成本,因此不应机械地包裹所有函数。

同样,以下对象每次都会产生新引用:

<ProductRow
  options={{ dense: true, color: 'blue' }}
/>

如果对象实际代表稳定配置,可以使用:

const options = useMemo(
  () => ({ dense: true, color: 'blue' }),
  [],
);

但更简单的方式通常是直接传递原始值:

<ProductRow dense color="blue" />

4. memo 不是组件内部状态的缓存

const Panel = memo(function Panel({ title }: { title: string }) {
  const [open, setOpen] = useState(false);

  return (
    <section>
      <h2>{title}</h2>
      <button onClick={() => setOpen((value) => !value)}>
        {open ? '关闭' : '打开'}
      </button>
    </section>
  );
});

Panel 自己的 open 状态更新时,它仍然会重新 Render。memo 只用于父级传入 Props 没有变化时跳过来自父级的更新传播,不能阻止组件自身状态更新。

Context 也是类似边界:

const ThemeContext = createContext<'light' | 'dark'>('light');

const Button = memo(function Button() {
  const theme = useContext(ThemeContext);
  return <button data-theme={theme}>保存</button>;
});

即使 Button 没有 Props,只要它读取的 Context 值变化,它仍然需要更新。memo 不能屏蔽组件实际读取的 Context 变化。

5. children 是常见的 memo 失效来源

const Card = memo(function Card({
  children,
}: {
  children: React.ReactNode;
}) {
  return <div className="card">{children}</div>;
});

function Page() {
  return (
    <Card>
      <ExpensiveContent />
    </Card>
  );
}

<ExpensiveContent /> 每次父组件执行时都会产生新的 React 元素对象,因此 children Props 的引用通常不同。此时 Cardmemo 可能无法跳过。

这不表示 children 用法错误,而是说明:

  • React 元素也是对象;
  • Props 比较默认比较引用;
  • 稳定引用必须有明确的收益场景;
  • 不要为了让每个 children 引用稳定而增加复杂缓存。

6. 自定义比较函数的风险

可以传入第二个参数:

const UserRow = memo(
  function UserRow({ user }: { user: User }) {
    return <div>{user.name}</div>;
  },
  (prev, next) => prev.user.id === next.user.id,
);

这段代码只有在“用户 ID 相同就代表该行所有显示内容都相同”时才正确。如果 user.name 可能变化,而比较函数仍返回 true,界面就会显示旧数据。

一个正确的比较函数必须满足:

compare(Pold,Pnew)=true组件输出和相关行为可以保持不变\text{compare}(P_{\text{old}}, P_{\text{new}}) = \text{true} \Rightarrow \text{组件输出和相关行为可以保持不变}

它不是“对象 ID 相同就跳过”的通用优化。比较函数本身如果做深比较,可能在数据较大时比直接 Render 更慢;如果比较函数读取可变对象,还会隐藏状态变化,造成陈旧 UI。

7. React Compiler 与手写 Memo

React 生态正在提供编译器能力,用于在满足条件时自动进行部分记忆化优化。是否启用、支持哪些语法、构建工具如何配置,取决于具体 React 版本和框架配置,不能假设所有项目都默认开启。

即使使用编译器,也仍然需要理解:

  • 不可变数据和稳定数据流;
  • 纯组件;
  • 正确的 Effect 依赖;
  • Context 传播;
  • 列表 Key;
  • Bundle 和服务器边界。

编译器可以减少手写 memouseMemouseCallback 的数量,但不会把一个错误的深度比较函数变正确,也不会把 10000 个 DOM 节点变成 30 个可见节点。


四、更新边界:性能优化的真正单位是“状态传播范围”

1. 什么是更新边界

更新边界是指一次状态变化能够影响到的组件子树范围。

例如:

function Page() {
  const [query, setQuery] = useState('');

  return (
    <>
      <SearchInput value={query} onChange={setQuery} />
      <SearchResults query={query} />
      <Footer />
    </>
  );
}

query 位于 Page,所以输入时 Page 会更新,两个子树都有机会参与 Render。即使 Footer 没有使用 query,它仍可能被父组件更新传播。

把状态下移:

function SearchArea() {
  const [query, setQuery] = useState('');

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

function Page() {
  return (
    <>
      <SearchArea />
      <Footer />
    </>
  );
}

现在输入只触发 SearchArea 子树更新,Footer 位于更新边界之外。

状态下移通常比“给所有子组件加 memo”更直接,因为它从数据流层面减少了需要参与更新的 Fiber 数量。

2. 状态提升与状态下移的取舍

状态应放在所有需要共享它的组件的最近公共祖先,但不应为了方便而提升到过高层级。

错误倾向:

function App() {
  const [isTooltipOpen, setTooltipOpen] = useState(false);
  const [search, setSearch] = useState('');
  const [selectedTab, setSelectedTab] = useState('all');
  const [modal, setModal] = useState<ModalState>(null);

  return <EntireApplication />;
}

如果这些状态只被局部区域使用,把它们集中放在顶层会扩大每次更新的传播范围。

更合理的拆分是:

  • 搜索状态放在搜索区域;
  • Tab 状态放在 Tab 区域;
  • Tooltip 状态放在 Tooltip 所属组件;
  • 真正跨页面共享的状态才放入更高层或外部 store。

这不是“状态越低越好”,而是让状态的作用域与业务依赖一致。

3. Context 是隐式的更新广播

Context 方便跨层传值,但 Provider 的 value 改变时,读取该 Context 的组件都可能更新:

function App() {
  const [user, setUser] = useState<User | null>(null);
  const [theme, setTheme] = useState<'light' | 'dark'>('light');

  const value = { user, theme, setTheme };

  return (
    <AppContext.Provider value={value}>
      <Routes />
    </AppContext.Provider>
  );
}

每次 App Render 时,value 都是新对象。即使 usertheme 没变,Context value 的引用也变了。

可以先稳定对象:

const value = useMemo(
  () => ({ user, theme, setTheme }),
  [user, theme],
);

但这只解决 Provider value 引用变化,不能改变“所有读取该 Context 的组件都依赖这个整体对象”的事实。

更有效的拆分通常是多个 Context:

<AuthContext.Provider value={authValue}>
  <ThemeContext.Provider value={themeValue}>
    <Routes />
  </ThemeContext.Provider>
</AuthContext.Provider>

如果某组件只读取主题,就不必因为用户信息变化而参与同一个 Context 的更新。

需要注意,memo 不能阻止组件读取的 Context 变化。若希望细粒度订阅,应使用拆分 Context、选择器型 store,或把 Context 读取放在外层,再把稳定的原始值传给 memo 子组件。

4. key 是列表更新边界的一部分

列表的 key 用于描述“这个位置上的元素对应哪个逻辑实体”:

items.map((item) => (
  <Row key={item.id} item={item} />
));

使用索引作为 Key:

items.map((item, index) => (
  <Row key={index} item={item} />
));

在列表头部插入、删除或排序时,会导致旧 Fiber 与新数据错误对应。后果不只是不必要的渲染,还可能包括:

  • 输入框状态出现在错误行;
  • 动画状态错位;
  • 组件本地状态跟随位置而不是实体;
  • React 无法正确复用对应子树。

Key 不要求全局唯一,只要求在同一个兄弟列表中稳定且能标识实体。它也不是为了“让 React 更快”而随意生成的随机值:

key={Math.random()}

随机 Key 会让每次 Render 都像卸载并重新挂载,破坏 DOM、状态和缓存复用。


五、并发更新与更新边界:让输入保持可响应

React 的并发能力允许某些更新被推迟、打断或重新开始,但它不会减少业务计算本身。它主要改变工作调度和用户感知。

1. useTransition:标记非紧急更新

搜索场景中,输入框的值应立即更新,而结果列表可以作为较低优先级更新:

import { useState, useTransition } from 'react';

function SearchBox({
  onChange,
}: {
  onChange: (value: string) => void;
}) {
  const [text, setText] = useState('');
  const [isPending, startTransition] = useTransition();

  function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
    const nextText = event.target.value;

    setText(nextText);

    startTransition(() => {
      onChange(nextText);
    });
  }

  return (
    <>
      <input value={text} onChange={handleChange} />
      {isPending && <span>更新结果中…</span>}
    </>
  );
}

这里有两个逻辑:

  1. setText 是紧急更新,保证输入框跟手;
  2. onChange 触发的结果更新被标记为 Transition,React 可以在更高优先级输入到来时优先处理输入。

Transition 不是后台线程,也不会自动把同步计算移出主线程。如果 onChange 内部同步执行了一个巨大的排序,仍可能阻塞 JavaScript 主线程。此时需要进一步减少计算、使用 Web Worker,或采用服务端查询。

2. useDeferredValue:延后读取某个值

也可以保留即时输入值,再产生一个延迟使用的值:

function SearchPage() {
  const [query, setQuery] = useState('');
  const deferredQuery = useDeferredValue(query);

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

在输入快速变化时:

  • 输入框使用最新的 query
  • 结果区域可以暂时使用旧的 deferredQuery
  • React 在合适时机追上最新结果。

这会产生短暂的“输入内容已变化,但结果还没跟上”的状态,因此结果区域应提供合适的加载或过渡视觉反馈。

3. 一致性要求

并发渲染可能在 Commit 前多次计算或放弃某次计算,因此 Render 阶段必须保持纯净:

function Price({ amount }: { amount: number }) {
  // 适合:根据输入计算结果
  const formatted = amount.toFixed(2);

  return <span>{formatted}</span>;
}

不要在 Render 阶段执行不可逆副作用:

let requestCount = 0;

function BadComponent() {
  requestCount += 1; // Render 可能重复或被放弃,不应依赖这里发生一次
  return null;
}

网络请求、订阅、DOM 操作等副作用应放在合适的 Effect、事件处理器或框架的数据加载机制中。否则并发 Render、StrictMode 检查或错误重试都可能造成重复副作用。


六、长列表:减少“同时存在”的工作,而不是只优化每一行

1. 为什么 10000 个简单节点也会变慢

假设列表有 NN 条数据,每一行平均产生 kk 个 React 元素和 dd 个 DOM 节点,那么初始工作量可以近似理解为:

WN×(Crow-render+kCelement+dCdom)W \approx N \times (C_{\text{row-render}} + kC_{\text{element}} + dC_{\text{dom}})

N=10000N = 10000 时,即使每行很简单,总工作仍然线性增长。滚动时还可能出现:

  • 大量 DOM 节点参与布局;
  • 内存占用增加;
  • 样式匹配成本增加;
  • 更新时需要比较更多 Fiber;
  • 屏幕阅读器或查找功能面对非常大的 DOM 树。

给每一行加 memo 只能减少部分更新成本,不能减少首次创建 10000 行的成本。

2. 虚拟化的核心思想

虚拟列表只渲染视口附近的窗口:

总数据:        [0 ... 99999]
可视窗口:              [320 ... 345]
额外缓冲区:          [300 ... 365]
实际 DOM:             只创建约 66 行

可见范围通常根据滚动位置 ss、视口高度 HH、行高 hh 计算:

istart=shi_{\text{start}} = \left\lfloor \frac{s}{h} \right\rfloor

iend=min(N1,s+Hh)i_{\text{end}} = \min\left( N - 1, \left\lceil \frac{s + H}{h} \right\rceil \right)

其中:

  • ss:滚动容器的垂直滚动距离;
  • HH:容器可视高度;
  • hh:固定行高;
  • NN:数据总数。

实际实现通常还会增加 overscan,避免快速滚动时出现空白。

3. 一个固定高度的可运行示例

下面的示例不依赖第三方库,展示虚拟列表的基本机制:

import {
  useEffect,
  useMemo,
  useState,
} from 'react';

type Item = {
  id: string;
  name: string;
};

type VirtualListProps = {
  items: Item[];
  height: number;
  rowHeight: number;
  overscan?: number;
};

export function VirtualList({
  items,
  height,
  rowHeight,
  overscan = 4,
}: VirtualListProps) {
  const [scrollTop, setScrollTop] = useState(0);

  const range = useMemo(() => {
    const firstVisible = Math.floor(scrollTop / rowHeight);
    const visibleCount = Math.ceil(height / rowHeight);

    const start = Math.max(0, firstVisible - overscan);
    const end = Math.min(
      items.length,
      firstVisible + visibleCount + overscan,
    );

    return { start, end };
  }, [height, items.length, overscan, rowHeight, scrollTop]);

  const visibleItems = items.slice(range.start, range.end);

  return (
    <div
      style={{
        height,
        overflowY: 'auto',
        position: 'relative',
      }}
      onScroll={(event) => {
        setScrollTop(event.currentTarget.scrollTop);
      }}
    >
      <div
        style={{
          height: items.length * rowHeight,
          position: 'relative',
        }}
      >
        {visibleItems.map((item, offset) => {
          const index = range.start + offset;

          return (
            <div
              key={item.id}
              style={{
                height: rowHeight,
                left: 0,
                position: 'absolute',
                right: 0,
                top: index * rowHeight,
              }}
            >
              {item.name}
            </div>
          );
        })}
      </div>
    </div>
  );
}

使用方式:

const items: Item[] = Array.from(
  { length: 100_000 },
  (_, index) => ({
    id: `item-${index}`,
    name: `Item ${index}`,
  }),
);

export function Demo() {
  return (
    <VirtualList
      items={items}
      height={480}
      rowHeight={36}
      overscan={6}
    />
  );
}

每一步成立的原因是:

  1. 外层容器固定可视高度并允许滚动;
  2. 内层容器使用总高度,保持滚动条比例正确;
  3. 可见行使用绝对定位,位置由全量索引乘以行高决定;
  4. React 只为 [start, end) 范围创建元素;
  5. key 使用实体 ID,使数据与 Fiber 的对应关系稳定;
  6. 滚动时只更新 scrollTop,重新计算窗口。

这个示例的前置条件是每行高度固定。如果行高不固定,index * rowHeight 不成立,需要测量实际高度并维护前缀高度或使用成熟的动态虚拟化方案。

4. 虚拟化的真实边界

虚拟化并非无条件适用:

  • 浏览器查找只能找到当前挂载的节点;
  • 需要完整 DOM 的导出、打印和无障碍场景需要额外处理;
  • 锚定滚动位置可能在动态高度变化时跳动;
  • 行内输入框、焦点和本地状态需要稳定 Key;
  • 服务端渲染只能预渲染一个初始窗口,客户端滚动后再补充;
  • 容器高度为 auto 时,计算可见范围更复杂;
  • 快速滚动时 overscan 太小会出现白屏,太大则失去虚拟化收益。

可访问性方面,虚拟列表应正确表达列表语义和位置,例如必要时使用 aria-setsizearia-posinset,并确保键盘焦点不会因为节点被回收而丢失。具体属性需要结合组件角色和屏幕阅读器测试,不能只添加属性而不验证实际交互。

5. 分页、无限滚动和虚拟化不是同一件事

这三者解决不同问题:

  • 分页:减少一次从服务端获取的数据量和客户端状态量;
  • 无限滚动:按需继续获取数据;
  • 虚拟化:即使客户端已有大量数据,也只挂载可见窗口。

一个生产列表可能同时使用三者:

服务端分页获取 100 条
        ↓
客户端累计 5000 条
        ↓
虚拟列表只挂载当前约 40 条

如果数据仍在服务端,虚拟化不能减少网络传输;如果一次已经把百万条数据放进内存,虚拟化也不能解决 JSON 解析和内存占用问题。


七、更新边界与长列表结合:避免滚动和选择状态互相传播

列表性能通常同时受三类更新影响:

  1. 滚动位置变化;
  2. 查询条件变化;
  3. 单行选中、编辑或悬停变化。

如果所有状态都放在列表顶层:

function ListPage() {
  const [scrollTop, setScrollTop] = useState(0);
  const [selectedId, setSelectedId] = useState<string | null>(null);
  const [query, setQuery] = useState('');

  // 所有状态变化都可能让整个列表父组件重新执行
}

可以按照职责拆分:

  • 滚动状态只由虚拟列表内部管理;
  • 查询状态由搜索区域管理;
  • 选中状态由列表容器管理;
  • 行组件使用 memo,只让受影响行更新。
type RowProps = {
  item: Item;
  selected: boolean;
  onSelect: (id: string) => void;
};

const Row = memo(function Row({
  item,
  selected,
  onSelect,
}: RowProps) {
  return (
    <button
      aria-pressed={selected}
      onClick={() => onSelect(item.id)}
    >
      {item.name}
    </button>
  );
});

如果选中一个新 ID,通常只有旧选中行和新选中行的 selected 值发生变化,其他行的 Props 可以保持稳定。这个收益依赖于:

  • 行使用稳定的实体 Key;
  • items 中未变化的对象保持引用;
  • onSelect 引用稳定;
  • 没有一个每次都变化的整体对象作为 Row Props。

八、Bundle:性能不只发生在 Render 之后

1. Bundle 成本由哪些阶段组成

客户端 JavaScript 的实际成本可以分解为:

TJS=Tdownload+Tparse+Tcompile+Texecute+ThydrateT_{\text{JS}} = T_{\text{download}} + T_{\text{parse}} + T_{\text{compile}} + T_{\text{execute}} + T_{\text{hydrate}}

其中:

  • download:网络下载;
  • parse:解析 JavaScript;
  • compile:引擎编译;
  • execute:模块初始化和业务代码执行;
  • hydrate:将服务端 HTML 与客户端 React 逻辑连接起来。

压缩后体积只影响下载的一部分。一个压缩文件较小但包含大量初始化代码,仍可能造成执行和 hydration 延迟。

2. Tree Shaking 的成立条件

Tree Shaking 依赖静态模块依赖关系和构建工具的分析。例如:

// math.ts
export function add(a: number, b: number) {
  return a + b;
}

export function expensiveChartRuntime() {
  // ...
}
import { add } from './math';

console.log(add(1, 2));

在模块是 ESM、构建工具能分析副作用的前提下,未使用的导出通常有机会被删除。

但以下写法可能增加分析难度:

const moduleName = './math';
const module = require(moduleName);

或者包声明了不准确的副作用信息。不能仅凭“使用了 ESM”就保证最终文件一定最小,必须通过构建产物分析验证。

常见影响 Bundle 的因素包括:

  • 从大包入口导入单个功能,但包没有良好拆分;
  • 引入仅在一个页面使用的编辑器、图表或日期库;
  • Polyfill 和国际化数据全部打入主包;
  • CSS、字体和 WebAssembly 被忽略;
  • CommonJS 依赖阻碍 Tree Shaking;
  • sideEffects 配置错误导致必要副作用被误删。

3. Code Splitting 与 React.lazy

不应把只在某个低频页面使用的代码放入首屏主 Bundle:

import { lazy, Suspense } from 'react';

const AnalyticsChart = lazy(
  () => import('./AnalyticsChart'),
);

export function AnalyticsPage() {
  return (
    <Suspense fallback={<p>图表加载中…</p>}>
      <AnalyticsChart />
    </Suspense>
  );
}

这里的过程是:

  1. 首屏 Bundle 不必同步包含 AnalyticsChart 的完整实现;
  2. 第一次渲染到 AnalyticsChart 时,动态 import 返回 Promise;
  3. Lazy 组件在模块加载期间暂停;
  4. 最近的 Suspense 显示 fallback
  5. 模块加载完成后,React 继续渲染并提交真实图表。

代码分割并不等于“免费变快”。它会产生新的网络请求和加载边界。如果用户几乎必然会访问图表,过度拆分可能导致点击后才开始下载,反而增加等待。应根据路由、用户行为和网络情况决定分割粒度。

错误边界也必须与懒加载配合:

<ErrorBoundary fallback={<p>图表加载失败,请重试。</p>}>
  <Suspense fallback={<p>图表加载中…</p>}>
    <AnalyticsChart />
  </Suspense>
</ErrorBoundary>

Suspense 负责“还在等待”,Error Boundary 负责“加载或渲染失败”。网络失败、模块执行错误和组件内部异常不能用同一个 Loading 状态掩盖。

4. Suspense 不是通用数据缓存

Suspense 可以协调懒加载、框架提供的数据加载能力以及其他符合 Suspense 协议的资源,但单独把普通 Promise 放进组件 Render 中并不能自动形成正确的生产级缓存:

function Bad() {
  const data = fetch('/api/data').then((response) => response.json());
  // 每次 Render 都可能创建新的 Promise
  return null;
}

如果 Promise 没有稳定缓存,重新 Render 可能重复发起请求,甚至造成无限暂停。数据获取应使用框架的数据加载机制或明确实现资源缓存,并处理取消、错误和重试。

5. 路由级拆分通常是第一层边界

一个典型应用可以按照路由拆包:

主 Bundle
├── App Shell
├── 路由基础设施
└── 登录页必要代码

按需 Chunk
├── Dashboard
├── Editor
├── AnalyticsChart
└── Admin

路由级拆分的优势在于边界天然清晰。用户访问 /login 时,不必下载管理后台的编辑器和图表运行时。

组件级拆分适合大型、低频或条件性功能,例如:

  • 富文本编辑器;
  • 图表库;
  • 地图 SDK;
  • PDF 预览;
  • 管理员专属面板。

但首屏关键组件不应为了追求更小的初始 Bundle 而被拆到点击后才加载。性能目标是降低用户完成任务的总成本,而不是单独追求某个 JS 文件的最小体积。


九、客户端与服务端边界:减少 Bundle 的最有效方式是“不发送”

1. Server Component 与 Client Component 的区别

在支持 React Server Components 的框架中,Server Component 在服务端执行,其代码通常不需要发送到浏览器。Client Component 则需要发送到客户端,以支持:

  • useState
  • useEffect
  • 浏览器事件;
  • 访问浏览器 API;
  • 交互式 UI。

因此,服务端边界不仅影响 Render 位置,也影响 Bundle 组成。

例如,产品详情页可以让服务端负责:

  • 获取产品数据;
  • 生成静态展示内容;
  • 执行数据库查询或服务端格式化。

只有购买按钮、数量选择器等交互部分放入 Client Component。

概念结构如下:

Server ProductPage
├── Server ProductDetails
└── Client AddToCartButton

如果在顶层 Client Component 中导入整个页面树,即使其中一些组件没有交互,它们的依赖也可能进入客户端依赖图。边界应尽可能放在真正需要浏览器能力的位置,而不是把整个页面标记为客户端组件。

2. 服务端执行不等于客户端零成本

即使展示内容由服务端生成,客户端仍可能需要:

  • 下载 Client Component 及其依赖;
  • 对服务端输出进行 hydration;
  • 建立事件处理;
  • 加载交互所需的懒加载模块。

因此需要同时观察:

  • HTML 首字节和完整响应时间;
  • 初始 JavaScript 体积;
  • hydration 任务时长;
  • 首次输入延迟;
  • 交互组件是否在可用时及时加载。

3. Hydration 不匹配的性能和正确性风险

服务端和客户端第一次渲染应生成一致的结构。以下写法容易产生不一致:

function Time() {
  return <span>{new Date().toLocaleString()}</span>;
}

服务端和客户端的时区、时间点或语言环境可能不同,导致 hydration mismatch。随机数、依赖 window 的初始输出也有类似问题。

更稳妥的方式是:

  • 服务端生成确定值并传给客户端;
  • 将仅客户端可知的内容延迟到合适的客户端阶段;
  • 使用框架提供的 hydration 安全方案;
  • 不把 mismatch 警告当作普通日志忽略,因为它可能导致额外修复工作或交互异常。

十、常见失败方案:看似优化,实际扩大成本

1. 给所有组件加 memo

失败原因:

  • 很多组件 Props 每次都变化;
  • 组件本身很便宜;
  • Context 或自身状态仍然会触发更新;
  • 比较函数增加了额外成本;
  • 代码可读性和依赖管理复杂度上升。

正确做法是先通过 Profiler 找出频繁更新且计算昂贵的稳定子树,再建立有证据的边界。

2. 用 useMemo 包裹所有计算

const value = useMemo(() => a + b, [a, b]);

如果 a + b 的成本远小于 Hook 依赖比较和缓存维护成本,这种写法没有实际收益。useMemo 更适合:

  • 昂贵且纯的计算;
  • 计算结果作为稳定引用传给 memo 子组件;
  • 计算结果作为依赖,且稳定引用能避免下游大量工作。

它不是语义正确性工具。代码必须在没有缓存时也正确。

3. 通过随机 Key 强制刷新

<Component key={Date.now()} />

这会让 React 将其视为新实体,触发卸载和重新挂载。它可能暂时“清空状态”或绕过旧状态,但会增加 DOM、Effect 和资源成本,并隐藏真正的状态设计问题。

如果确实需要重置组件状态,应使用表达业务实体变化的稳定 Key,并明确知道重挂载会触发什么生命周期行为。

4. 虚拟化后仍然把全部数据同步加工

const rows = hugeData
  .sort(expensiveComparator)
  .map(expensiveTransform);

return <VirtualList items={rows} />;

虚拟化只减少渲染窗口,不会自动减少 sortmap 的成本。若每次输入都重新处理百万条数据,主要瓶颈仍在数据计算阶段。

应考虑:

  • 服务端筛选和排序;
  • 对纯计算结果做有证据的缓存;
  • 增量计算;
  • Web Worker;
  • 分页后再虚拟化;
  • 避免在输入的高优先级更新中同步执行大计算。

5. 只看 Bundle 大小,不看执行和交互

某个 Bundle 从 500 KB 降到 400 KB,不一定意味着用户体验按比例改善。需要验证:

  • 主线程解析和执行时间;
  • 首次输入延迟;
  • hydration 时间;
  • 用户真正访问的路由是否更快;
  • 动态 Chunk 是否在关键交互时才开始下载;
  • 缓存命中和网络失败表现。

十一、一个完整的诊断路径

面对“输入卡顿、列表慢、首屏慢”时,可以按因果路径排查。

场景一:输入框卡顿

  1. 浏览器 Performance 查看一次输入事件是否形成 Long Task;
  2. React Profiler 查看输入引起的哪些子树更新;
  3. 判断是:
    • 大量组件 Render;
    • 单个组件计算昂贵;
    • 大量 DOM Commit;
    • 浏览器布局或绘制;
  4. 如果结果区域不是紧急反馈,使用 useTransitionuseDeferredValue
  5. 如果列表节点数量很大,使用虚拟化;
  6. 如果查询计算本身很重,考虑服务端查询或 Worker;
  7. 再用同样的数据规模重复测量。

场景二:列表首屏慢

  1. 统计列表数据量和最终 DOM 节点数;
  2. 用 Profiler 测量初始 mount 的 React 成本;
  3. 用 Performance 查看脚本执行、布局和绘制;
  4. 若所有节点同时挂载,分页、按需加载或虚拟化;
  5. 若单行计算昂贵,优化行组件和数据预处理;
  6. 若代码只有这个页面需要,考虑路由级或组件级拆包;
  7. 检查服务端渲染和 hydration 是否重复承担大量工作。

场景三:首屏代码很大

  1. 生成生产构建;
  2. 查看 Bundle 分析报告,按模块和路由确认体积来源;
  3. 检查是否把低频依赖放入主入口;
  4. 检查导入方式和包的 ESM、side effects 配置;
  5. 使用动态 import 拆分低频功能;
  6. 在支持 Server Components 的框架中,将纯展示和服务端数据访问留在服务端;
  7. 验证动态 Chunk 的 Loading、错误和重试路径。

十二、生产取舍:优化目标必须和用户任务一致

性能优化不是单一数字竞赛,而是不同成本之间的取舍:

  • memo 减少计算,但增加 Props 稳定性要求;
  • useMemo 减少重复计算,但增加缓存和依赖复杂度;
  • 虚拟化减少 DOM,但增加焦点、测量、打印和无障碍复杂度;
  • Code Splitting 减少首屏代码,但增加请求和加载边界;
  • Server Component 减少客户端 Bundle,但要求明确客户端能力边界;
  • Transition 改善交互响应,但不减少底层计算总量;
  • 分页减少网络和内存,但改变用户浏览和搜索体验。

一个可靠的优化结论应至少包含:

问题:哪些操作慢
证据:Profiler / Performance / Bundle 分析结果
原因:具体是哪类 Render、Commit、脚本或网络成本
方案:改变了哪个更新边界或加载边界
验证:相同输入、相同数据规模下是否改善
风险:状态、焦点、错误、SSR、缓存或可访问性是否受影响

最终可以用一个简化判断来选择工具:

观测到的问题 优先考虑
子树频繁更新且 Props 稳定 memo、稳定引用、拆分状态
状态变化传播范围太大 状态下移、拆分组件、拆分 Context
单次计算本身很重 算法、缓存、Worker、服务端计算
同时存在的列表节点太多 分页、无限加载、虚拟化
首屏 JavaScript 太大 Tree Shaking、路由拆包、动态 import
纯展示代码被发送到浏览器 Server/Client 边界调整
输入被结果更新阻塞 Transition、Deferred Value、减少同步工作
DOM 少但页面仍然卡 浏览器布局、绘制、第三方脚本或主线程任务

Profiler 告诉你 React 做了什么,更新边界决定 React 应该影响多大范围,Memo 决定哪些稳定子树可以跳过,虚拟化决定页面同时保留多少节点,Bundle 和服务端边界决定浏览器一开始必须下载和执行多少代码。只有把这些层次分开测量,优化才不会从“减少工作”变成“增加复杂度”。


系列导航与关联阅读

官方资料

本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。