0%

React 性能优化之React.memo, useMemo, useCallback

我们在做项目的时候,通常会将页面拆分为无数个小组件(function components or class components),如果是小型项目,可能性能问题还不太明显,但如果是大型项目,比如交易所,需要极致的响应速度和性能,那么性能优化是逃不掉的,可能在不经意的情况下会造成多个组件多次重复触发重新渲染,会给浏览器造成较大的负担,用户体验也会下降。

现在用Function Component比较多,那么看下如何用React.memouseMemouseCallback来优化组件。

场景

首先,我们构造这样一个场景,父组件为一个输入框和一个统计点击的按钮,子组件接受父组件输入框的value:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import {ChangeEvent, useState} from "react";
import {Child} from "./Child";

export function Parent() {
const [number, setNumber] = useState<any>('');
const [count, setCount] = useState<number>(0);
const handleInput = (event: ChangeEvent) => {
const target = event.target as HTMLInputElement;
setNumber(target.value)
}
return (
<div style={{
width: '600px',
margin: '0 auto',
display: 'flex',
justifyContent: 'center',
alignItems: 'flex-start',
flexDirection: 'column',
gap: '20px',
}}>
<button onClick={() => setCount(count + 1)}>counter: {count}</button>
<form onSubmit={() => {
}}>
<input type={"text"} value={number} onChange={handleInput}/>
</form>
<Child index={number}/>
</div>
)
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import React, {useRef} from "react";

interface ChildProps{
index: number;
}

export function Child({index}: ChildProps) {
console.log(`render Child`, index);
const refCount = useRef(0);
refCount.current++;

return (
<div style={{border: '3px solid #ccc', color: '#fff', padding: '10px'}}>
render count : {refCount.current}, index: {index}
</div>
)
}

这里Child组件里面的refCount可以统计子组件渲染了多少次。

运行现在的代码,我们可以看到,当input里面输入内容的时候子组件会渲染,当点击count按钮的时候,子组件也会触发渲染。

React.memo

React.memo为高阶组件,可以直接将组件用React.memo包装起来,这样如果组件的props是相同的结果的话,就不会重新渲染,React将跳过渲染组件的操作并直接复用最近一次渲染的结果:

1
export default React.memo(Child);

或者如果不想import default的方式,那么可以导出:

1
export const ChildWithMemo = React.memo(Child);

我们修改一下,让在Parent组件中使用React.memo包装的Child。
首先修改Child组件导出ChildWithMemo:

1
export const ChildWithMemo = React.memo(Child);

然后在Parent组件中使用:

1
<ChildWithMemo index={number}/>

观察发现,只有当input输入值的时候,Child才会刷新,点击count按钮,Child不会刷新。

React.memo仅检查props的变更,但当组件中有实现useStateuseReduceruseContext的hook时,当state或context发生变化时也会重新渲染。

默认情况下是对props做浅比较(shallowly compare),如果props是obj类型的时候,比较的是引用地址,所以如果要精确控制props比较,需要传递一个function:

1
2
3
4
5
6
7
8
9
function areEqual(prevProps, nextProps) {
/*
如果把 nextProps 传入 render 方法的返回结果与
将 prevProps 传入 render 方法的返回结果一致则返回 true,
否则返回 false
*/
}

export default React.memo(Child, areEqual);

注意这里areEqual的控制和class组件的shouldComponentUpdate返回值是相反的,areEqual返回true表示props是相等的,不用渲染,只有返回false才会触发渲染。

比如,我们修改下Child组件接受的props参数:

1
2
3
4
interface ChildProps{
index: number;
count?: number;
}

在Parent里面调用Child组件的时候也把count传递进去:

1
<ChildWithMemo index={number} count={count}/>

这个时候运行观察,input输入和点击count按钮都会触发子组件的更新。

那么我们使用areEqual比较方法来排除不影响子组件变更的因素,修改Child组件:

1
2
3
4
5
6
7
8
function areEqual(prevProps: ChildProps, nextProps: ChildProps) {
if (prevProps.index === nextProps.index) {
return true;
}
return false;

}
export const ChildWithMemo = React.memo(Child, areEqual);

然后运行观察,确实是只有当input输入了才会触发Child组件重新渲染,点击count是不渲染的。这样达到了我们的目的,只有当特定props更新的时候才触发子组件的更新。

useMemo

导致组件重新渲染的不仅仅是父组件传递进来的props,组件内部的状态变更的时候也会重新渲染,这时候假设有复杂的程序在每次渲染的时候执行也会造成性能开销,所以我们可以用useMemo这个hook在组件内部更细粒的控制渲染。

useMemo返回一个记忆的值(memoized),把创建函数和依赖项数组作为参数传入useMemo,它仅会在某个依赖项改变的时候才重新计算memoized值,这有助于避免在每次渲染时都进行的高开销计算。

useMemo是在渲染期间执行的,它和useEffect的区别是,useEffect会执行一些副作用导致开始再次渲染,useMemo仅仅是在这个渲染期间内的一些操作,不应该有副作用。

我们可以直接改写上面Parent组件中使用Child的方式:

1
2
3
4
5
6
7
8
export function Home() {
// ....
const ChildUseMemo = useMemo(() => (<Child index={number} count={count}/>), [number]);
return (
//...
{ChildUseMemo}
)
}

这样只有当number变化的时候,Child组件才会更新。

另一个很有用的场景是在Provider一个Context的时候,比如我们有一个WalletSelectorProvider

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
import React, {useContext, useMemo, useState} from "react";

export interface WalletSelectorContextValue {
selector: string;
accountId: string;
}

export const WalletSelectorContext = React.createContext<WalletSelectorContextValue | null>(null);

interface WalletSelectorProviderProps {
children: any;
}

export const WalletSelectorContextProvider = ({children}: WalletSelectorProviderProps) => {
const [selector, setSelector] = useState<string>('');
const [accountId, setAccountId] = useState<string>('');
const [other, setOther] = useState<string>('');
const providerValue = useMemo(() => ({
selector,
accountId,
}), [accountId, selector]);

const changeAccountId = () => {
setAccountId('tony' + Math.floor(Math.random() * 100))
}
const changeSelector = () => {
setSelector('metamask');
}
const changeOther = () => {
setOther(Math.random().toString());

}
return (
<WalletSelectorContext.Provider value={{selector, accountId}}>
<button onClick={changeAccountId}>set accountId</button>
<button onClick={changeSelector}>set selector</button>
<button onClick={changeOther}>set other</button>
{children}
</WalletSelectorContext.Provider>
)
}

export function useWalletSelector() {
const context = useContext(WalletSelectorContext);
if (!context) {
throw new Error('use wallet selector must be used within a WalletSelectorContextProvider');
}
console.log('context', context)
return context;
}

我们通过这个provider传递了一个context,里面包含了accountIdselector这两个变量,我们创建了三个按钮,来分别改变accountIdselector、和other

accountIdselector是传递给子组件的context,other是我们特意创建用来使WalletSelector触发渲染的,充当影响因素。

然后在App.tsx中应用这个provider:

1
2
3
4
5
6
7
8
9
10
function App() {
return (
<div className="App">
<WalletSelectorContextProvider>
<Parent/>
</WalletSelectorContextProvider>

</div>
);
}

并且在Child组件中通过useWalletSelector获取context:

1
2
3
4
export function Parent() {
// ...
const {accountId} = useWalletSelector();
}

现在运行这个代码,当我们点击三个按钮的时候,Parent都会重新渲染。

但我们为了优化性能,需要只有当accountIdselector变换的时候才去渲染其他子元素,WalletSelector的其他变量编号不要去触发。

那我们可以用useMemo封装传递的Context变量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
export const WalletSelectorContextProvider = ({children}: WalletSelectorProviderProps) => {

const providerValue = useMemo(() => ({
selector,
accountId,
}), [accountId, selector]);

return (
// <WalletSelectorContext.Provider value={{selector, accountId}}>
<WalletSelectorContext.Provider value={providerValue}>
<button onClick={changeAccountId}>set accountId</button>
<button onClick={changeSelector}>set selector</button>
<button onClick={changeOther}>set other</button>
{children}
</WalletSelectorContext.Provider>
)
}

这样再次运行,可以看到只有点击accountIdselector按钮的时候才会触发子组件的渲染,点击other按钮的时候,是不会触发的。

useCallback

useCallback返回一个memoized函数。它将返回该回调函数的memoized版本,该回调函数仅在某个依赖项改变时才会更新。当把回调函数传递给经过优化的并使用相等性去避免非必要渲染的子组件时,它非常有用。

useCallback(fn, deps)相当于useMemo(() => fn, deps)

我们来改造下Child组件和Parent组件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import {useState} from "react";
import {Child} from "./Child";

export function Parent() {
const [count, setCount] = useState<number>(0);
const [index, setIndex] = useState<number>(0)

const addIndex = () => {
setIndex(i => i + 1);
}
return (
<div style={{
width: '600px',
margin: '0 auto',
display: 'flex',
justifyContent: 'center',
alignItems: 'flex-start',
flexDirection: 'column',
gap: '20px',
}}>
<button onClick={() => setCount(count + 1)}>counter: {count}</button>

<Child onAdd={addIndex} index={index}/>
</div>
)
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import React, {useRef} from "react";

interface ChildProps {
onAdd:() => void;
index?: number;
}

export function Child({index, onAdd}: ChildProps) {
console.log(`render Child`);
const refCount = useRef(0);
refCount.current++;

return (
<div style={{border: '3px solid #ccc', color: '#fff', padding: '10px'}}>
<button onClick={onAdd}>add</button>
render count : {refCount.current}, index: {index}
</div>
)
}

我们从父组件传递了一个function给子组件,父组件中的count还是作为干扰项让父组件重新更新。

现在运行的时候,不管我们点击add按钮还是count按钮,子组件都会更新。

我们来给子组件加上memo:

1
export const ChildWithMemo = React.memo(Child);

在Parent组件中使用:

1
<ChildWithMemo onAdd={addIndex} index={index}/>

现在如果点击的话,还是老样子,好像没什么变化,点击任何一个按钮都会更新子组件。

点击add按钮更新子组件是没问题的,因为index的变化,导致子组件更新。
但是count是父组件的内容,和子组件是没关系的,我们要优化性能,让在点击count的时候,子组件不要去渲染。

那么点击count按钮的时候为什么子组件会更新?是因为我们给子组件传递了一个function的props,当Parent组件更新的时候,addIndex 会重新赋值,子组件接受的onAdd是一个新的引用地址。

当然我们可以通过isEqual方法去避免,如果遇到更复杂的场景可能会比较麻烦。

我们这时候可以用useCallbackmemo结合来实现。

修改父组件中的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import {useCallback, useState} from "react";
import {ChildWithMemo} from "./Child";

export function Parent() {
const [count, setCount] = useState<number>(0);
const [index, setIndex] = useState<number>(0)

const addIndex = useCallback(() => {
setIndex(i => i + 1);
}, []);
return (
<div style={{
width: '600px',
margin: '0 auto',
display: 'flex',
justifyContent: 'center',
alignItems: 'flex-start',
flexDirection: 'column',
gap: '20px',
}}>
<button onClick={() => setCount(count + 1)}>counter: {count}</button>

<ChildWithMemo onAdd={addIndex} index={index}/>
</div>
)
}

我们将父组件中的addIndex修改为了useCallback包装的方法,再次运行,发现点击count按钮时,子组件不更新,只有点击add按钮的时候子组件才更新。


React函数式组件确实是方便强大,结合丰富的hooks,我们可以简短精悍的组件,但稍不注意就陷入了性能的陷阱。充分掌握React.memouseMemouseCallback可以让我们写出性能更好的React代码,这里只是结合官方文档和参照一些博客用简单的例子来理解,后续还得在实践中多应用才能更好的掌握他们。

码字辛苦,打赏个咖啡☕️可好?💘