本文由一個基礎的購物車需求展開,一步一步帶你深刻理解React Hook 中的坑和優化。react
經過本篇文章你能夠學到這些:ios
🦄 React Hook + TypeScript編寫 業務組件 的實踐axios
🦄 如何利用 React.memo 優化性能api
🦄 如何避免 Hook 帶來的 閉包陷阱性能優化
🦄 如何抽象出簡單好用的 自定義hook閉包
做爲一個購物車需求,那麼它必然涉及到幾個需求點:函數
首先咱們請求到購物車數據,這裏並非本文的重點,能夠經過自定義請求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來實現一個計算價格總和的函數:ui
// 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)
}
複製代碼
若是是
{cartData.map(cartItem => {
const { id } = cartItem
const checked = checkedMap[id]
return (
<ItemCard key={id} cartItem={cartItem} checked={checked} onCheckedChange={onCheckedChange} /> ) })} 複製代碼
能夠看出,是否勾選的邏輯就這樣輕鬆的傳給了子組件。
到了這步,基本的購物車需求已經實現了。
可是如今咱們有了新的問題。
這是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中的cheked相等,那麼就不去從新渲染子組件。
到這裏就完成了嗎?固然不是,其實,這裏是有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來抽象這些數據以及行爲。 而且此次咱們經過 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剔除等等。快去推廣給團隊的小夥伴,讓他們早點下班吧。
有一天,忽然又來了個需求,咱們須要用一個map來根據購物車商品的id來記錄另外的一些東西,咱們忽然發現,上面的自定義hook把map的處理等等邏輯也都打包進去了,咱們只能給map的值設爲true / false,靈活性不夠。
咱們進一步把useMap也抽出來,而後讓useCheckedMap基於它之上開發。
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。
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 帶來了一種新的開發模式,可是也帶來了一些陷阱,它是一把雙刃劍,若是你能合理使用,那麼它會給你帶來很強大的力量。
💌感謝你的閱讀,但願這篇文章能幫到你~