前言
本文会从现象到本质讲解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和第二参数的依赖项并没有直接关系,这里也是最容易出问题的地方。一般是因为漏加依赖导致执行结果与预期不符。但是全加又容易导致达不到性能优化预期。
- 依赖项,决定了缓存对象的更新节点。需要更新则需要依赖,反之则不需要。