记一个React.memo引起的bug
目录
- 一般memo用法:
- 问题描述
- 解决方法
- 方案一.使用useRef+forceUpdate方案
- 方案2.使用useCallback
- 总结
与PureComponent不同的是PureComponent只是进行浅对比props来决定是否跳过更新数据这个步骤,memo可以自己决定是否更新,但它是一个函数组件而非一个类,但请不要依赖它来“阻止”渲染,因为这会产生 bug。
一般memo用法:
import React from "react"; function MyComponent({props}){ console.log('111); return ( <div> {props} </div> ) }; function areEqual(prevProps, nextProps) { if(prevProps.seconds===nextProps.seconds){ return true }else { return false } } export default React.memo(MyComponent,areEqual)
问题描述
我们在处理业务需求时,会用到memo来优化组件的渲染,例如某个组件依赖自身的状态即可完成更新,或仅在props中的某些数据变更时才需要重新渲染,那么我们就可以使用memo包裹住目标组件,这样在props没有变更时,组件不会重新渲染,以此来规避不必要的重复渲染。
下面是我创建的一个公共组件:
type Props = { inputDisable?: boolean // 是否一直展示输入框 inputVisible?: boolean value: any min: number max: number onChange: (v: number) => void } const InputNumber: FC<Props> = memo( (props: Props) => { const { inputDisable, max, min, value, inputVisible } = props const handleUpdate = (e: any, num) => { e.stopPropagation() props.onChange(num) } return ( <View className={styles.inputNumer}> {(value !== 0 || inputVisible) && ( <> <Image className={styles.btn} src={require(value <= min ? '../../assets/images/reduce-no.png' : '../../assets/images/reduce.png')} onClick={e => handleUpdate(e, value - 1)} mode='aspectFill' /> <Input value={value} disabled={inputDisable} alwaysEmbed type='number' cursor={-1} onInput={e => handleUpdate(e, parseInt(e.detail.value ? e.detail.value : '0'), 'input')} /> </> )} <Image className={styles.btn} src={require(max !== -1 && (value >= max || min > max) ? '../../assets/images/plus-no.png' : '../../assets/images/plus.png')} onClick={e => handleUpdate(e, value + 1)} /> </View> ) }, (prevProps, nextProps) => { return prevProps.value === nextProps.value && prevProps.min === nextProps.min && prevProps.max === nextProps.max } ) export default InputNumber
这个组件是一个自定义的数字选择器,在memo的第二个参数中设置我们需要的参数,当这些参数有变更时,组件才会重新渲染。
在下面是我们用到这个组件的场景。
type Props = { info: any onUpdate: (items) => void } const CartBrand: FC<Props> = (props: Props) => { const { info } = props const [items, setItems] = useState<any>( info.items.map(item => { // selected默认为false return { num:1, selected: false } }) ) useEffect(() => { getCartStatus() }, []) // 获取info.items中没有提供,但是展示需要的数据 const getCartStatus = () => { setTimeout(() => { setItems( info.items.map(item => { //更新selected为true return {num: 1, selected: true } }) ) }, 1000) } return ( <View className={styles.brandBox}> {items.map((item: GoodSku, index: number) => { return ( <InputNumber key={item.skuId} inputDisable min={0} max={50} value={item.num} onChange={v => { console.log(v, item.selected) }} /> ) })} </View> ) } export default CartBrand
这个组件的目的是展示props传过来的列表,但是列表中有些数据服务端没有给到,需要你再次通过另一个接口去获取,我用settimeout替代了获取接口数据的过程。为了让用户在获取接口的过程中不需要等待,我们先根据props的数据给items设置了默认值。然后在接口数据拿到后再更新items。
但几秒钟后我们在子组件InputNumber中更新数据,会看到:
selected依然是false!
这是为什么呢?前面不是把items中所有的selected都改为true了吗?
我们再打印一下items看看:
似乎在InputNumber中的items依然是初始值。
对于这一现象,我个人理解为memo使用的memoization算法存储了上一次渲染的items数值,由于InputNumber没有重新渲染,所以在它的本地状态中,items一直是初始值。
解决方法
方案一. 使用useRef + forceUpdate方案
我们可以使用useRef来保证items一直是最新的,讲useState换为useRef
type Props = { info: any onUpdate: (items) => void } const CartBrand: FC<Props> = (props: Props) => { const { info } = props const items = useRef<any>( info.items.map(item => { // selected默认为false return { num:1, selected: false } }) ) useEffect(() => { getCartStatus() }, []) // 获取info.items中没有提供,但是展示需要的数据 const getCartStatus = () => { setTimeout(() => { items.current = info.items.map(() => { return { num: 1, selected: true } }) }, 1000) } return ( <View className={styles.brandBox}> {items.current.map((item: GoodSku, index: number) => { return ( <InputNumber key={item.skuId} inputDisable min={0} max={50} value={item.num} onChange={v => { console.log(v, items) }} /> ) })} </View> ) } export default CartBrand
这样再打印的时候我们会看到
items中的selected已经变成true了
但是此时如果我们需要根据items中的selected去渲染不同的文字,会发现并没有变化。
return ( <View className={styles.brandBox}> {items.current.map((item: GoodSku, index: number) => { return ( <View key={item.skuId}> <View>{item.selected ? '选中' : '未选中'}</View> <InputNumber inputDisable // 最小购买数量 min={0} max={50} value={item.num} onChange={() => { console.log('selected', items) }} /> </View> ) })} </View> )
显示还是未选中
这是因为useRef的值会更新,但不会更新他们的 UI,除非组件重新渲染。因此我们可以手动更新一个值去强制让组件在我们需要的时候重新渲染。
const CartBrand: FC<Props> = (props: Props) => { const { info } = props // 定义一个state,它在每次调用的时候都会让组件重新渲染 const [, setForceUpdate] = useState(Date.now()) const items = useRef<any>( info.items.map(item => { return { num: 1, selected: false } }) ) useEffect(() => { getCartStatus() }, []) const getCartStatus = () => { setTimeout(() => { items.current = info.items.map(() => { return { num: 1, selected: true } }) setForceUpdate() }, 5000) } return ( <View className={styles.brandBox}> {items.current.map((item: GoodSku, index: number) => { return ( <View key={item.skuId}> <View>{item.selected ? '选中' : '未选中'}</View> <InputNumber inputDisable // 最小购买数量 min={0} max={50} value={item.num} onChange={() => { console.log('selected', items) }} /> </View> ) })} </View> ) } export default CartBrand
这样我们就可以使用最新的items,并保证items相关的渲染不会出错
方案2. 使用useCallback
在InputNumber这个组件中,memo的第二个参数,我没有判断onClick回调是否相同,因为无论如何它都是不同的。
参考这个文章:use react memo wisely
函数对象只等于它自己。让我们通过比较一些函数来看看:
function sumFactory() { return (a, b) => a + b; } const sum1 = sumFactory(); const sum2 = sumFactory(); console.log(sum1 === sum2); // => false console.log(sum1 === sum1); // => true console.log(sum2 === sum2); // => true
sumFactory()是一个工厂函数。它返回对 2 个数字求和的函数。
函数sum1和sum2由工厂创建。这两个函数对数字求和。但是,sum1和sum2是不同的函数对象(sum1 === sum2is false)。
每次父组件为其子组件定义回调时,它都会创建新的函数实例。在自定义比较函数中过滤掉onClick固然可以规避掉这种问题,但是这也会导致我们上述的问题,在前面提到的文章中,为我们提供了另一种解决思路,我们可以使用useCallback来缓存回调函数:
type Props = { info: any onUpdate: (items) => void } const CartBrand: FC<Props> = (props: Props) => { const { info } = props const [items, setItems] = useState( info.items.map(item => { return { num: 1, selected: false } }) ) useEffect(() => { getCartStatus() }, []) // 获取当前购物车中所有的商品的库存状态 const getCartStatus = () => { setTimeout(() => { setItems( info.items.map(() => { return { num: 1, selected: true } }) ) }, 5000) } // 使用useCallback缓存回调函数 const logChange = useCallback( v => { console.log('selected', items) }, [items] ) return ( <View className={styles.brandBox}> {items.map((item: GoodSku, index: number) => { return ( <View key={item.skuId}> <InputNumber inputDisable // 最小购买数量 min={0} max={50} value={item.num} onChange={logChange} /> </View> ) })} </View> ) }
相应的,我们可以把InputNumber的自定义比较函数去掉。
type Props = { inputDisable?: boolean // 是否一直展示输入框 inputVisible?: boolean value: any min: number max: number onChange: (v: number) => void } const InputNumber: FC<Props> = memo( (props: Props) => { const { inputDisable, max, min, value, inputVisible } = props const handleUpdate = (e: any, num) => { e.stopPropagation() props.onChange(num) } return ( <View className={styles.inputNumer}> {(value !== 0 || inputVisible) && ( <> <Image className={styles.btn} src={require(value <= min ? '../../assets/images/reduce-no.png' : '../../assets/images/reduce.png')} onClick={e => handleUpdate(e, value - 1)} mode='aspectFill' /> <Input value={value} disabled={inputDisable} alwaysEmbed type='number' cursor={-1} onInput={e => handleUpdate(e, parseInt(e.detail.value ? e.detail.value : '0'), 'input')} /> </> )} <Image className={styles.btn} src={require(max !== -1 && (value >= max || min > max) ? '../../assets/images/plus-no.png' : '../../assets/images/plus.png')} onClick={e => handleUpdate(e, value + 1)} /> </View> ) } ) export default InputNumber
这样在items更新的时候,inputNumber也会刷新,不过在复杂的逻辑中,比如items的结构非常复杂,items中很多字段都会有高频率的改变,那这种方式会减弱InputNumber中memo的效果,因为它会随着items的改变而刷新。
总结
在最后,我还是选择了方案一解决这个问题。同时提醒自己,memo的使用要谨慎
到此这篇关于记一个React.memo引起的bug的文章就介绍到这了,更多相关React memo bug内容请搜索我们以前的文章或继续浏览下面的相关文章希望大家以后多多支持我们!