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