前言
本文会从现象到本质讲解React.memo
,useMemo
,useCallback
的功能性与应用场景,让小白少走弯路。
写给小白:当你不知道你这里为什么要使用这个方法的时候,你便不要使用它!
React.memo
ts
function memo<P extends object>(
Component: FunctionComponent<P>,
propsAreEqual?: (prevProps: Readonly<P>, nextProps: Readonly<P>) => boolean,
): NamedExoticComponent<P>;
function memo<T extends ComponentType<any>>(
Component: T,
propsAreEqual?: (prevProps: Readonly<ComponentProps<T>>, nextProps: Readonly<ComponentProps<T>>) => boolean,
): MemoExoticComponent<T>;
先来一个简单的demo
父组件两个状态,子组件接收其中一个并直接打印随机数来判断子组件渲染情况;
如果子组件随机数变化了,则表明子组件重新渲染了。
tsx
function Demo1() {
const [count, setCount] = useState(0)
const [childrenCount, setChildrenCount] = useState(0)
return (
<div>
<Button onClick={() => setCount(count + 1)}>count + 1</Button> <br />
<Button onClick={() => setChildrenCount(childrenCount + 1)}>childrenCount + 1</Button> <br />
<Demo1_1 count={childrenCount} />
<Demo1_2 count={childrenCount} />
</div>
)
}
const Demo1_1 = (props: {count: number}) => {
return <div>Demo1_1: { Math.random() }</div>
}
const Demo1_2 = React.memo((props: {count: number}) => {
return <div>Demo1_2: { Math.random() }</div>
})
我们通过分别点击两个按钮可以看到,
Demo1_1
在父组件任意状态变化的情况下都进行了重渲染,Demo1_2
仅在接收的props变化的时候才会进行重渲染;如此,我们便可以使用
React.memo
来减少父级状态变化给子组件带来没必要的渲染,从而优化性能。
React.memo 第二参数
React.memo支持传入第二个参数propsAreEqual
,一个函数,入参为变化前后props
,出参为布尔值。我们添加 Demo1_3
,并再次修改父组件状态:
tsx
const Demo1_3 = React.memo((props: {count: number}) => {
return <div>Demo1_3: {Math.random()} | count: {props.count}</div>
}, (prevProps, nextProps) => {
return nextProps.count > 3
})
我们通过修改父组件
childrenCount
状态可以看到,propsAreEqual
返回值为true
时,子组件会认为prevProps
与nextProps
是等价的,便不会重渲染,从而优化性能。注意
prevProps
与nextProps
本身是不全等的!如果返回prevProps === nextProps
则结果永远为false
需要按字段逐个判断,如:
prevProps.count === nextProps.count && ...
第二参数通常不需要手动维护,默认会逐个比较
props
中每个key
useCallback
ts
function useCallback<T extends Function>(callback: T, deps: React.DependencyList): T
可以看到,useCallback
接受一个函数和一组依赖,并返回一个函数
先来一个简单的Demo
父组件自身有一个可改变的状态,子组件接收一个方法props
,分别按内联、定义函数、useCallback 三种方式传入子组件,子组件渲染随机数。
如果子组件随机数变化,则表示子组件进行了重渲染。
tsx
function Demo2() {
const [count, setCount] = useState(0)
const handleClick = () => { }
const handleClickCallback = useCallback(() => { }, [])
return (
<div>
<Button onClick={() => setCount(count + 1)}>count + 1</Button> <br />
<Demo2_1 mark={1} onClick={() => { }}/>
<Demo2_1 mark={2} onClick={handleClick} />
<Demo2_1 mark={3} onClick={handleClickCallback} />
</div>
)
}
const Demo2_1 = React.memo((props: {onClick: () => void, mark: number}) => {
return <div>mark: {props.mark} { Math.random() }</div>
})
我们通过点击按钮可以看到,在父组件任意状态变化的情况下
mark1
,mark2
进行了重渲染,mark3
不会进行重渲染;根据
React.memo
的执行逻辑可以推断,是父组件更新state后,导致传入子组件的onClick参数变化,从而触发子组件的重渲染。
count
变化会导致Demo2
重新执行mark1
子组件会在Demo2
执行的时候,接收到一个全新匿名函数作为propsmark2
子组件会在Demo2
执行的时候,接收到一个参数handleClick
指向一个全新匿名函数作为propsmark3
子组件会在Demo2
执行的时候,接收到一个参数handleClickCallback
指向useCallback
执行创建的函数缓存,函数缓存不更新,则handleClickCallback指向不变。如此便知道,我们可以通过
useCallback
创建函数缓存,来避免函数重复创建和销毁带来的开销,同时,将函数缓存传入React.memo
包装的子组件,可以避免函数指向变化带来子组件额外的渲染,从而提升性能。
useCallback 第二参数
useCallback支持传入第二个参数deps: React.DependencyList
,一个数组,如果不传递,会在每次useCallback
执行的时候创建一个全新的函数缓存,如此,便与不使用useCallback
表现的现象一致,同时还会增加创建和销毁函数缓存的开销,造成反向优化的效果。
传入第二参数后,useCallback
执行时会根据dep元素变化而更新返回的函数缓存。
我们写一个Demo3
,分别在依赖项中传入和不传入状态依赖,传入的事件打印父级状态
tsx
function Demo3() {
const [childrenCount, setChildrenCount] = useState(0)
const handleClickCallback4 = useCallback(() => console.log(childrenCount), [childrenCount])
const handleClickCallback5 = useCallback(() => console.log(childrenCount), [])
return (
<div>
<Button onClick={() => setChildrenCount(childrenCount + 1)}>childrenCount + 1 count={childrenCount}</Button> <br />
<Demo3_1 mark={4} onClick={handleClickCallback4} />
<Demo3_1 mark={5} onClick={handleClickCallback5} />
</div>
)
}
const Demo3_1 = React.memo((props: {onClick: () => void, mark: number}) => {
return <div onClick={props.onClick}>mark: {props.mark} { Math.random() }</div>
})
我们通过修改父级状态后,再点击子组件查看打印结果可以得到如下现象:
mark4
在每次父组件状态更新后,点击子组件可以正常取到当前父组件的childrenCount
,但是子组件会随着父组件状态更新而重渲染mark5
不会随着父组件状态更新重渲染,但是点击子组件后,也没办法获取到当前父组件的childrenCount
,只能获取初始化的值。
如此看来,在子组件未用到父组件状态,但接收的方法用到父组件状态的情况下,似乎无法做到有效渲染优化。
实际上,针对这个场景,并不是没有解决方案:
通常方案:useRef 缓存state
使用ref缓存最新state值,并在state更新的时候同步更新ref值。在
useCallback
里从ref中取值即可
tsx
const [childrenCount, stateSetChildrenCount] = useState(0)
const childrenCountRef = useRef(0)
const setChildrenCount = useCallback((value: number) => {
childrenCountRef.current = value
stateSetChildrenCount(value)
}, [])
const handleClickCallback6 = useCallback(() => {
console.log(childrenCountRef.current)
}, [])
邪修方案:setState中取最新state。但是:不推荐!不推荐!不推荐!
使用setState的回调函数来获取最新state
tsx
const [childrenCount, setChildrenCount] = useState(0)
const handleClickCallback7 = useCallback(() => {
setChildrenCount(prevState => {
console.log(prevState)
return prevState
})
}, [])
useMemo
ts
function useMemo<T>(factory: () => T, deps: DependencyList): T;
可以看到,useMemo
接受一个函数和一组依赖,并返回函数的返回值。
聪明的小伙伴会发现,如果 T extends Function
,那么useMemo
的返回便与useCallback
的返回一般无二了。
事实也是如此:如下写法 someFunction
的实际效果是一样的,但是:不推荐!不推荐!不推荐!
tsx
const someFunction = useCallback((params: any) => {}, [])
const someFunction = useMemo(() => (params: any) => {}, [])
实际使用中,如果返回值为函数类型,便用useCallback
,其它类型再使用useMemo
。如此,便与期望一致。
另外,请不要,请不要,请不要
在useMemo
中执行setState
操作,如有需要,请移步useEffect
和useCallback
,这是个规范性的问题。
其它使用方式可参考上文useCallback
。
易错点与矛盾点
-
那是不是所有的react函数组件都需要使用
React.memo
包装一遍?- 视情况而定,
React.memo
本质是一个高阶函数,本身便会带来有限的性能开销。 - 如果是一个有很多state的大页面,包裹一个有很多state的大组件,使用
React.memo
会减少大量的渲染开销。 - 如果是一个无状态页面,包裹一个子组件,自然没必要使用
React.memo
。 - 虽然是两个极端,实际还得通过实际情况做判断。如果上述示例中,子组件本身就存在跟随父组件重渲染的需求:实时更新随机数。那便不能使用
React.memo
。 - 大部分情况,小页面都不会复杂到必须使用
React.memo
来优化性能的地步。
- 视情况而定,
-
那是不是所有的react函数组件内部变量都需要使用
useCallback
、useMemo
?- 视情况而定,同理,这些hook本身便会带来有限的性能开销。
- 如有一长段数据处理方法,需要页面指定state变化的时候才执行,则用
useMemo
封装会减少大量重复计算开销。 - 如父组件有大量state变化,需要传递一个简单的方法作为props到子组件,则用
useCallback
封装会减少大量函数定义和销毁。 - 如果你对这些hook使用并不熟练,则不需要使用这些hook,牺牲性能带来系统稳定。
-
那是不是
useCallback
、useMemo
中所有使用到的state都需要作为第二参数依赖项?- 并不是,本质上来说,使用到的state和第二参数的依赖项并没有直接关系,这里也是最容易出问题的地方。一般是因为漏加依赖导致执行结果与预期不符。但是全加又容易导致达不到性能优化预期。
- 依赖项,决定了缓存对象的更新节点。需要更新则需要依赖,反之则不需要。