React 渲染性能优化:别一上来就到处加 memo

React 性能问题最容易被误解的地方,是大家总想找一个“开关”。

页面卡了,就加 React.memo;列表慢了,就加 useMemo;函数传下去导致子组件刷新,就加 useCallback。这些手段都能用,但如果不知道渲染为什么发生,最后很容易把代码写成一堆缓存和依赖数组,性能没明显变好,维护成本倒是先涨上来了。

我以前也这么干过。一个管理后台的配置页,左边是树,右边是表单,中间还有预览区域。用户在表单里输入一个字段,整页都抖一下,低配电脑上明显卡。我第一反应是给子组件全包 memo,结果只解决了一小部分。后来用 Profiler 一看,某个组件本身并不慢,是状态放得太高:一个输入框变化,整棵页面状态都更新,树、预览、工具栏全部跟着重新计算。

从那以后我对 React 性能的理解变得更朴素:先控制更新范围,再谈缓存。

先搞清楚一次更新影响了谁

一个组件为什么会重新渲染,掰开来看其实就那么几种触发源:它自己的 state 变了、它的父组件重新渲染了、它消费的 context 值变了、它订阅的外部 store 发了通知、或者它的 key 变了导致整个组件被卸载重挂。真正在项目里制造麻烦、又最容易被忽略的,是父组件更新这一种——因为它是隐式的。你没动某个子组件的任何 props,它照样跟着父组件重新执行了一遍。

比如:

1function Page() {
2  const [keyword, setKeyword] = useState('')
3  const [selectedId, setSelectedId] = useState(null)
4
5  return (
6    <>
7      <SearchBox value={keyword} onChange={setKeyword} />
8      <HeavyTable keyword={keyword} selectedId={selectedId} />
9      <Preview selectedId={selectedId} />
10    </>
11  )
12}

这段代码看起来没问题,但如果 SearchBox 每敲一个字都会让 Page 更新,那么 HeavyTablePreview 也都会重新执行。哪怕它们最后渲染出来的 DOM 没变,计算过程也已经发生了。

很多人会立刻给 HeavyTablememo,但更应该先问:keyword 这个状态真的需要放在 Page 里吗?如果它只影响搜索框和表格,就应该把它收进更小的区域。

1function SearchableTable() {
2  const [keyword, setKeyword] = useState('')
3
4  return (
5    <>
6      <SearchBox value={keyword} onChange={setKeyword} />
7      <HeavyTable keyword={keyword} />
8    </>
9  )
10}
11
12function Page() {
13  const [selectedId, setSelectedId] = useState(null)
14
15  return (
16    <>
17      <SearchableTable />
18      <Preview selectedId={selectedId} />
19    </>
20  )
21}

这个改动比加 memo 更直接。因为它从源头上缩小了更新范围,Preview 不再被搜索输入牵连。

状态不是越集中越好

很多项目会形成一种惯性:页面状态全放到顶层,子组件只负责展示。这样数据流确实清楚,但性能上未必划算。

我现在决定一个状态放哪,就问自己两件事:有几个组件真正需要读它,以及它变化的时候哪些区域允许被一起更新。这两个问题一问,位置基本就定了。如果一个状态只服务一个输入框,就别放到整页;如果它会同时影响表格筛选、分页和导出按钮,那就放到这几个组件共同的最近父级,刚好覆盖到、不多不少。状态挂得越高,一次更新波及的面就越大,这是个很朴素但很多人不去想的账。

表单尤其明显。一个大表单如果所有字段都存在父组件里:

1function FormPage() {
2  const [form, setForm] = useState({
3    name: '',
4    phone: '',
5    address: '',
6  })
7
8  return (
9    <>
10      <NameField form={form} setForm={setForm} />
11      <PhoneField form={form} setForm={setForm} />
12      <AddressField form={form} setForm={setForm} />
13    </>
14  )
15}

每个字段输入都会让所有字段组件重新渲染。字段少时无所谓,字段多、校验多、联动多时就会明显变慢。

更好的方式是让字段组件只订阅自己需要的值,或者使用成熟表单库的字段级订阅能力。没有表单库时,也至少不要把整个 form 对象原样传给每个字段。

