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