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

前言

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

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

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

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

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

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

預覽地址

https://sl1673495.github.io/r...性能優化

代碼倉庫

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

https://github.com/sl1673495/...函數

需求分解

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

  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 的一個缺陷,默認狀況下幾乎沒有任何性能優化。

咱們來看一下動圖演示:

gif2

購物車此時有 5 個商品,看控制檯的打印,每次都是以 5 爲倍數增加每點擊一次 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 還原:

gif3

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

在勾選了第一個商品後,咱們此時的最新的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 之 useChecked

那麼下一個場景,又遇到這種全選反選相似的需求,難道咱們再這樣重複寫一套嗎?這是不可接受的,咱們用自定義 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 剔除等等。快去推廣給團隊的小夥伴,讓他們早點下班吧。

自定義 hook 之 useMap

有一天,忽然又來了個需求,咱們須要用一個 map 來根據購物車商品的 id 來記錄另外的一些東西,咱們忽然發現,上面的自定義 hook 把 map 的處理等等邏輯也都打包進去了,咱們只能給 map 的值設爲true / false,靈活性不夠。

咱們進一步把useMap也抽出來,而後讓useCheckedMap基於它之上開發。

useMap

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

export interface Option {
  /** 用來在map中做爲key 通常取id */
  key?: string
}

export type MapType = {
  [key: string]: any
}

export const CHANGE = 'CHANGE'

export const CHANGE_ALL = 'CHANGE_ALL'

export const SET_MAP = 'SET_MAP'

export type Change<T> = {
  type: typeof CHANGE
  payload: {
    dataItem: T
    value: any
  }
}

export type ChangeAll = {
  type: typeof CHANGE_ALL
  payload: any
}

export type SetCheckedMap = {
  type: typeof SET_MAP
  payload: MapType
}

export type Action<T> = Change<T> | ChangeAll | SetCheckedMap
export type OnValueChange<T> = (item: T, value: any) => any

/**
 * 提供map操做的功能
 * 在數據更新的時候自動剔除陳舊項
 */
export const useMap = <T extends Record<string, any>>(
  dataSource: T[],
  { key = 'id' }: Option = {}
) => {
  const [map, dispatch] = useReducer(
    (checkedMapParam: MapType, action: Action<T>) => {
      switch (action.type) {
        // 單值改變
        case CHANGE: {
          const { payload } = action
          const { dataItem, value } = payload
          const { [key]: id } = dataItem
          return {
            ...checkedMapParam,
            [id]: value,
          }
        }
        // 全部值改變
        case CHANGE_ALL: {
          const { payload } = action
          const newMap: MapType = {}
          dataSource.forEach((dataItem) => {
            newMap[dataItem[key]] = payload
          })
          return newMap
        }
        // 徹底替換map
        case SET_MAP: {
          return action.payload
        }
        default:
          return checkedMapParam
      }
    },
    {}
  )

  /** map某項的值變動 */
  const onMapValueChange: OnValueChange<T> = useCallback((dataItem, value) => {
    dispatch({
      type: CHANGE,
      payload: {
        dataItem,
        value,
      },
    })
  }, [])

  // 數據更新的時候 若是map中的數據已經不在dataSource內了 就刪除掉
  useEffect(() => {
    dataSource.forEach((checkedItem) => {
      let changed = false
      if (
        // map中包含此項
        // 而且數據源中找不到此項了
        checkedItem[key] in map &&
        !dataSource.find((dataItem) => checkedItem[key] === dataItem[key])
      ) {
        delete map[checkedItem[key]]
        changed = true
      }
      if (changed) {
        dispatch({
          type: SET_MAP,
          payload: Object.assign({}, map),
        })
      }
    })
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [dataSource])

  return {
    map,
    dispatch,
    onMapValueChange,
  }
}

這是一個通用的 map 操做的自定義 hook,它考慮了閉包陷阱,考慮了舊值的刪除。

在此之上,咱們實現上面的useChecked

useChecked

import { useCallback } from 'react'
import { useMap, CHANGE_ALL, Option } from './use-map'

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

export type OnCheckedChange<T> = (item: T, checked: boolean) => any

/**
 * 提供勾選、全選、反選等功能
 * 提供篩選勾選中的數據的函數
 * 在數據更新的時候自動剔除陳舊項
 */
export const useChecked = <T extends Record<string, any>>(
  dataSource: T[],
  option: Option = {}
) => {
  const { map: checkedMap, onMapValueChange, dispatch } = useMap(
    dataSource,
    option
  )
  const { key = 'id' } = option

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

  type FilterCheckedFunc = (item: T) => boolean
  /** 篩選出勾選項 能夠傳入filter函數繼續篩選 */
  const filterChecked = useCallback(
    (func?: FilterCheckedFunc) => {
      const checkedDataSource = dataSource.filter(item =>
        Boolean(checkedMap[item[key]])
      )
      return func ? checkedDataSource.filter(func) : checkedDataSource
    },
    [checkedMap, dataSource, key]
  )
  /** 是否全選狀態 */
  const checkedAll =
    dataSource.length !== 0 && filterChecked().length === dataSource.length

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

  return {
    checkedMap: checkedMap as CheckedMap,
    dispatch,
    onCheckedChange,
    filterChecked,
    onCheckedAllChange,
    checkedAll,
  }
}

總結

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

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

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

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

相關文章
相關標籤/搜索