1<NameField value={form.name} onChange={(name) => setForm((prev) => ({ ...prev, name }))} />

这还不是终极优化,但已经比“每个字段拿整个表单对象”好很多。因为子组件的 props 更窄,后面要不要 memo 也更容易判断。

memo 解决的是重复渲染,不解决慢计算本身

React.memo 的作用很单纯:当 props 没变时,跳过这个子组件的重新渲染。

但它不是哪儿都该加。真正值得包 memo 的,是那种渲染本身有点贵、props 又相对稳定、偏偏父组件还频繁更新的组件——而且它自己最好没有依赖那种一直在变的 context,否则 context 一变照样刷新,memo 白包。四个条件凑齐了,加 memo 才有意义。

比如:

1const UserCard = memo(function UserCard({ user }) {
2  return <div>{user.name}</div>
3})

memo 不是免费午餐。React 需要比较新旧 props,比较本身也有成本。如果组件很轻,或者 props 每次都是新对象,memo 基本没意义。

下面这种写法就很典型:

1<UserCard user={{ id: user.id, name: user.name }} />

即使 idname 没变,这里每次都会创建新对象,memo 看到的 user 引用都不一样,照样会渲染。除非你把对象稳定下来:

1const cardUser = useMemo(() => {
2  return { id: user.id, name: user.name }
3}, [user.id, user.name])
4
5<UserCard user={cardUser} />

但写到这里就要小心了:如果只是为了让 memo 生效而到处补 useMemo,代码会变得很累。更简单的办法往往是传原始值:

1<UserCard id={user.id} name={user.name} />

props 越简单,优化越稳定。

useCallback 不是性能护身符

useCallback 经常被滥用。

它只是在依赖不变时返回同一个函数引用,主要价值是配合 memo 或作为 hook 依赖。它不会让函数执行更快,也不会自动减少组件渲染。

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

这段代码有价值的前提是:handleSelect 会传给一个被 memo 包住的子组件,并且这个函数引用变化会导致那个子组件无效刷新。

如果函数只在当前组件内部使用,或者子组件本来就会因为其他 props 变化而刷新,那么 useCallback 基本只是增加噪音。

我现在的习惯是先不写 useCallback。只有 Profiler 或明确的渲染链路证明函数引用导致了问题,再补。

有个反例经常骗到人:给一个没包 memo 的普通子组件传函数,然后特地用 useCallback 把它稳住。这完全是空转——子组件本来就会随父组件重渲染,函数引用稳不稳定它根本不在乎。useCallback 的稳定引用只有落到 memo 组件的 props 上,或者进了别的 hook 的依赖数组里,才真正兑现价值。脱离这两个前提,它就只是给代码添了一层噪音,还让人误以为做了优化。

列表性能的第一刀通常是虚拟列表

如果页面一次渲染几千行数据,memouseMemo 都不是第一优先级。因为 DOM 数量本身就太多了。

长列表应该优先考虑虚拟列表,只渲染视口附近的元素:

1import { FixedSizeList } from 'react-window'
2
3function UserList({ users }) {
4  return (
5    <FixedSizeList
6      height={600}
7      itemCount={users.length}
8      itemSize={48}
9      width="100%"
10    >
11      {({ index, style }) => (
12        <div style={style}>{users[index].name}</div>
13      )}
14    </FixedSizeList>
15  )
16}

虚拟列表解决的是数量级问题。几千个 DOM 节点变成几十个,收益比微调某个组件大得多。

但它也有代价:动态高度、滚动定位、键盘导航、可访问性、表格固定列都会变复杂。所以我不会在几十条数据的列表里上虚拟滚动。只有数据量稳定超过几百行,或者移动端明显卡顿时,才值得引入。

用 Profiler 找证据

性能优化最怕靠感觉。

React DevTools 的 Profiler 能看到每次提交耗时、哪些组件渲染了、以及它为什么渲染。我排查时的路径基本是固定的:先录一次操作,找出哪次交互最慢;再看这次慢的是 render 阶段还是 commit 阶段;然后盯着看哪些组件在这次交互里明明不该渲染却渲染了;最后判断最贵的那个组件到底是自己计算贵,还是子树铺得太大。

