在前面的章節中,我們學習了 context 的使用方式,基于它我們可以搞一個自己的狀態管理庫。不過,他存在性能上的問題,以致于雖然從功能的實現上來說,他非常不錯,但是從性能上來說,context 的表現非常糟糕,雖然很少有 React 學習者關注到這個問題,但是如果你關注項目的整體架構,并且想要成為頂尖高手的話,這是你必須掌握的最后一步。
接下來我們會用案例來探討 context 存在什么樣的性能問題,并思考如何設計一個方案來替代 context,解決它的性能問題。
我們需要通過一個實踐案例來分析 context 存在的性能問題。我計劃把幾個不同的 counter 狀態分散放到不同的子組件中去。項目結構如圖。
+ App - index.tsx - Provider.tsx - Counter01.tsx - Counter02.tsx - Counter03.tsx - Reset.tsx
在入口文件中,使用 Provider 把所有的子組件包裹起來。
import Provider from './Provider';import Counter01 from './Counter01';import Counter02 from './Counter02';import Counter03 from './Counter03';import Reset from './Reset';/** * @description 性能有問題,子組件每次都會rerender * @returns */export default function App() { return ( <Provider> <Counter01 /> <Counter02 /> <Counter03 /> <Reset /> </Provider> )}
在 Provider 中,我們創建好 context,并在 state 中定義好數據,并通過 value 向子組件傳遞。
import {createContext, Dispatch, SetStateAction, useState} from 'react'interface Props { children: any}const initialState = { counter01: 0, counter02: 0, counter03: 0}type State = typeof initialStateinterface Value extends State { setCounter01: Dispatch<any>, setCounter02: Dispatch<any>, setCounter03: Dispatch<any>}export const context = createContext<Value>(initialState as Value)export default function Provider(props: Props) { const [state, setState] = useState(initialState) const value = { ...state, setCounter01: (value: number) => setState({...state, counter01: value}), setCounter02: (value: number) => setState({...state, counter02: value}), setCounter03: (value: number) => setState({...state, counter03: value}) } return ( <context.Provider value={value}> {props.children} </context.Provider> )}
每個子組件里,都會顯示一個 counter,并帶有一個按鈕點擊能遞增 counter,為了方便查看該子組件是否被 re-render,我們會在內部邏輯中執行 console.log 來觀察。
import { useContext } from 'react';import {context} from './Provider'export default function Counter01() { const {counter01, setCounter01} = useContext(context) console.log('counter01: ', counter01) function clickHandle() { setCounter01(counter01 + 1) } return ( <button onClick={clickHandle}> counter01: {counter01} </button> )}
除此之外,為了驗證 memo 的效果,我們還使用 memo 將一個子組件包裹起來。
import { useContext, memo } from 'react';import {context} from './Provider'function Counter03() { const {counter03, setCounter03} = useContext(context) console.log('counter03: ', counter03) function clickHandle() { setCounter03(counter03 + 1) } return ( <button onClick={clickHandle}> counter03: {counter03} </button> )}export default memo(Counter03)
Reset
組件中只會重置對應的數據為初始狀態。
import { useContext } from 'react';import {context} from './Provider'export default function Reset() { const {setCounter01, setCounter02} = useContext(context) console.log('reset'); function clickHandle() { setCounter01(0); // setCounter02(1); } return ( <div> <button onClick={clickHandle}> Reset01 02 to 0 </button> </div> )}
OK,全部代碼大概如此。運行,測試之后,我們發現此時存在嚴重的 re-render 現象:當我們修改任何一個狀態時,所有的子組件都會 re-render,即使這個組件跟這個狀態毫無關系。就算你使用 memo 將子組件包裹起來,該子組件依然會 re-render。因此,當你基于 context 開發頂層狀態管理器時,你的 React 項目的性能,將會很差。
梳理一下,具體的糟糕表現為:
為什么會出現這個問題呢?
我們前面已經分析過,React 組件的 re-render 機制,需要同時保證 state、props、context 都不變,組件才不會 re-render。
我們觀察一下 Provider 的寫法
export default function Provider(props: Props) { const [state, setState] = useState(initialState) const value = { ...state, setCounter01: (value: number) => setState({...state, counter01: value}), setCounter02: (value: number) => setState({...state, counter02: value}), setCounter03: (value: number) => setState({...state, counter03: value}) } return ( <context.Provider value={value}> {props.children} </context.Provider> )}
在 context 發生變化時,value 總會被重新聲明,context.Provider 的 props.value 總是會發生變化,那么他的子組件的穩定結構從頂層就被破壞了,因此當 state 發生變化時,被他包裹的所有子組件都會 re-render。
在思考 context 的替代方案之前,我們先總結一下 context 的能力。
那么,我們如何基于 React 現有的機制,做到和 context 一樣的事情呢?要單獨想到比較困難,但是答案卻非常簡單。具體的思路是,我們可以利用發布訂閱模式,收集每個組件內部的 setState,把共享狀態的 satate 收集到一起,然后利用他們各自的 setState 去觸發數據的更新即可。這樣,我們就可以實現上面的兩個要求了。
創建一個 store.ts 文件來完成我們的構想。
首先創建一個對象用來存儲所有的數據,并約定好數據的格式。
interface StoreItem { value: any, dispatch: Set<any>}interface Store { [key: string]: StoreItem}const store: Store = {}
理解這個數據格式,是整個功能實現的關鍵。不同的數據會對應不同的 key 值,相同的數據會對應不同的 setState,我們在 store 中用對應的格式把這個關系存儲起來。
另外我再單獨定義一個對象,去存儲每一個狀態的初始化狀態。
interface KeyMap { [key: string]: boolean}const isInitStore: KeyMap = {}
修改數據,本質上是執行 setState,因此,我們需要先定義好一個 set 方法用于觸發存儲在 dispatch 中的所有 setState 執行,該方法只能在 store 模塊內部被調用。
function _setValue(key: string, value: any) { store[key].value = value store[key].dispatch.forEach((cb: any) => { cb(value) })}
我們還需要定義一個 useSubscribe 用于在子組件內部訂閱狀態。該方法用于收集每個組件的 setState,并返回當前組件對應的狀態,和修改該狀態的方法。
export function useSubscribe(key: string, value?: any) { const [state, setState] = useState(value || null) // 如果沒有被初始化,則初始化一次 if (!isInitStore[key]) { store[key] = { value: value, dispatch: new Set() } isInitStore[key] = true } if (store[key].dispatch.has(setState) === false) { store[key].dispatch.add(setState) } return [state, (_value: any) => _setValue(key, _value)]}
有的時候我們還需要單獨調用某個方法去修改全局的狀態,因此,我們還需要對外拋出一個 useDispatch 來完成這個需求。
export function useDispatch(key: string) { return (value: any) => _setValue(key, value)}
OK,簡單的代碼,我們的這個功能就設計好了。我們在子組件中使用他們一下試試看。在子組件中使用時,只需要使用 useSubscribe 訂閱一下即可。該方法返回了狀態值,和修改狀態值的 set 方法。
import { useSubscribe } from './store';export default function Counter01() { const [counter, setCounter] = useSubscribe('counter01') console.log('counter01: ', counter) function clickHandle() { setCounter(counter + 1) } return ( <button onClick={clickHandle}> counter01: {counter} </button> )}
這里傳入的字符串非常關鍵,如果你在不同的組件中共享同一個數據,那么他們傳入的 key 值需要保持一致才能做到共享。例如我們分別定義下面兩個組件,他們能共享同一個狀態。
import { useSubscribe } from './store';function Counter03() { const [counter, setCounter] = useSubscribe('counter04') console.log('counter03: ', counter) function clickHandle() { setCounter(counter + 1) } return ( <button onClick={clickHandle}> counter03: {counter} </button> )}export default Counter03
import {useSubscribe} from './store'export default function Counter04() { const [counter, setCounter] = useSubscribe('counter04') console.log('counter04: ', counter) function clickHandle() { setCounter(counter + 1) } return ( <button onClick={clickHandle}> counter04: {counter} </button> )}
如果我們要單獨在別的組件中修改全局狀態,則可以利用 useDispatch。
import { useDispatch } from './store';export default function Reset() { const setCounter01 = useDispatch('counter01') const setCounter02 = useDispatch('counter02') const setCounter03 = useDispatch('counter04') console.log('reset'); function clickHandle() { setCounter01(0); setCounter02(0); } function clickHandle03() { setCounter03(0) } return ( <div> <button onClick={clickHandle}> Reset01 02 to 0 </button> <button onClick={clickHandle03}> Reset03 </button> </div> )}
程序運行起來之后,測試一下。
發現我們不僅實現了全局狀態共享,也實現了數據跨組件傳遞。也解決了 context 引發不相干子組件刷新的問題。甚至組組件連 memo 的優化手段都不需要用,依然能夠保持最低代價的 re-render。也就是說,這種方案完美解決了 context 的性能弊病,成為了一個高性能方案。因此,基于你的需求稍微擴展一下,他就能夠成為一個強大的狀態管理庫運用于你的真實項目中。
在前面的篇幅中,我有強調過 React 對 JavaScript 的弱侵入性是他的一大優勢。在這個方案里,已經展現出來這一優勢的巨大作用。我們有機會利用各種 JavaScript 的解決方案運用到我們的項目中,擴展 React 的項目邊界。
我們這個方案基于閉包,利用發布訂閱模式,在子組件中訂閱組件對應的 setState,并在執行時統一觸發所有相同狀態的 set 方法。如果對我標黑的幾個基礎知識掌握得比較好的話,對這個方案理解起來會比較容易。否則可能會面臨比較大的理解成本。不過也沒有關系,加入 React 知命境付費群,可以在群里跟群友進一步探討該方案,我也會在群里直播講解該方案
除了我們自己利用發布訂閱模式來解決該問題之外,React 官方文檔也提供了一個 hook 來達到類似的效果:useSyncExternalStore,因為直接學習它有不少理解成本,因此我們鋪墊了本文的方案,后續會專門寫一篇文章來學習它,包括我們熟知的狀態管理方案 zustand 也是基于這個 hook 來實現。
本文鏈接:http://www.www897cc.com/showinfo-26-70397-0.htmlReact 性能優化終章,成為頂尖高手的最后一步
聲明:本網頁內容旨在傳播知識,若有侵權等問題請及時與本網聯系,我們將在第一時間刪除處理。郵件:2376512515@qq.com
上一篇: 利用@Embeddable實現實體和級聯關系的分開定義
下一篇: 如何在 Npm 上發布二進制文件?