React Hook + битва корзины покупок TS (оптимизация производительности, ловушка закрытия, пользовательский хук)

React.js

предисловие

Эта статья начинается с основных требований к корзине покупок и шаг за шагом ведет вас к глубокому пониманию ям и оптимизаций в React Hook.

Из этой статьи вы можете узнать:

✨Написание React Hook + TypeScript业务组件упражняться

✨ Как использовать React.memo优化性能

✨ Как избежать крючков闭包陷阱

✨ Как аннотация что-то простое и легко в использовании自定义hook

адрес предварительного просмотра

sl1673495.github.io/react-cart

репозиторий кода

Код, используемый в этой статье, был отсортирован в репозитории github, а пример проекта создан с помощью cra.Что касается части оптимизации производительности, вы можете открыть консоль, чтобы просмотреть ситуацию повторного рендеринга.

GitHub.com/forget 1673495/день…

декомпозиция спроса

Как спрос на корзину для покупок, он должен включать несколько точек спроса:

  1. Отметить, Отметить все и Снять отметку.
  2. Рассчитать общую стоимость на основе выбранных элементов.

gif1

Реализация спроса

получить данные

Во-первых, мы запрашиваем данные корзины, что не является предметом этой статьи, это может быть реализовано через кастомный хук запроса, или это может быть реализовано с помощью обычного useState + useEffect.

const getCart = () => {
  return axios('/api/cart')
}
const {
  // 购物车数据
  cartData,
  // 重新请求数据的方法
  refresh,
} = useRequest < CartResponse > getCart

Проверить реализацию логики

Мы рассматриваем объект как таблицу отображения,checkedMapЭта переменная для записи всех проверенных идентификаторов продуктов:

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)
}

Рассчитать общую стоимость

Затем используйте сокращение для реализации функции, которая вычисляет сумму цен.

// 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Каждому идентификатору элемента присваивается значение 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. Каждый раз, когда флажок установлен, он вызывает повторную визуализацию всех дочерних компонентов.

Если у нас есть 50 товаров в корзине, и мы меняемcheckedstate, что также приводит к повторному рендерингу 50 дочерних компонентов.

Мы подумали об API:React.memo, этот API в основном эквивалентен компоненту класса вshouldComponentUpdate, что, если мы используем этот API для повторного рендеринга дочерних компонентов только при проверенных изменениях?

Хорошо, приступим к написанию подкомпонента:

// 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)

В соответствии с этой стратегией оптимизации мы думаем, что до двух раз до и после рендеринга входящих реквизитовcheckedравны, то не выполняйте повторный рендеринг дочернего компонента.

Ошибки, вызванные устаревшими значениями в React Hook

Это сделано здесь? На самом деле, здесь есть ошибка.

Давайте посмотрим на уменьшение количества ошибок:

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-компонента существует только одна ссылка, поэтому к последнему значению функции в ссылке всегда можно получить доступ через текущий, и не будет проблемы устаревших значений замыканий.

  // 要把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)

На этом наша простая оптимизация производительности завершена.

useChecked для пользовательских хуков

Затем, в следующем сценарии, мы сталкиваемся с таким требованием выбрать все и снова отменить выбор. Должны ли мы повторять это снова? Это неприемлемо, мы абстрагируем эти данные и поведение с помощью пользовательских хуков.

И на этот раз мы избегаем ловушки закрытия старых значений с помощью useReducer (диспетчер сохраняет уникальную ссылку на время жизни компонента и всегда работает с последним значением).

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)

Мы сделали всю сложную бизнес-логику в пользовательском хуке, включая удаление недействительного идентификатора после обновления данных и т. д. Идите и расскажите об этом своим товарищам по команде, и пусть они уйдут с работы пораньше.

useMap для пользовательских хуков

Однажды возник внезапный спрос. Нам нужно было использовать карту для записи других вещей в соответствии с идентификатором товара в корзине. Мы вдруг обнаружили, что указанный выше пользовательский хук также упаковал логику обработки карты и так далее. , мы можно только установить значение карты на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,
  }
}

Это настраиваемый хук для общей операции карты, который учитывает ловушку закрытия, принимая во внимание удаление старых значений.

Вдобавок к этому мы реализуем вышеуказанное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.

После использования пользовательских перехватчиков для извлечения общей логики объем кода в наших бизнес-компонентах значительно сокращается, и можно повторно использовать другие подобные сценарии.

React Hook предлагает новый режим разработки, но также содержит некоторые подводные камни, это палка о двух концах, и если вы используете ее правильно, она даст вам много сил.

Спасибо за прочтение, надеюсь, эта статья вдохновит вас.