本文由一個基礎的購物車需求展開,一步一步帶你深刻理解 React Hook 中的坑和優化react
經過本篇文章你能夠學到:ios
✨React Hook + TypeScript 編寫業務組件
的實踐git
✨ 如何利用 React.memo優化性能
github
✨ 如何避免 Hook 帶來的閉包陷阱
axios
✨ 如何抽象出簡單好用的自定義hook
api
https://sl1673495.github.io/r...性能優化
本文涉及到的代碼已經整理到 github 倉庫中,用 cra 搭建了一個示例工程,關於性能優化的部分能夠打開控制檯查看重渲染的狀況。閉包
https://github.com/sl1673495/...函數
做爲一個購物車需求,那麼它必然涉及到幾個需求點:性能
首先咱們請求到購物車數據,這裏並非本文的重點,能夠經過自定義請求 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 的一個缺陷,默認狀況下幾乎沒有任何性能優化。
咱們來看一下動圖演示:
購物車此時有 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
相等,那麼就不去從新渲染子組件。
到這裏就完成了嗎?其實,這裏是有 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 帶來了一種新的開發模式,可是也帶來了一些陷阱,它是一把雙刃劍,若是你能合理使用,那麼它會給你帶來很強大的力量。
感謝你的閱讀,但願這篇文章能夠給你啓發。