React Hook + TS 購物車實戰(性能優化、閉包陷阱、自定義hook)

前言

本文由一個基礎的購物車需求展開,一步一步帶你深刻理解React Hook中的坑和優化react

經過本篇文章你能夠學到:ios

✨React Hook + TypeScript編寫業務組件的實踐git

✨如何利用React.memo優化性能github

✨如何避免Hook帶來的閉包陷阱axios

✨如何抽象出簡單好用的自定義hookapi

預覽地址

sl1673495.github.io/react-cart性能優化

代碼倉庫

本文涉及到的代碼已經整理到github倉庫中,用cra搭建了一個示例工程,關於性能優化的部分能夠打開控制檯查看重渲染的狀況。bash

github.com/sl1673495/r…閉包

需求分解

做爲一個購物車需求,那麼它必然涉及到幾個需求點:函數

  1. 勾選、全選與反選。
  2. 根據選中項計算總價。

gif1

需求實現

獲取數據

首先咱們請求到購物車數據,這裏並非本文的重點,能夠經過自定義請求hook實現,也能夠經過普通的useState + useEffect實現。

const getCart = () => {
  return axios('/api/cart')
}
const { 
  // 購物車數據
  cartData,
  // 從新請求數據的方法
  refresh 
} = useRequest<CartResponse>(getCart)
複製代碼

勾選邏輯實現

咱們考慮用一個對象做爲映射表,經過checkedMap這個變量來記錄全部被勾選的商品id:

type CheckedMap = {
  [id: number]: boolean
}
// 商品勾選
const [checkedMap, setCheckedMap] = useState<CheckedMap>({})
const onCheckedChange: OnCheckedChange = (cartItem, checked) => {
  const { id } = cartItem
  const newCheckedMap = Object.assign({}, checkedMap, {
    [id]: checked,
  })
  setCheckedMap(newCheckedMap)
}
複製代碼

計算勾選總價

再用reduce來實現一個計算價格總和的函數

// cartItems的積分總和
 const sumPrice = (cartItems: CartItem[]) => {
    return cartItems.reduce((sum, cur) => sum + cur.price, 0)
 }
複製代碼

那麼此時就須要一個過濾出全部選中商品的函數

// 返回已選中的全部cartItems
const filterChecked = () => {
  return (
    Object.entries(checkedMap)
      // 經過這個filter 篩選出全部checked狀態爲true的項
      .filter(entries => Boolean(entries[1]))
      // 再從cartData中根據id來map出選中列表
      .map(([checkedId]) => cartData.find(({ id }) => id === Number(checkedId)))
  )
}
複製代碼

最後把這倆函數一組合,價格就出來了:

// 計算禮享積分
  const calcPrice = () => {
    return sumPrice(filterChecked())
  }
複製代碼

有人可能疑惑,爲何一個簡單的邏輯要抽出這麼幾個函數,這裏我要解釋一下,爲了保證文章的易讀性,我把真實需求作了簡化。

在真實需求中,可能會對不一樣類型的商品分別作總價計算,所以filterChecked這個函數就不可或缺了,filterChecked能夠傳入一個額外的過濾參數,去返回勾選中的商品的子集,這裏就再也不贅述。

全選反選邏輯

有了filterChecked函數之後,咱們也能夠輕鬆的計算出派生狀態checkedAll,是否全選:

// 全選
const checkedAll = cartData.length !== 0 && filterChecked().length === cartData.length
複製代碼

寫出全選和反全選的函數:

const onCheckedAllChange = newCheckedAll => {
  // 構造新的勾選map
  let newCheckedMap: CheckedMap = {}
  // 全選
  if (newCheckedAll) {
    cartData.forEach(cartItem => {
      newCheckedMap[cartItem.id] = true
    })
  }
  // 取消全選的話 直接把map賦值爲空對象
  setCheckedMap(newCheckedMap)
}
複製代碼

若是是

  • 全選 就把checkedMap的每個商品id都賦值爲true。
  • 反選 就把checkedMap賦值爲空對象。

渲染商品子組件

{cartData.map(cartItem => {
  const { id } = cartItem
  const checked = checkedMap[id]
  return (
      <ItemCard key={id} cartItem={cartItem} checked={checked} onCheckedChange={onCheckedChange} /> ) })} 複製代碼

能夠看出,是否勾選的邏輯就這樣輕鬆的傳給了子組件。

React.memo性能優化

到了這一步,基本的購物車需求已經實現了。

可是如今咱們有了新的問題。

這是React的一個缺陷,默認狀況下幾乎沒有任何性能優化。

咱們來看一下動圖演示:

gif3

