乐趣区

关于前端:React-Hook-TS-购物车实战性能优化闭包陷阱自定义hook

前言

本文由一个根底的购物车需要开展,一步一步带你深刻了解 React Hook 中的坑和优化

通过本篇文章你能够学到:

✨React Hook + TypeScript 编写 业务组件 的实际

✨ 如何利用 React.memo优化性能

✨ 如何防止 Hook 带来的 闭包陷阱

✨ 如何形象出简略好用的 自定义 hook

预览地址

https://sl1673495.github.io/r…

代码仓库

本文波及到的代码曾经整顿到 github 仓库中,用 cra 搭建了一个示例工程,对于性能优化的局部能够关上控制台查看重渲染的状况。

https://github.com/sl1673495/…

需要合成

作为一个购物车需要,那么它必然波及到几个需要点:

  1. 勾选、全选与反选。
  2. 依据选中项计算总价。

需要实现

获取数据

首先咱们申请到购物车数据,这里并不是本文的重点,能够通过自定义申请 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 的一个缺点,默认状况下简直没有任何性能优化。

咱们来看一下动图演示:

购物车此时有 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 还原:

如果咱们先点击了第一个商品的勾选,再点击第二个商品的勾选,你会发现第一个商品的勾选状态没了。

在勾选了第一个商品后,咱们此时的最新的 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 带来了一种新的开发模式,然而也带来了一些陷阱,它是一把双刃剑,如果你能正当应用,那么它会给你带来很弱小的力量。

感激你的浏览,心愿这篇文章能够给你启发。

退出移动版