首頁 > 軟體

記一個React.memo引起的bug

2022-03-08 13:00:16

與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的使用要謹慎


IT145.com E-mail:sddin#qq.com