一、前言
该篇主要分享在 React 组件开发中,如何进行性能优化,会从三个维度进行分析。
- 优化目的:分析到底是在优化什么?
- 渲染机制:哪些机制会触发重新渲染?
- 相关案例:分享一些的性能优化的技巧
二、优化目的
2.1 为什么交互会卡顿?
下面这个例子,在页面打开时会执行一个运算量非常大的任务。用户在输入时,会出现卡住的感觉。
来看一下性能分析图,该计算任务占用了 600 多毫秒的时间。导致这段时间内浏览器是没办法响应用户任何操作的。
原因是:主线程采用事件循环(Event Loop)机制来处理任务。当主线程被长时间(CPU密集型任务)占用时,任务队列中的任务(如用户输入、动画、网络请求回调等)无法及时得到处理,导致这些任务被延迟执行。
2.2 React 卡顿的根因
现在知道了长时间的 CPU 密集型任务会导致卡顿时,那我们来分析一下,React 本身是怎么导致交互卡顿的。
这里我们通过一个 demo 来演示
tsx
const ListItem: React.FC<{ value: number }> = ({ value }) => {
return (
<div className="list-item">
<span>Item {value}</span>
</div>
);
};
function App() {
const [items, setItems] = useState(() => {
return Array.from({ length: 100000 }, (_, i) => {
return {
value: i,
};
});
});
return (
<div className="app">
<h1>大量组件渲染性能演示</h1>
<div className="controls">
<label>组件数量: {items.length}</label>
<button
onClick={() => {
setItems([...items, { value: items.length + 1 }]);
}}
>
添加一条数据
</button>
</div>
<div className="list-container">
{items.map((item) => (
<ListItem key={item.value} value={item.value} />
))}
</div>
</div>
);
}
先来看首次渲染 10 w 个组件,React 框架本身的执行逻辑需要 600 ms+(主要是创建 Fiber 树的过程)
上面的代码 demo,如果不主动去优化,每次点击"添加一条数据" 都会触发 500 ms+ 的一个执行耗时,这个时间内浏览器将无法及时响应用户的操作(遍历 Fiber 树进行更新的计算)
如果给 ListItem 加上 React.memo,耗时将会缩减到 100 ms +(主要是因为 Fiber 遍历过程中跳过了大部门的计算)
所以使用 React 框架,造成用户交互卡顿的根因是:状态更新时,大量 Fiber 的计算导致主线程一直被占用(因为 React 会从根节点开始全量的遍历),导致浏览器无法及时响应用户的操作。
2.3 优化思路
核心优化思路就是
- 减少渲染的次数:减少这种大量的递归计算的触发
- 控制更新粒度:跳过一些不必要的递归
三、渲染机制
首先需要知道,哪些机制会让组件重新渲染。
1.1 setState
当组件内部通过 setState
更新状态时,会触发整体的重新渲染。
示例:
2. 父组件重新渲染
即使子组件的 state 未变化,父组件的重新渲染也会导致所有子组件重新渲染。这是因为 React 默认以组件树为单位进行更新。子组件的 props 变化本质上是父组件重新渲染的结果,而非直接原因。
示例:
3. Context 变化
当组件通过 useContext
订阅的 Context 值变化时,所有依赖该 Context 的组件会重新渲染。即使组件仅使用 Context 中的部分数据,只要 Context 整体更新,所有订阅组件都会重新渲染。
示例:
4. forceUpdate
通过类组件的 forceUpdate()
方法或自定义 Hook(如 useForceUpdate
)可强制触发重新渲染,但通常不推荐使用。
示例:
5. Hooks 内部变化
Hooks 的状态或依赖变化可能触发重新渲染,例如:
• useEffect
依赖项变化后的副作用执行可能导致父组件状态更新。
• 自定义 Hooks 中状态变化会传播到宿主组件。
四、具体方案
4.1 减少更新
重新渲染的根源是因为触发了状态的更新。下面的案例,它忽略了 idNumber 并不需要渲染,只是在提交请求时才会用到。这样用户在修改 idNumber 时就会造成不必要的更新。
所以像不需要渲染,但需要频繁修改的数据,可以用 let 变量或者 ref 进行保存。
还有一个注意点是,在 react 18 版本之前,异步任务(setTimeout、promise)下的 setState 是没有批处理优化的。下面的代码,调用两次 setState 都会触发组件的重新渲染。
比较好的办法就是,通过 useReducer 将 state 合并
4.2 合理使用 Memo
函数组件使用 Memo(类组件使用 shouldComponentUpdate 来跳过不必要的更新),配合一些比较的方法。
关于逻辑计算的 memo,除非它真的非常耗时,不然不建议使用 useMemo 去进行优化,因为可能适得其反。
4.3 传参优化
函数参数
下面的代码,每次 count 更新都会导致子组件更新,因为每次渲染都会创建一个新的匿名函数。
需要对函数参数进行优化,通过 useCallback 固定函数的引用地址。
对象参数
下面的案例中,每次更新 num,都会导致 themeContext.Provider 下面消费了 context 组件全部重新更新。因为每次渲染,都会重新创建一个新的对象传递给 value。
需要通过 useMemo 进行优化
4.4 长列表优化
虚拟列表的核心思想是仅渲染可视区域内的列表项,而非一次性渲染所有列表项。这能显著减少 DOM 节点数量,提升性能。你可以使用 react-virtualized
或者 react-window
这类库来实现虚拟列表。
4.5 Context 注意事项
在下面案例中,无论是 theme 更新,还是 num 更新,都会导致另外一个组件被迫更新。原因是当 Provider
的 value
属性发生变化时,React 都会将这个变化广播给所有订阅了该 Context 的组件
有两个解决办法,一是尽可能将 Context 拆分的更细。二是使用 Zustand 这样的第三方库。
还有就是,避免消费组件获取 Provider 的层级太深,因为通过 useContext 获取值,它是一个向上不断查询的过程,过深的组件层级会造成不必要的性能浪费。
五、总结
React 渲染时会进行大量的 Fiber 计算逻辑。减少状态的更新,控制更新的粒度是 React 优化的核心。平时多多注意编码的习惯,会大大提升应用的性能。