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 更新,那么 HeavyTable 和 Preview 也都会重新执行。哪怕它们最后渲染出来的 DOM 没变,计算过程也已经发生了。
很多人会立刻给 HeavyTable 加 memo,但更应该先问: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 }} />
即使 id 和 name 没变,这里每次都会创建新对象,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 的依赖数组里,才真正兑现价值。脱离这两个前提,它就只是给代码添了一层噪音,还让人误以为做了优化。
列表性能的第一刀通常是虚拟列表
如果页面一次渲染几千行数据,memo 和 useMemo 都不是第一优先级。因为 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——这些都是减少更新发生。如果问题是“该渲染,但渲染本身很贵”,才轮到 memo、useMemo、虚拟列表,或者把重计算搬到 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])
但如果 user 和 theme 的变化频率、使用区域完全不同,拆成两个 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 编译器今年才刚公布,能自动省掉一部分手写 memo/useMemo,但还很早期,我只当个值得关注的方向,没往生产里放。
我的优化顺序
React 性能优化不应该从 API 开始,而应该从更新模型开始。
我现在基本按这个顺序处理:
- 复现具体慢交互,不优化模糊的“感觉卡”
- 用 Profiler 看渲染链路,确认谁在更新
- 调整状态位置,让更新范围变小
- 收窄 props,避免把大对象传得到处都是
- 拆 context,避免一个值变化拖动全局
- 对确实昂贵且 props 稳定的组件加
memo - 对长列表上虚拟滚动
- 对重计算做缓存、延迟或移到 worker
这套顺序的核心是:先减少需要做的事,再让剩下的事做得更快。前五步基本不引入任何缓存 API,纯靠调整结构就能解决大半问题;真到要加 memo、useMemo 的时候,问题已经被收窄到很小的范围,加得也就更有把握、更好维护。
回到开头那个配置页。它最后没靠满页的 memo 救回来,而是把 keyword 从顶层挪进了搜索区,一次输入不再惊动树和预览,抖动就消失了。整个过程我几乎没写缓存,只是把状态放对了地方。
memo、useMemo、useCallback 都是工具,不是策略。真正的策略是让每次用户操作只影响它应该影响的那一小块界面——想清楚这件事,你会发现大多数时候根本轮不到那个“开关”。