3.1 性能优化 本节摘要:React 性能优化的核心不是「用对某个 API」,而是「让 Diff 的三条假设成立」。本节从 React.memo、useMemo、useCallback 三个记忆化工具讲起,再到虚拟滚动、代码分割、图片优化,最后给出 Profiler 的分析流程与「优化前先测量」的工程纪律。 本节目标 阅读完本节,你应当能够: 用 React.memo 避免纯函数组件的不必要重渲染 用 useMemo 缓存计算结果、useCallback 缓存函数引用 理解三个记忆化工具的依赖数组与适用边界 用虚拟滚动解决长列表性能问题 用 React.
本节摘要:React 性能优化的核心不是「用对某个 API」,而是「让 Diff 的三条假设成立」。本节从 React.memo、useMemo、useCallback 三个记忆化工具讲起,再到虚拟滚动、代码分割、图片优化,最后给出 Profiler 的分析流程与「优化前先测量」的工程纪律。
阅读完本节,你应当能够:
性能优化最常听到的问题是「为什么我用了 React.memo 反而变卡」。答案藏在第 2.1 节的结论里:React 的渲染成本主要在于「重新调用组件函数、重建虚拟 DOM」。优化就是要减少不必要的重建。
一个典型场景:父组件有个 state 每秒变化,子组件其实只依赖某个不变的数据。父组件每次重渲染,子组件都被迫跟着重渲染——即使它该显示的界面根本没变。这就是「不必要的渲染」,是 React 应用最常见的性能浪费。
React 给的武器是「跳过」:React.memo 让子组件比较 props,没变就跳过;useCallback 让传给子组件的函数引用稳定,配合 memo 生效;useMemo 让重计算只发生在依赖变化时。三个工具解决三个不同层面的「重复劳动」。
React.memo 是高阶组件,接收一个组件,返回它的记忆化版本。默认浅比较 props,props 没变就跳过重渲染:
import { memo } from 'react'; const MyComponent = memo(function MyComponent(props) { return <div>{props.data}</div>; });
memo 还可以传自定义比较函数,精细控制「什么算变了」:
const areEqual = (prevProps, nextProps) => { return prevProps.data === nextProps.data; }; const MyComponent = memo(MyComponentInner, areEqual);
适用场景:纯函数组件、props 更新频繁但实际数据变化不大、大型组件树的叶子节点。
useMemo 缓存函数调用的返回值,依赖不变就不重算:
import { useMemo, useState } from 'react'; function MyComponent() { const [count, setCount] = useState(0); const expensiveValue = useMemo(() => { let result = 0; for (let i = 0; i < 1000000; i++) result += i; return result; }, [count]); // count 变化才重算 return <p>{expensiveValue}</p>; }
关键在依赖数组:[count] 告诉 React「count 没变就用缓存」。适用场景:计算密集型操作、会被多次读取的派生值。
useCallback 缓存函数本身,避免每次渲染都创建新引用:
import { useCallback, useState } from 'react'; function Parent() { const [count, setCount] = useState(0); const handleClick = useCallback(() => { setCount(prev => prev + 1); }, []); // 空依赖,函数引用永远不变 return <Child onClick={handleClick} />; }
它的价值不是「省了创建函数的开销」——创建函数本身很便宜。它的价值是:让传给 memo 子组件的 props 引用稳定,否则 memo 的浅比较每次都发现新函数引用而失效。
💡 关键直觉:React.memo、useCallback、useMemo 是同一套思路的三个维度——React.memo 拦组件,useCallback 稳引用,useMemo 缓存计算。它们的共同前提是依赖数组要写对:漏写依赖读到旧值,多写依赖缓存失效。
| 工具 | 拦什么 | 何时用 | 代价 |
|---|---|---|---|
| React.memo | 组件重渲染 | 纯组件、频繁 props 更新 | 多一层比较 |
| useCallback | 函数重建 | 传函数给 memo 子组件 | 多一个闭包 |
| useMemo | 重复计算 | 计算密集、派生值 | 多一份内存 |
真正项目里三者经常组合。看一个「子组件只依赖少量 props 但父组件高频更新」的经典场景:
function TodoItem({ todo, onToggle }) { return ( <li onClick={() => onToggle(todo.id)}> {todo.text} - {todo.done ? '已完成' : '未完成'} </li> ); } const MemoTodoItem = memo(TodoItem); function TodoList({ todos }) { const [filter, setFilter] = useState('all'); const handleToggle = useCallback((id) => { console.log('切换', id); }, []); // 稳定引用,配合 memo return ( <div> <button onClick={() => setFilter('done')}>只看已完成</button> <ul> {todos.map(todo => ( <MemoTodoItem key={todo.id} todo={todo} onToggle={handleToggle} /> ))} </ul> </div> ); }
filter 状态变化让 TodoList 重渲染,但每个 MemoTodoItem 的 props(todo 和 handleToggle)都没变,memo 跳过它们的重渲染。没有 memo,filter 一变,整个列表重建;没有 useCallback,handleToggle 每次新引用,memo 失效。
优化有代价:代码变复杂、内存占用增加。三个场景别优化:
几千上万项列表,memo 救不了——每次滚动都要渲染新的一批。虚拟滚动的思路是只渲染可见区域内的项,用绝对定位模拟滚动:
import { FixedSizeList as List } from 'react-window'; const Row = ({ index, style }) => ( <div style={style}>Row {index}</div> ); function MyList({ data }) { return ( <List height={150} itemCount={data.length} itemSize={35} width={300} > {Row} </List> ); }
react-window 的 FixedSizeList 适用于等高的项,VariableSizeList 适用于高度不一的项。虚拟列表把渲染量从「总数」降到「视口能看到的数量」,长列表从「卡顿」变「流畅」。
代码分割把应用拆成多个小 bundle,按需加载,减少首屏体积。React.lazy 配 Suspense:
import { lazy, Suspense } from 'react'; const MyComponent = lazy(() => import('./MyComponent')); function App() { return ( <Suspense fallback={<div>加载中...</div>}> <MyComponent /> </Suspense> ); }
动态 import() 还能在事件里按需加载:
async function handleClick() { const module = await import('./MyModule'); module.doSomething(); }
SOURCE 原文列了四条:压缩(TinyPNG 类工具)、WebP 格式、懒加载(loading="lazy")、CDN 加速。图片体积往往是首屏体积的大头,这四条优先级高、成本低。
这两个工具被过度使用的频率很高。先说清楚它们的成本:useMemo 会缓存计算结果,占一份内存;useCallback 会缓存函数引用,多一个闭包。如果缓存的值很少被用到、或者依赖频繁变化导致缓存经常失效,那缓存不仅没省事,反而多了维护负担。
判断是否需要 useMemo,可以问自己两个问题:第一,这个计算真的贵吗?数组排序、简单过滤这类操作在几百条数据量级下开销可以忽略,useMemo 反而成了负担。第二,这个值会作为依赖传给 memo 子组件吗?如果是,useMemo 才有意义——它能稳定引用,让 memo 生效。脱离这两个前提用 useMemo,属于「为了优化而优化」。
useCallback 同理。它真正有价值的场景只有一个:把函数传给 React.memo 包裹的子组件。如果子组件没有 memo,每次渲染传新函数没有任何额外代价——React 本来就会重新渲染它,函数新不新无所谓。所以「给所有函数套 useCallback」是典型的反模式。
排查「组件为什么重渲染」时,React DevTools 有一个隐藏好用的功能:在组件树里选中组件,能高亮显示它何时重渲染。配合 Profiler 的「渲染原因」面板,可以看到触发渲染的具体 props 或 state 变化来源。
还有个小技巧:在渲染函数里临时打印,直接看哪些组件被重复渲染:
function TodoItem({ todo }) { console.log('TodoItem 渲染', todo.id); return <li>{todo.text}</li>; }
打开控制台观察:点击无关按钮时,如果 TodoItem 的日志还在刷,说明它在做无意义的重渲染——这就是 memo 该上场的地方。观察完记得删掉 console.log,别让调试代码留在生产里。
记忆化是「主动优化」,还有一类是「避免作死」——别在渲染期制造变化:
<Child config={{x:1}} /> 每次渲染都新对象,memo 浅比较必失React DevTools 的 Profiler 标签能记录每次渲染的耗时与原因。排查顺序:
React.Profiler 组件还能在代码里测量特定部分:
<Profiler id="TodoList" onRender={(id, phase, actualDuration) => { console.log(id, phase, actualDuration); }}> <TodoList /> </Profiler>
⚠️ 常见坑:把所有组件都包上 memo。memo 有比较成本,全部包等于处处增加开销,还可能因为依赖写错引入 bug。正确做法是「Profiler 指哪打哪」,只优化确认有问题的组件。
💡 关键直觉:性能优化的黄金法则是「先测量,再优化,再测量」。没有测量就动手,等于闭眼修水管——修完可能更糟。React 的优化工具都在等一个「确切的瓶颈」,别让它们空转。
记忆化是从「跳过渲染」入手,还有一种思路是「从源头减少渲染」。状态提升把共享状态放到更靠近使用处的公共父组件,避免无关组件被连带渲染;状态下移把只属于局部的 state 移进局部组件,让父组件不感知、不重渲染。
举个例子:一个表单里有个只在提交时用到的「草稿状态」,如果把它放在页面级组件里,每次输入都让整个页面重渲染。把它下移进表单组件内部,页面组件就不再被输入动作打扰。这类结构性调整往往比堆 memo 更彻底——它减少的是渲染本身,而不是在渲染发生后试图跳过。
开发模式下 React 会做额外检查(propTypes、警告、严格模式双调用),性能表现和线上不同。优化效果的验证要以生产构建为准:用构建产物本地起一个预览服务,再录 Profiler。别用开发环境的卡顿吓自己,也别用开发环境的流畅安慰自己。

这张图把常见问题与对应解法对应起来:横向是症状,纵向是工具,交叉点是使用条件。排查性能问题时不逐个试工具,先看症状属于哪一类,再选对应的解法。
把本节知识串成可操作的五步:
这个流程比任何「优化技巧清单」都重要——它把性能优化从「玄学」变成「工程」。
「用了 memo 反而更慢了,怎么回事?」 排查两处:一是比较成本是否大于渲染成本——一个只有两个 span 的静态组件,每次浅比较的开销可能和直接渲染相当,memo 白加;二是依赖是否稳定——父组件每次渲染都传新的内联对象或箭头函数,memo 的浅比较每次都命中「变了」,等于空转。前者该去掉 memo,后者该配合 useCallback/useMemo 把引用稳住。memo 是「props 稳定时才生效」的工具,前提不成立,结果必然失望。
「首屏体积优化到底从哪下手?」 先分清体积构成,再动手。打开构建产物分析面板,按体积排序看三大块:依赖库(antd、echarts 这类动辄几百 KB)、业务代码、图片资源。依赖库大的,先看能不能按需引入或换轻量替代;业务代码大的,用路由级代码分割按访问拆包;图片大的,压缩换格式。一次性把三块全压,改动面太大,先挑占比最高的一块做,验证有效再继续。
「Suspense 和错误边界能配合吗?」 能,而且应该配合。Suspense 管「加载中」,错误边界管「加载失败」,两者是同一个异步渲染流程的两个阶段。用 React.lazy 懒加载的组件,Suspense 的 fallback 只在加载期间显示;一旦模块加载失败(比如网络断了),错误会在 Suspense 之上抛给最近的错误边界。把这两个一起用,用户得到的体验是「先看到加载占位,失败了看到友好的降级页」——比单独用任何一个都完整。这也是第 3.8 节错误边界与本节懒加载联动的典型场景。
下一节进入路由管理——性能让应用「跑得快」,路由让应用「走得通」。React Router 的多页面导航、动态参数、嵌套路由,是任何多页面应用的骨架。