前端知库

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 之前:

JSX
// 改前:输入框的状态放在页面顶层,打一个字整个页面都渲染
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 可以推迟加载组件的代码,直到它第一次被渲染:

JSX
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 提供了一个开启了分析功能的特殊生产构建,需要在构建配置里切换才能使用。