手把手教你寫一個 React 狀態管理庫
theme: juejin
自從 React Hooks
推行後,Redux
作為狀態管理方案就顯得格格不入了。Dan Abramov 很早就提到過 “You might not need Redux”,開發者必須要寫很多“模式代碼”,繁瑣以及重複是開發者不願意容忍的。除了 actions/reducers/store
等概念對新手不夠友好之外,最大的缺點就是它對 typescript
類型支持太爛,在大型項目中這是不可接受的。
通過對 Redux
的優缺點總結來看,我們可以自己寫一個狀態管理庫,本次需要達到的目的:
typescript
類型要完善- 足夠簡單,概念要少
- 與
React Hooks
要搭配
因此,閲讀此文檔的前提要對 React Hooks
、typescript
等有一定的概念。OK, 那我們開始吧。
思路
目前流行的狀態管理庫很多都太複雜了,夾雜着大量的概念及 API,我們需要規劃着如何實現它。狀態管理也就是狀態提升的極致表現。我們的目的是要足夠簡單, API 少。
思考一下,我們是否可以考慮用 Context
去穿透做管理,用最基本的 useState
等 hooks
做狀態存儲,那麼就嘗試下吧。
這是三個最簡單的函數式組件 Demo,我們用它來試驗:
```jsx
function App() {
return
function Card() {
return
function CardBody() { return
實現
我們定義 Context
,一個很基本的狀態模型
```jsx
// 描述 Context 的類型
interface IStoreContext {
count: number;
setCount: React.Dispatch
// 創建一個 Context,無需默認值,這裏為了演示方便,用了斷言
export const StoreContext = React.createContext
以及定義基本的 state
,並配合 Context
```jsx function App() { // 定義狀態 const [count, setCount] = React.useState(0); const increment = () => setCount(count + 1); const decrement = () => setCount(count - 1);
// 包裹 Provider,所有子組件都能拿到 context 的值
return (
接下來我們在 CardBody
中使用這個 Context
,使其穿透取值
```jsx function CardBody() { // 獲取外層容器中的狀態 const store = React.useContext(StoreContext);
return
這樣,一個最簡單的穿透狀態管理的代碼寫好了。發現問題了嗎,狀態的業務邏輯寫在了 App 組件裏,這個代碼耦合度太高了!我們來整理一下,需要將 App
的狀態通過自定義 hook 抽離出去,保持邏輯與組件的純粹性。
```jsx // 將 App 中的狀態用自定義 hook 管理,邏輯和組件的表現抽離 function useStore() { // 定義狀態 const [count, setCount] = React.useState(0); const increment = () => setCount(count + 1); const decrement = () => setCount(count - 1);
return { count, setCount, increment, decrement, }; } ```
在 App
中使用這個 hook
```jsx function App() { const store = useStore();
return (
現在來看是不是舒心多了,邏輯在單獨的 hook 中控制,具有高內聚的特點。想想也許還不夠,useStore
和 StoreContext
的邏輯還不夠內聚,繼續:
將 useStore
和 StoreContext.Provider
抽離成一個組件
jsx
const Provider: React.FC = ({ children }) => {
const store = useStore();
return <StoreContext.Provider value={store}>{children}</StoreContext.Provider>;
};
再來看 App 組件,是不是很清晰?
jsx
function App() {
return (
<StoreProvider>
<Card />
</StoreProvider>
);
}
好了,我們可以將這個模式封裝成一個方法,通過工廠模式來創建 Context
和 Provider
。
```jsx
// 將自定義 Hook 通過參數傳入
// 定義泛型描述 Context 形狀
export function createContainer
const Provider: React.FC<{ initialState?: State }> = ({ initialState, children }) => {
// 使用外部傳入的 hook
const value = useHook(initialState);
return
return { Provider, Context }; } ```
OK,一個簡單的狀態管理算成型了。好不好用我們來試試,將之前定義的 useStore
的代碼移入 createContainer
裏
```jsx export const BaseStore = createContainer(() => { // 定義狀態 const [count, setCount] = React.useState(0); const increment = () => setCount(count + 1); const decrement = () => setCount(count - 1);
return { count, setCount, increment, decrement, }; }); ```
在 App
中替換為 BaseStore
導出的 Provider
jsx
function App() {
return (
<BaseStore.Provider>
<Card />
</BaseStore.Provider>
);
}
在 CardBody
使用 BaseStore
導出的 Context
,因為定義的時候用了泛型,這裏能完美識別當前 store
的形狀,從而具備編輯器智能提示
```jsx function CardBody() { const store = React.useContext(BaseStore.Context);
return
那麼恭喜你,你已經創建了一個屬於自己的狀態管理庫,我們給它取個名字叫 unstated-next
調整
但是方便和性能總是有所取捨的,毫無疑問,成也 Context
,敗也 Context
。因為它的穿透無差別更新特性也就決定了會讓所有的 React.memo
優化失效。一次 setState
幾乎讓整個項目跟着 rerender
,這是極為不可接受的。因為自定義 Hook 每次執行返回的都是一個全新的對象,那麼 Provider
每次都會接受到這個全新的對象。所有用到這個 Context
的子組件都跟着一起更新,造成無意義的損耗調用。
有方案嗎?想一想,辦法總比困難多。我們可以優選 Context
上下文的特性,放棄導致重新渲染的特性(即每次傳給他一個固定引用)。這樣的話狀態改變,該更新的子組件不跟着更新了怎麼辦,有什麼辦法觸發 rerender 呢?答案是 setState
,我們可以將 setState
方法提升到 Context
裏,讓容器去調度調用更新。
```jsx // createContainer 函數中
// 首先我們可以將 Context 設置為不觸發 render
// 這裏 createContext 第二個參數的函數返回值為 0 即為不觸發 render
// 注意:這個 API 非正規。當然也可以用 useRef 轉發整個 Context 的值,使其不可變
// 用非正規的 API 僅僅只是為了不用 ref 而少點代碼 😄
const Context = React.createContext
那現在 Context
已經是不可變了,該如何實現更新邏輯呢?思路可以是這樣:我們在子組件 mount
時添加一個 listener
到 Context
中,unMount
時將其移除,Context
有更新時,調用這個 listener
,使其 rerender
。
聲明一個 Context
,用來放這些子組件的 listener
jsx
// createContainer 函數中
const ListenerContext = React.createContext<Set<(value: Value) => void>>(new Set());
現在子組件中需要這樣一個 hook,想選擇 store
裏的某些狀態去使用,無相關的 state
改變不用通知我更新。
那麼我們就起個名字叫 useSelector
,用來監聽哪些值變化可以讓本組件 rerender
。
函數類型可以這樣定義:通過傳入一個函數,來手動指定需要監聽的值,並返回這個值
```jsx // createContainer 函數中
function useSelector
那我們來實現這個 useSelector
。首先是觸發 rerender
的方法,這裏用 reducer
讓其內部自增,調用時不用傳參數
jsx
const [, forceUpdate] = React.useReducer((c) => c + 1, 0);
這裏我們需要和容器中的 Context
通信,從而獲取所有狀態傳給 selector
函數
```jsx // 這裏的 Context 已經不具備觸發更新的特性 const value = React.useContext(Context); const listeners = React.useContext(ListenerContext);
// 調用方法獲取選擇的值 const selected = selector(value); ```
創建 listener
函數,通過 Ref 轉發,將選擇後的 state
提供給 listener
函數使用, 讓這個函數能拿到最新的 state
,
```jsx const StoreValue = { selector, value, selected, }; const ref = React.useRef(StoreValue);
ref.current = StoreValue; ```
實現這個 listener
函數
jsx
function listener(nextValue: Value) {
try {
const refValue = ref.current;
// 如果前後對比的值一樣,則不觸發 render
if (refValue.value === nextValue) {
return;
}
// 將選擇後的值進行淺對比,一樣則不觸發 render
const nextSelected = refValue.selector(nextValue);
//
if (isShadowEqual(refValue.selected, nextSelected)) {
return;
}
} catch (e) {
// ignore
}
// 運行到這裏,説明值已經變了,觸發 render
forceUpdate();
}
我們需要在組件 mount/Unmount
的時候添加/移除 listener
jsx
React.useLayoutEffect(() => {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}, []);
完整實現如下:
```jsx
function useSelector
const value = React.useContext(Context); const listeners = React.useContext(ListenerContext);
const selected = selector(value);
const StoreValue = { selector, value, selected, }; const ref = React.useRef(StoreValue);
ref.current = StoreValue;
React.useLayoutEffect(() => { function listener(nextValue: Value) { try { const refValue = ref.current; if (refValue.value === nextValue) { return; } const nextSelected = refValue.selector(nextValue); if (isShadowEqual(refValue.selected, nextSelected)) { return; } } catch (e) { // ignore } forceUpdate(); }
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}, []); return selected; } ```
有了 selector
。最後一步,我們來改寫 Provider
```jsx // createContainer 函數中
const Provider: React.FC<{ initialState?: State }> = ({ initialState, children }) => { const value = useHook(initialState); // 使用 Ref,讓 listener Context 不具備觸發更新 const listeners = React.useRef<Set<(listener: Value) => void>>(new Set()).current;
// 每次 useHook 裏面 setState 就會讓本組件更新,使 listeners 觸發調用,從而使改變狀態的子組件 render
listeners.forEach((listener) => {
listener(value);
});
return (
大功吿成!useSelector
返回的新對象都會如同 React.memo
一樣做淺對比。API 用法也如同 React-Redux
,毫無學習成本。我們來看看用法
```jsx function CardBody() { // count 一旦變化後,本組件觸發 rerender // 這裏如果嫌麻煩,可以使用 lodash 中的 pick 函數 const store = BaseStore.useSelector(({ count, increment }) => ({ count, increment }));
return
值得注意的是,createContainer
函數中返回出去的值不能是每次 render
都重新生成的。我們來修改一下 BaseStore
```jsx export const BaseStore = createContainer(() => { // 定義狀態 const [count, setCount] = React.useState(0);
// 之前定義的兩個 function 替換為 useMethods 包裹,保證 increment 、decrement 函數引用不變 const methods = useMethods({ increment() { setCount(count + 1); }, decrement() { setCount(count - 1); }, });
return { count, setCount, ...methods, }; }); ```
這裏的 useMethods
Hook 就是我之前有篇文章分析過的,用來代替 useCallback
,源碼見 Heo。
錦上添花,可以將 useSelector
結合 lodash.picker
封裝一個更常用的 API,取個名字叫 usePicker
```jsx // createContainer 函數中
function usePicker
試試效果:
```jsx function CardBody() { const store = BaseStore.usePicker(['count', 'increment']);
return
總結
好了,這就是我當時寫一個狀態管理的思路,你學會了嗎?源碼見 Heo,也是我們正在用的狀態管理,它足夠輕量、配合 Hooks、完美支持 TS、改造原有代碼的難度小。目前已在生產環境中穩定運行了一年多了,最高複雜度的項目為一次性渲染 2000 多個遞歸結構的組件,性能依然保持得很優秀。歡迎大家 Star。
歡迎關注公眾號 前端星辰
,後續會為大家送上各種組件的實現思路,或者歡迎 技術交流