以一个列表为例讲讲 React 重渲染优化
什么时候组件会重渲染?
状态更改、父级(或子级)重新渲染、context value 发生变化以及 hooks 发生变化的时候,都会触发组件的重渲染行为。
以列表 Demo 为例
React的性能优化主要是在于如何减少组件重渲染,因为当视图变得复杂/组件层级嵌套比较深的时候,重渲染的开销就会变得很大,甚至会导致页面出现卡顿现象,所以如何减少重渲染是 React 性能优化最重要的一部分。
下面我们将会以一个长列表的例子去讲如何进行 React 重渲染的优化,点击列表中的某一项时可以切换其选中状态
下面是初始示例代码:
import { memo, useState, useCallback } from "react";
// 创建 100 个列表数据
const data = new Array(100)
.fill()
.map((_, i) => i + 1)
.map((n) => ({
id: n,
name: `Item ${n}`,
}));
export default function App() {
// 存储长列表选中状态
const [selected, setSelected] = useState([]);
// 切换选中状态
const toggleItem = (item) => {
if (!selected.includes(item)) {
setSelected([...selected, item]);
} else {
setSelected(selected.filter((current) => current !== item));
}
};
return (
<div className="App">
<h1>长列表 Demo</h1>
<List data={data} selectedItems={selected} toggleItem={toggleItem} />
</div>
);
}
const List = ({ data, selectedItems, toggleItem }) => {
return (
<ul>
{data.map((item) => (
<ListItem
// 别忘记加 key,并且这个 key 需要是唯一的
key={item.name}
name={item.name}
selected={selectedItems.includes(item)}
onClick={() => toggleItem(item)}
/>
))}
</ul>
);
};
const ListItem = ({ name, selected, onClick }) => {
// 在 render-body 模拟真实应用,进行一次复杂的计算
expensiveOperation();
return (
<li
style={selected ? { textDecoration: "line-through" } : undefined}
onClick={onClick}
>
{name}
</li>
);
};
const expensiveOperation = () => {
let total = 0;
for (let i = 0; i < 200000; i++) {
total += Math.random();
}
return total;
};
首先最基本的是在列表循环中加上 key,在动态列表中这个 key 是很重要的,它可以帮助 React 的 Diff 算法去进行数据比对,找出哪些 item 发生了变化,但是在动态列表中千万不要用 index 下标作为 key 值,假如你删除了列表中的第 N 项,那 N+1 后面所有项的 key 都会被改变,在 React 做 Diff 对比的时候就会出现性能问题
React 的 Diff 算法有这样的规则:
- 当元素类型变化时,会销毁重建
- 当元素类型不变时,对比属性
- 当组件元素类型不变时,通过props递归判断子节点
- 递归对比子节点,当子节点是列表时,通过key和props来判断。若key一致,则进行更新,若key不一致,就销毁重建
所以在列表中一定需要确保 key 的唯一性以避免不必要的销毁重建工作,但是如果你的列表是静态不会发生改变的,那其实用 index 也可以。
性能测试
写完了这个组件之后,我们要测试它的性能,这时候就可以用 React Devtools Profiler 来进行性能分析。
首先我们先把 cpu 性能降低 6 倍来模拟移动端设备。
接下来就打开 React 开发者性能分析工具
接下来在 React Devtools 里面的设置打开记录组件为什么会渲染的选项
配置完之后,接下来我们点击调试面板左上角的录制按钮,随便把几个 item 切换成选中状态,然后按停止录制
我们可以看到整个渲染是自顶向下的(APP-> List -> ListItem),渲染时间为 1027ms ,平均每一个 ListItem 花费了 10ms (100个 item) 左右的时间进行更新,这 1 秒钟去做一整个列表的渲染目前看着还好对吧,但是如果把这个 100 换成 10000 ,然后把它放到一个更低端的设备上,那结果天呐真是太糟糕了。
现在其实最主要的问题是,我们只点击了其中的某一项 item 也会导致整个列表所有 item 的重渲染。接下来我们就要去谈一谈怎么去解决这个问题。
性能优化
React.memo
那怎么去解决上面这个问题呢?这时候首先想到的就是 React.memo(Component, areEquals) 这个 API ,它会在每次组件重渲染的时候对 oldProps 和 newProps 进行浅比较,当 props 没发生改变的时候,组件就不会进行重渲染。 不过,当你使用这个 API 的时候要特别注意你的 props 中有没有对象或者函数,因为他们是引用数据类型,浅比较的时候实际比较的是他们的内存地址。 当然你也可以自己去写逻辑,在 areEquals 中去写嵌套对象里的每一个属性的对比逻辑,让它变成深层比较,最后返回一个 boolean 值,然后 memo 就会根据返回结果是否是 true 再去做重渲染,但是自己手写 memo 逻辑这样会大大增加代码维护成本,没特殊需求不建议去写。
在我们上面的 ListItem 组件中,用 memo 包裹之后其实就是下面这段逻辑
function arePropsEqual(prevProps, nextProps) {
return prevProps.name === nextProps.name &&
prevProps.selected === nextProps.selected &&
prevProps.onClick === nextProps.onClick
}
这里我们可以大概看一下 memo 的源码,其实也很简单:
源码在 packages/react-reconciler/ReactFiberBeginWork.new.js
文件下,详细逻辑可以看 updateSimpleMemoComponent
这个函数,但是这个 API 里面的核心方法就是 shallowEqual ,返回 false 时组件要重渲染,返回 true 组件不需要重渲染。
function shallowEqual(objA, objB) {
// 1.判断 A 和 B 是否同一个对象(判断是否是对同一个对象值的引用)
if (Object.is(objA, objB)) {
return true;
}
// 2.props 不是对象或者是 null,就返回 false,说明要更新组件
if (
typeof objA !== 'object' ||
objA === null ||
typeof objB !== 'object' ||
objB === null
) {
return false;
}
const keysA = Object.keys(objA);
const keysB = Object.keys(objB);
// 3.长度不同就直接返回 false
if (keysA.length !== keysB.length) {
return false;
}
// 4.对比 props 里的每一个属性,看 oldProps 和 newProps 是否有不同的 key
for (let i = 0; i < keysA.length; i++) {
const currentKey = keysA[i];
if (
!hasOwnProperty.call(objB, currentKey) ||
!Object.is(objA[currentKey], objB[currentKey])
) {
return false;
}
}
return true;
}
对 memo 原理有了基本的了解之后,接下来回到我们上面的问题中,首先我们得对 ListItem 进行一个 memo 包装
import { memo } from "react";
// 注意看,这里用 memo 进行了包裹
const ListItem = memo(({ name, selected, onClick }) => {
expensiveOperation(selected);
return (
<li
style={selected ? { textDecoration: "line-through" } : undefined}
onClick={onClick}
>
{name}
</li>
);
});
好的,加上了这个 memo 之后我们再回去测一下性能看看表现如何
从图片的结果看,不仅没起到优化作用,渲染速度还变慢了? (为什莫,这到底是为什莫?)
其实道理很简单,我们的 ListItem 的 props 里面有一个引用数据类型,就是 onClick
这个事件函数(函数在 JS 中可以看作是一个对象,是引用数据类型),上面的图中我们也看到它提示了 onClick 这个 prop 发生了改变。但是实际上我们的 onClick 事件中的内容并没发生变化,为什么 memo 的比对结果还是 false?
实际上我们的点击方法是在 List 组件中,当点击列表 item 的时候就会调用该方法,然后就会触发重渲染
const List = ({ data, selectedItems, toggleItem }) => {
return (
<ul>
{data.map((item) => (
<ListItem
key={item.name}
name={item.name}
selected={selectedItems.includes(item)}
// 注意这个匿名函数,每次 re-render 的时候都会重新声明
// 实际就是声明了这样的函数 const handleClick = () => { toggleItem(item) }
// onClick={handleClick}
onClick={() => toggleItem(item)}
/>
))}
</ul>
);
}
另外要说的一个概念,就是渲染体和非渲染体,渲染体中的代码会在每次组件重渲染的时候都会重新执行,也包括下面的这个 handleClick 方法,也就是说,点击列表 item 后触发重渲染就会重新声明 handleClick 方法
export default function List() {
// 这里是渲染体
const handleClick = () => {
// 这里是非渲染体
}
return <ul>...</ul>
}
重渲染前的 handleClick 和重渲染完成之后的 handleClick 方法指向的内存地址实际发生了改变。
(() => {}) === (() => {}) // false
所以此时传递到 ListItem 组件中的 handleClick 方法已经发生变化了,就有了上面说到的 onClick prop changed 的问题。
useCallback
那么知道了问题,我们就要去解决,此时就可以使用 useCallback 这个 API 了,它的参数第一项接收一个需要缓存的函数,第二项接收一个依赖,当依赖发生变化的时候才会去重新声明 toggleItem 函数。我们首先要对代码进行一点点小改写。
1.首先是 App 组件中的 toggleItem 方法添加上 useCallback 包裹
export default function App() {
// 增加 useCallback 包裹
const toggleItem = useCallback((item) => {
if (!selected.includes(item)) {
setSelected([...selected, item]);
} else {
setSelected(selected.filter((current) => current !== item));
}
}, [selected]);
return ...
}
2.然后修改 List 组件中的 onClick 方法和 item 入参
const List = ({ data, selectedItems, toggleItem }) => {
return (
<ul>
{data.map((item) => (
<ListItem
key={item.id}
// 从 name={item.name} 换成 item={item}
item={item}
selected={selectedItems.includes(item)}
// 从 onClick={() => toggleItem(item)} 换成 toggleItem
onClick={toggleItem}
/>
))}
</ul>
);
};
3.最后修改 ListItem 组件中的 onClick 方法,并把点击的参数 item 作为回调往外传
// 入参中的 name -> item
const ListItem = memo(({ item, selected, onClick }) => {
expensiveOperation(selected);
return (
<li
style={selected ? { textDecoration: "line-through" } : undefined}
// 改写这里变成 onClick=>{onClick} -> onClick={() => onClick(item)}
// 为什么这里不需要用 useCallback 包裹呢?答案是没必要,加了反而降低性能,因为它传到的是原生标签
onClick={() => onClick(item)}
>
{item.name}
</li>
);
})
当我们修改完代码并加上 useCallback 之后,再去开发者工具中测一下:
我们发现并没起到任何的优化作用,渲染的时间还是和之前一样,onClick 还是改变了。这是为什么呢???
通过分析发现,每当我们调用了 setSelected 的时候, 外部的 selected 数组都发生了改变,selected 作为 toggleItem 的依赖项,然后又导致了其重新声明了。
解决办法其实很简单,就是不要把 selected 作为其依赖项,而去使用 useState 的函数式更新。
const toggleItem = useCallback((item) => {
// 修改这里
setSelected((prevSelected) => {
if (!prevSelected.includes(item)) {
return [...prevSelected, item];
} else {
return prevSelected.filter((current) => current !== item);
}
});
}, []);
改完之后我们再到开发者工具里看一哈
我们这里点击了 item1、2、3,可以看到,只有点击的对应组件本身进行了渲染,每次的渲染时间变成了10~20ms,对比之前 1s 的全量重渲染时间,快了贼多有没有?
useMemo
最后我们还要注意到,在 ListItem 中有一个循环计算,它在组件的渲染体内,这也是比较耗费性能的操作,假如我们只关注它第一次渲染时得到的结果,没必要在每次重渲染的时候都重新对它进行计算,所以此时可以使用 useMemo 包裹它,它就不会在每次重渲染的时候重新执行。
const ListItem = memo(({ item, selected, onClick }) => {
// 在 render-body 模拟真实应用进行一次复杂的计算
useMemo(() => expensiveOperation(), []);
return (
<li
style={selected ? { textDecoration: "line-through" } : undefined}
onClick={() => onClick(item)}
>
{item.name}
</li>
);
});
加了 useMemo 之后,我们可以看到组件的渲染时间变成了 4ms 左右了,比之前的十几毫秒更快了
其它问题
1.啥时候该用 React.memo?
当你的组件需要接收外部 props 的时候,建议统一都加上 memo,但是此时要关注是否有接收函数/对象/数组等引用数据类型的属性,如果有,就需要确保其使用 useCallback/useMemo 来保证其引用地址的唯一性,防止子组件不必要的重渲染。
2.啥时候该用 useCallback?
只有当一个函数要传递给使用 React.memo 包裹过的子组件的时候,才需要使用 useCallback 来包裹这个函数。
就像我们上面的例子一样, ListItem 是使用 memo 包裹过的,并且接收了一个外部的函数入参,这时候这个外部的函数才需要使用 useCallback包裹。
假如像这样的写法,就完全没必要用 useCallback,否则只会是负优化
export default function App() {
const [selected, setSelected] = useState(false);
const handleClick = () => {
setSelected(!selected);
}
return <div onClick={handleClick}>{selected ? 'true' : 'false'}</div>
}
为什么说上面的代码加了 useCallback 是负优化?我们先来看下源码里的这两个方法
当我们组件第一次挂载的时候,会把 useCallback 包裹的函数和依赖放到 hook 的缓存中,然后在组件重渲染的时候,会把缓存取出来进行比对,如果依赖没变化就返回之前缓存的函数,此时其缓存的函数引用地址没发生变化,如果依赖发生改变了再重新去缓存。
function mountCallback(callback, deps) {
const hook = mountWorkInProgressHook();
const nextDeps = deps === undefined ? null : deps;
hook.memoizedState = [callback, nextDeps];
return callback;
}
function updateCallback(callback, deps) {
const hook = updateWorkInProgressHook();
const nextDeps = deps === undefined ? null : deps;
const prevState = hook.memoizedState;
if (prevState !== null) {
if (nextDeps !== null) {
const prevDep = prevState[1];
// 对比依赖是否发生了改变
if (areHookInputsEqual(nextDeps, prevDeps)) {
return prevState[0];
}
}
}
hook.memoizedState = [callback, nextDeps];
return callback;
}
说它是负优化就是本身缓存就是会耗费一定的内存空间的,并且如果有依赖项,还会增加了一次对比的过程,并且这个 handleClick 方法传递给的是原生标签,并不会发生任何优化作用的行为,所以 useCallback 不能无脑去用。 useMemo 其实也是差不多的道理,很简单的计算量不大的逻辑就完全没必要用 useMemo 了。
3.当useCallback函数包裹的依赖项太多了怎么优化?
在真实的应用场景中往往事情没这么简单,比如我们在 App 组件中新增了一个 isEnabled Hook,并且 toggleItem 依赖了这个 isEnabled 状态,每当 isEnabled 发生改变的时候,都会触发 App 组件重渲染,此时 toggleItem 本身也会发生改变,然后就会去让 List 重渲染,接下来就是 ListItem 重渲染..., 产生了一连串的连锁反应,之前说的优化都白干了
export default function App() {
const [selected, setSelected] = useState([]);
const [isEnabled, setEnabled] = useState(false);
const toggleItem = useCallback(
(item) => {
// Only toggle the items if enabled
if (isEnabled) {
setSelected((prevSelected) => {
if (!prevSelected.includes(item)) {
return [...prevSelected, item];
} else {
return prevSelected.filter((current) => current !== item);
}
});
}
},
[isEnabled]
);
return (
<div className="App">
<button onClick={() => setEnabled(!isEnabled)}>
{isEnabled ? "关闭" : "打开"}选中操作
</button>
<h1>长列表 Demo</h1>
<List data={data} selectedItems={selected} toggleItem={toggleItem} />
</div>
);
}
那么该如何解决这个问题呢?
1.使用 useReducer
首先我们来看下 useReducer 重构过后的页面表现。当我们切换选中操作开关的时候,可以看到 ListItem 组件已经不像之前一样会重渲染了。
根据 新版 React 官方文档 的介绍,在多事件处理函数中更新多状态的组件,这种情况就很适合把状态更新的逻辑给合并到一个函数中,这个函数就叫 reducer,其实新版官网里写的相当详细,解释了很多问题,具体的可以自己进去看一下。
根据官网的最佳实践:
- useReducer 的 reducer 保证是一个纯函数(固定的输入总是会有固定的输出)
- 每一个 case 只处理一类事件
于是我们写了下面这样的一段代码:
// 使用 TS 的话可以用 enum 枚举
const REDUCER_TYPE = {
SET_IS_ENABLED: "setIsEnabled",
SET_SELECTED: "setSelected",
TOGGLE_ITEM: "toggleItem",
};
const appState = {
selected: [],
isEnabled: false,
};
const reducer = (state, action) => {
switch (action.type) {
case REDUCER_TYPE.SET_SELECTED: {
return {
...state,
selected: action.selected,
};
}
case REDUCER_TYPE.SET_IS_ENABLED: {
return {
...state,
isEnabled: action.isEnabled,
};
}
case REDUCER_TYPE.TOGGLE_ITEM: {
if (state.isEnabled) {
return {
...state,
selected: !state.selected.includes(action.item)
? [...state.selected, action.item]
: state.selected.filter((current) => current !== action.item),
};
} else {
return state;
}
}
default: {
throw Error("Unknown action: " + action.type);
}
}
};
export default function App() {
// const [selected, setSelected] = useState([]);
// const [isEnabled, setEnabled] = useState(false);
const [state, dispatch] = useReducer(reducer, appState);
// 修改这段代码为 dispatch 事件
const toggleItem = useCallback(
(item) => {
dispatch({
type: REDUCER_TYPE.TOGGLE_ITEM,
item,
});
},
[dispatch]
);
return (
<div className="App">
<button
onClick={() => {
// dispatch 事件
dispatch({
type: REDUCER_TYPE.SET_IS_ENABLED,
isEnabled: !state.isEnabled,
});
}}
>
// 从 state 中读取 isEnabled
{state.isEnabled ? "关闭" : "打开"}选中操作
</button>
<h1>长列表 Demo</h1>
<List
data={data}
// 从 state 中读取 selected
selectedItems={state.selected}
toggleItem={toggleItem}
/>
</div>
);
}
再来看下 useState 和 useReducer 的对比:
- useReducer 代码量会更多,但是如果多个事件函数中都要更新状态的时候,useReducer 写出的代码可能会更好维护,并且它也能很好解决一些重渲染的问题,因为它把状态的增删改都抽离于组件本身了
- 可读性一般来说是 useState 更强一些,但是当组件状态变多且复杂的时候,useReducer 可读性反而会更好(但是要保证每个action的细粒度),不过这样带来的代价也是代码量上升
- 从调试和测试的角度看也是 useReducer 更好,因为 useReducer 是纯函数不依赖于组件的渲染逻辑,而且可以很容易看到每一个 action 对应的状态改变发生的行为
2.使用状态管理库
如果你觉得用 useReducer 太麻烦的话(代码量太多),可以试试状态管理库。老牌的 Redux 就不用说了,其实和 reducer 的写法差不多。这里有几个比较新且好用的状态管理库。分别是 Jotai 12.1k⭐、 Zustand 28k⭐、Valtio 6.3k⭐ ,这三个状态管理库都是同一个团队开发的,而且用的人还挺多。其中 Jotai 和 Recoil 类似, Zustand 和 Redux 类似,Valtio 和 Mobx 类似,它们的名字分别是日语、 德语、芬兰语 中的 “状态”,这几个库和之前一些老牌的库比上手要更简单,而且使用起来更简洁,并且主打轻量级,gzip 后不超过 3kb。
上面提到的几个库本质上代表了3个流派:
dispatch 流派(单向数据流-中心化管理):redux、zustand、dva 等
响应式流派(中心化管理):mobx、valtio 等
原子状态流派(原子组件化管理):recoil、jotai 等
这里我们就以 zustand 为例, 这个包 gzip 之后只有 1.1k 的大小,真是小到不要不要的了。
我们现在就使用 zustand 来重构我们上面 useReducer 的例子
import { create } from "zustand";
import { shallow } from "zustand/shallow";
// 首先创建一个 Store
// 如果想要以可变的书写方式来返回不可变的数据,可以使用 immer 这个包
// 比如 set(produce(state => { state.isEnabled = !state.isEnabled; }))
// 或者使用 zustand 提供的 immer 中间件,可以使下面的代码更为简洁
const useAppStore = create((set) => ({
selected: [],
isEnabled: false,
changeIsEnabled: () => set((state) => ({ isEnabled: !state.isEnabled })),
toggleItem: (item) =>
set((state) => {
if (state.isEnabled) {
return {
...state,
selected: !state.selected.includes(item)
? [...state.selected, item]
: state.selected.filter((current) => current !== item),
};
} else {
return state.selected;
}
}),
}));
export default function App() {
// 取出里面的属性,当下面4个属性中的任意一个发生改变的时候就会触发页面重渲染,
// shallow 表示 oldState 和 newState 之间会进行浅比较来进行是否需要重渲染
const [selected, isEnabled, changeIsEnabled, toggleItem] = useAppStore(
(state) => [
state.selected,
state.isEnabled,
state.changeIsEnabled,
state.toggleItem,
],
shallow
);
return (
<div className="App">
<button onClick={changeIsEnabled}>
{isEnabled ? "关闭" : "打开"}选中操作
</button>
<h1>长列表 Demo</h1>
<List data={data} selectedItems={selected} toggleItem={toggleItem} />
</div>
);
}
我们再来看一下页面的表现,当我们切换 isEnabled 状态的时候,页面重渲染了,但是我们在 ListItem 并没发生重渲染,说明 toggleItem 这个函数并没有发生变化,这一切都是有 zustand 这个库在底层帮我们做好了优化 ,可以看到代码量也比自己用 useReducer 要少得多并且更加可读,当配合 immer 使用的时候代码还能更加简洁。
Zustand 在底层使用了 React 的 useSyncExternalStoreWithSelector(React 18内置) 这个 Hook,它可以让组件依据所选择的属性发生变化的时候进行重渲染,这也让 Zustand 不需要像 Redux 一样使用 Provider 来包裹组件,并且还能防止不必要的重渲染行为,使用起来更简单。