購物車此時有3個商品,看控制檯的打印,每次都是以3爲倍數增加每點擊一次checkbox,都會觸發全部子組件的從新渲染。

若是咱們有50個商品在購物車中,咱們改了其中某一項的checked狀態,也會致使50個子組件從新渲染。

咱們想到了一個api: React.memo,這個api基本等效於class組件中的shouldComponentUpdate,若是咱們用這個api讓子組件只有在checked發生改變的時候再從新渲染呢?

好,咱們進入子組件的編寫:

// memo優化策略
function areEqual(prevProps: Props, nextProps: Props) {
  return (
    prevProps.checked === nextProps.checked
  )
}

const ItemCard: FC<Props> = React.memo(props => {
  const { checked, onCheckedChange } = props
  return (
    <div> <checkbox value={checked} onChange={(value) => onCheckedChange(cartItem, value)} /> <span>商品</span> </div> ) }, areEqual) 複製代碼

在這種優化策略下,咱們認爲只要先後兩次渲染傳入的props中的checked相等,那麼就不去從新渲染子組件。

React Hook的陳舊值致使的bug

到這裏就完成了嗎?其實,這裏是有bug的。

咱們來看一下bug還原:

gif2

若是咱們先點擊了第一個商品的勾選,再點擊第二個商品的勾選,你會發現第一個商品的勾選狀態沒了。

在勾選了第一個商品後,咱們此時的最新的checkedMap實際上是

{ 1: true }
複製代碼

而因爲咱們的優化策略,第二個商品在第一個商品勾選後沒有從新渲染,

注意React的函數式組件,在每次渲染的時候都會從新執行,從而產生一個閉包環境。

因此第二個商品拿到的onCheckedChange仍是前一次渲染購物車這個組件的函數閉包中的,那麼checkedMap天然也是上一次函數閉包中的最初的空對象。

const onCheckedChange: OnCheckedChange = (cartItem, checked) => {
    const { id } = cartItem
    // 注意,這裏的checkedMap仍是最初的空對象!!
    const newCheckedMap = Object.assign({}, checkedMap, {
      [id]: checked,
    })
    setCheckedMap(newCheckedMap)
  }
複製代碼

所以,第二個商品勾選後,沒有按照預期的計算出正確的checkedMap

{ 
  1: true, 
  2: true
} 
複製代碼

而是計算出了錯誤的

{ 2: true }
複製代碼

這就致使了第一個商品的勾選狀態被丟掉了。

這也是React Hook的閉包帶來的臭名昭著陳舊值的問題。

那麼此時有一個簡單的解決方案,在父組件中用React.useRef把函數經過一個引用來傳遞給子組件。

因爲ref在React組件的整個生命週期中只存在一個引用,所以經過current永遠是能夠訪問到引用中最新的函數值的,不會存在閉包陳舊值的問題。

// 要把ref傳給子組件 這樣才能保證子組件能在不從新渲染的狀況下拿到最新的函數引用
  const onCheckedChangeRef = React.useRef(onCheckedChange)
  // 注意要在每次渲染後把ref中的引用指向當次渲染中最新的函數。
  useEffect(() => {
    onCheckedChangeRef.current = onCheckedChange
  })
  
  return (
    <ItemCard
      key={id}
      cartItem={cartItem}
      checked={checked}
+ onCheckedChangeRef={onCheckedChangeRef}
    />
  )
複製代碼

子組件

// memo優化策略
function areEqual(prevProps: Props, nextProps: Props) {
  return (
    prevProps.checked === nextProps.checked
  )
}

const ItemCard: FC<Props> = React.memo(props => {
  const { checked, onCheckedChangeRef } = props
  return (
    <div> <checkbox value={checked} onChange={(value) => onCheckedChangeRef.current(cartItem, value)} /> <span>商品</span> </div> ) }, areEqual) 複製代碼

到此時,咱們的簡單的性能優化就完成了。

自定義hook

那麼下一個場景,又遇到這種全選反選相似的需求,難道咱們再這樣重複寫一套嗎?這是不可接受的,咱們用自定義hook來抽象這些數據以及行爲。

而且此次咱們經過useReducer來避免閉包舊值的陷阱(dispatch在組件的生命週期中保持惟一引用,而且老是能操做到最新的值)。

import { useReducer, useEffect, useCallback } from 'react'

interface Option {
  /** 用來在map中記錄勾選狀態的key 通常取id */
  key?: string;
}

type CheckedMap = {
  [key: string]: boolean;
}

const CHECKED_CHANGE = 'CHECKED_CHANGE'

const CHECKED_ALL_CHANGE = 'CHECKED_ALL_CHANGE'

const SET_CHECKED_MAP = 'SET_CHECKED_MAP'