这一步的结论会直接决定往哪个方向修。如果问题是“不该渲染却渲染了”,那就回去调状态边界、收窄 props、拆 context——这些都是减少更新发生。如果问题是“该渲染,但渲染本身很贵”,才轮到 memouseMemo、虚拟列表,或者把重计算搬到 Web Worker 里去。两类问题的解药完全不同,先分清是哪一类,比急着加缓存重要得多。

这里特别要注意 context。一个大的 context value 只要引用变化,所有消费它的组件都会更新。

1<AppContext.Provider value={{ user, theme, permissions, updateUser }}>
2  {children}
3</AppContext.Provider>

这个 value 每次都是新对象。更麻烦的是,theme 变化时,所有只读 user 的组件也会跟着更新。解决方式通常是拆 context,或者把 value 用 useMemo 稳定住:

1const userValue = useMemo(() => ({ user, updateUser }), [user, updateUser])

但如果 usertheme 的变化频率、使用区域完全不同,拆成两个 context 更干净。

useMemo 缓存的是计算,不是渲染

useMemo 经常和 React.memo 被混着谈,但它俩管的事不一样。React.memo 决定“组件要不要重新渲染”,useMemo 决定“某个值要不要重新计算”。真正该上 useMemo 的,是那种输入没变、算一次却很贵的派生数据——一个上千行数组的排序加过滤加分组,每次渲染重算一遍确实浪费:

1const visibleRows = useMemo(() => {
2  return rows
3    .filter((r) => r.status === status)
4    .sort((a, b) => b.updatedAt - a.updatedAt)
5}, [rows, status])

但如果里面只是 a + b 这种廉价计算,包 useMemo 纯属自找麻烦——它自己也有维护依赖数组、比较依赖的成本,收益还不够抵。判断标准很简单:这个计算贵不贵,输入稳不稳定。两个都是“是”,才值得缓存。

到了 2024,React 18 的并发能力也给“渲染本身很贵”提供了另一条思路。像大列表筛选这种输入频繁、结果又重的场景,与其死磕缓存,不如用 useDeferredValue 让沉重的那部分渲染跟在输入后面、不阻塞输入框:

1const deferredKeyword = useDeferredValue(keyword)
2const list = <HeavyList keyword={deferredKeyword} />

输入框拿的是最新值、响应始终跟手,重列表用滞后一点的值去渲染,卡顿的手感就化掉了。startTransition 是同一类工具,把非紧急的状态更新标记成可打断的。它们不减少渲染次数,而是改变渲染的优先级——这也是“先减少要做的事,再让剩下的事不挡住用户”这条思路的延伸。顺带一提,社区在传的 React 编译器今年才刚公布,能自动省掉一部分手写 memouseMemo,但还很早期,我只当个值得关注的方向,没往生产里放。

我的优化顺序

React 性能优化不应该从 API 开始,而应该从更新模型开始。

我现在基本按这个顺序处理:

  1. 复现具体慢交互,不优化模糊的“感觉卡”
  2. 用 Profiler 看渲染链路,确认谁在更新
  3. 调整状态位置,让更新范围变小
  4. 收窄 props,避免把大对象传得到处都是
  5. 拆 context,避免一个值变化拖动全局
  6. 对确实昂贵且 props 稳定的组件加 memo
  7. 对长列表上虚拟滚动
  8. 对重计算做缓存、延迟或移到 worker

这套顺序的核心是:先减少需要做的事,再让剩下的事做得更快。前五步基本不引入任何缓存 API,纯靠调整结构就能解决大半问题;真到要加 memouseMemo 的时候,问题已经被收窄到很小的范围,加得也就更有把握、更好维护。

回到开头那个配置页。它最后没靠满页的 memo 救回来,而是把 keyword 从顶层挪进了搜索区,一次输入不再惊动树和预览,抖动就消失了。整个过程我几乎没写缓存,只是把状态放对了地方。

memouseMemouseCallback 都是工具,不是策略。真正的策略是让每次用户操作只影响它应该影响的那一小块界面——想清楚这件事,你会发现大多数时候根本轮不到那个“开关”。