React 应用有哪些常见的性能优化手段?
先用 Profiler 找到慢在哪里,再按三类处理:减少渲染次数(状态下放、children 传 JSX、memo、稳定的 key),减少每次渲染的成本(useMemo、虚拟列表、useTransition),减少加载体积(lazy 和 Suspense 按需加载)。启用 React Compiler 后,大部分手写的记忆化可以交给编译器。
简答
比如一个后台页面,在顶部搜索框里打字,下面几百行的表格跟着卡。我会先用 React DevTools 的 Profiler 录一次,看是哪些组件在渲染、每次花多久,确认瓶颈再动手,而不是到处加
memo。找到原因后按三类处理。第一类是减少渲染次数:把输入框这类频繁变化的状态放到用它的组件里,不往上提;包裹型组件用
children接收 JSX;对确实慢的子组件用memo,并配合useMemo、useCallback让 props 保持不变;列表用稳定的key,避免整项重建。第二类是减少每次渲染的成本:昂贵的计算用
useMemo缓存;长列表用虚拟列表只渲染可见的几十行;不急的更新用useTransition或useDeferredValue降低优先级,让输入先响应。第三类是减少加载体积:用lazy加Suspense把不在首屏的页面和组件拆出去,按需加载。要注意,
memo和useMemo都只是性能优化,不是保证,React 仍可能重新渲染。启用 React Compiler 后,它会在构建时自动做记忆化,新代码大多不用再手写这些。
先测量,再优化
React 提供了 <Profiler> 组件,包住一棵子树后,每次子树里的组件更新,React 都会调用 onRender 回调,告诉你这次渲染花了多久。日常排查更常用的是 React DevTools 里的 Profiler 面板,文档说它以浏览器扩展的形式提供了类似的功能,可以交互式地查看。
为什么一定要先测?因为优化有成本。文档提到 <Profiler> 本身会增加 CPU 和内存开销,生产构建里默认是关闭的;memo 每次也要比较 props。没有测量就加,可能多做了工作,界面却没有变快。另外 React 文档提醒,做性能测量时要让 React 运行在生产模式,开发环境多了很多检查,测出来比实际慢。
减少渲染次数
React 的默认行为是父组件重新渲染时,子组件跟着一起渲染,原因见《React 的渲染阶段与提交阶段有什么区别?》。所以最直接的优化是让状态变化影响的范围尽量小。React 文档在 memo 一页里先推荐的就是这几条,排在 memo 之前:
// 改前:输入框的状态放在页面顶层,打一个字整个页面都渲染
function Page() {
const [keyword, setKeyword] = useState('');
return (
<>
<input value={keyword} onChange={(e) => setKeyword(e.target.value)} />
<BigTable />
</>
);
}
// 改后:状态下放到用它的组件里,BigTable 不再跟着渲染
function SearchBox() {
const [keyword, setKeyword] = useState('');
return <input value={keyword} onChange={(e) => setKeyword(e.target.value)} />;
}
function Page() {
return (
<>
<SearchBox />
<BigTable />
</>
);
}
改后为什么 BigTable 不渲染了?因为 keyword 变化只会让 SearchBox 重新执行,Page 自己没有状态变化,也就不会带着 BigTable 一起渲染。文档的原话是优先使用局部状态,不要把状态提升到超出需要的地方,像表单输入、是否悬停这种临时状态不要放在树顶或全局状态库里。
同样的思路还有一种写法:组件在视觉上包裹别的组件时,让它通过 children 接收 JSX。包裹组件更新自己的状态时,React 知道传进来的 children 不需要重新渲染。文档还指出,React 应用的很多性能问题来自 Effect 里更新状态引起的连锁渲染,能在渲染时直接算出来的值就不要用 Effect 同步到 state。
这些都做不到时,再用 memo。memo 包住的组件在 props 没变时会跳过渲染,props 默认用 Object.is 逐个比较,所以传给它的对象和函数要用 useMemo、useCallback 保持同一个引用,否则每次都是新的,memo 等于没加。三者怎么配合见《memo、useMemo、useCallback 各自优化什么?》。
列表的 key 也影响渲染工作量。key 稳定时,React 能认出同一项,只更新变化的部分;用下标当 key 又发生插入或排序,React 会把状态和 DOM 对到错误的项上,详见《列表中的 key 为什么不能随意使用数组下标?》。反过来,同一位置换一个 key 会让 React 丢掉旧组件、重建新组件,这是重置状态的手段,不要在每次渲染时随机生成 key。
减少每次渲染的成本
渲染次数降不下来时,就让每次渲染少做点事。组件里有很贵的计算,比如对上万条数据过滤排序,用 useMemo 缓存结果,依赖没变就直接复用。
列表本身太长是另一种情况。一次渲染一万行,即使每行都很简单,创建和更新这么多 DOM 节点也会卡。虚拟列表只渲染可视区域内的几十行,滚动时替换内容,React 本身没有内置这个功能,通常借助第三方虚拟列表库实现,原理见《页面要渲染十万条数据,怎样不卡顿?》。
还有一类卡顿不是总量大,而是一次更新阻塞了输入。比如每输入一个字都要重新渲染一个复杂图表,可以用 useTransition 或 useDeferredValue 把图表更新标记为不紧急,React 会先处理输入,详见《useTransition 和 useDeferredValue 能优化哪些卡顿?》。
减少加载体积
首屏慢往往不是渲染慢,而是要下载、解析的 JavaScript 太多。lazy 可以推迟加载组件的代码,直到它第一次被渲染:
import { lazy, Suspense } from 'react';
const Settings = lazy(() => import('./Settings.jsx'));
function App({ tab }) {
return (
<Suspense fallback={<p>加载中…</p>}>
{tab === 'settings' && <Settings />}
</Suspense>
);
}
用户没切到设置页时,Settings 的代码不会下载。切过去后,代码还在加载期间组件会挂起,外层 Suspense 显示 fallback。要注意 lazy 依赖动态 import(),需要打包工具或框架支持,被导入的组件要是默认导出;lazy 要写在模块顶层,写在组件里每次渲染都会得到新组件,状态会被重置。
React Compiler 改变了什么
上面 memo、useMemo、useCallback 都要手写,文档也承认手动记忆化繁琐、容易写错、还增加维护的代码。React Compiler 是一个构建时工具,它分析组件代码,自动加上合适的记忆化,让组件只在需要时重新渲染。
所以启用编译器的新项目,一般不再手写这些 API,把精力放在状态放在哪里、列表是否太长、包是否太大这些编译器管不到的事上。编译器的细节见《React Compiler 解决了哪些优化问题?》。
常见追问
是不是给所有组件都加上 memo 就好了?
不是。
memo每次都要比较 props,props 每次都变的话,比较白做了。文档也说明它只是性能优化,不是保证,React 仍可能重新渲染。应该先用 Profiler 找到确实慢、又经常收到相同 props 的组件,再加。
Context 变化导致大量组件渲染怎么办?
用了某个 Context 的组件,在它的值变化时一定会重新渲染,
memo也挡不住。常见的做法是用useMemo、useCallback包住传给 Provider 的对象和函数,让value只在内容真变时才换成新对象,避免父组件每次渲染都通知所有使用者。
生产环境怎么用 Profiler?
<Profiler>在生产构建里默认关闭,React 提供了一个开启了分析功能的特殊生产构建,需要在构建配置里切换才能使用。