type CheckedChange<T> = {
  type: typeof CHECKED_CHANGE;
  payload: {
    dataItem: T;
    checked: boolean;
  };
}

type CheckedAllChange = {
  type: typeof CHECKED_ALL_CHANGE;
  payload: boolean;
}

type SetCheckedMap = {
  type: typeof SET_CHECKED_MAP;
  payload: CheckedMap;
}

type Action<T> = CheckedChange<T> | CheckedAllChange | SetCheckedMap
export type OnCheckedChange<T> = (item: T, checked: boolean) => any

/** * 提供勾選、全選、反選等功能 * 提供篩選勾選中的數據的函數 * 在數據更新的時候自動剔除陳舊項 */
export const useChecked = <T extends Record<string, any>>(
  dataSource: T[],
  { key = 'id' }: Option = {}
) => {
  const [checkedMap, dispatch] = useReducer(
    (checkedMapParam: CheckedMap, action: Action<T>) => {
      switch (action.type) {
        case CHECKED_CHANGE: {
          const { payload } = action
          const { dataItem, checked } = payload
          const { [key]: id } = dataItem
          return {
            ...checkedMapParam,
            [id]: checked,
          }
        }
        case CHECKED_ALL_CHANGE: {
          const { payload: newCheckedAll } = action
          const newCheckedMap: CheckedMap = {}
          // 全選
          if (newCheckedAll) {
            dataSource.forEach(dataItem => {
              newCheckedMap[dataItem.id] = true
            })
          }
          return newCheckedMap
        }
        case SET_CHECKED_MAP: {
          return action.payload
        }
        default:
          return checkedMapParam
      }
    },
    {}
  )

  /** 勾選狀態變動 */
  const onCheckedChange: OnCheckedChange<T> = useCallback(
    (dataItem, checked) => {
      dispatch({
        type: CHECKED_CHANGE,
        payload: {
          dataItem,
          checked,
        },
      })
    },
    []
  )

  type FilterCheckedFunc = (item: T) => boolean
  /** 篩選出勾選項 能夠傳入filter函數繼續篩選 */
  const filterChecked = useCallback(
    (func: FilterCheckedFunc = () => true) => {
      return (
        Object.entries(checkedMap)
          .filter(entries => Boolean(entries[1]))
          .map(([checkedId]) =>
            dataSource.find(({ [key]: id }) => id === Number(checkedId))
          )
          // 有可能勾選了之後直接刪除 此時id雖然在checkedMap裏 可是dataSource裏已經沒有這個數據了
          // 先把空項過濾掉 保證外部傳入的func拿到的不爲undefined
          .filter(Boolean)
          .filter(func)
      )
    },
    [checkedMap, dataSource, key]
  )
  /** 是否全選狀態 */
  const checkedAll =
    dataSource.length !== 0 && filterChecked().length === dataSource.length

  /** 全選反選函數 */
  const onCheckedAllChange = (newCheckedAll: boolean) => {
    dispatch({
      type: CHECKED_ALL_CHANGE,
      payload: newCheckedAll,
    })
  }

  // 數據更新的時候 若是勾選中的數據已經不在數據內了 就刪除掉
  useEffect(() => {
    filterChecked().forEach(checkedItem => {
      let changed = false
      if (!dataSource.find(dataItem => checkedItem.id === dataItem.id)) {
        delete checkedMap[checkedItem.id]
        changed = true
      }
      if (changed) {
        dispatch({
          type: SET_CHECKED_MAP,
          payload: Object.assign({}, checkedMap),
        })
      }
    })
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [dataSource])

  return {
    checkedMap,
    dispatch,
    onCheckedChange,
    filterChecked,
    onCheckedAllChange,
    checkedAll,
  }
}
複製代碼

這時候在組件內使用,就很簡單了:

const {
  checkedAll,
  checkedMap,
  onCheckedAllChange,
  onCheckedChange,
  filterChecked,
} = useChecked(cartData)
複製代碼

咱們在自定義hook裏把複雜的業務邏輯所有作掉了,包括數據更新後的無效id剔除等等。快去推廣給團隊的小夥伴,讓他們早點下班吧。

總結

本文經過一個真實的購物車需求,一步一步的完成優化、踩坑,在這個過程當中,咱們對React Hook的優缺點必定也有了進一步的認識。

在利用自定義hook把通用邏輯抽取出來後,咱們業務組件內的代碼量大大的減小了,而且其餘類似的場景均可以去複用。

React Hook帶來了一種新的開發模式,可是也帶來了一些陷阱,它是一把雙刃劍,若是你能合理使用,那麼它會給你帶來很強大的力量。

感謝你的閱讀,但願這篇文章能夠給你啓發。

相關文章
相關標籤/搜索