本文作者:EllieSummer
React v16.8 之後,Function Component 成為主流,React 狀態管理的方案也發生巨大的轉變。Redux 一直作為主流的 React 狀態管理方案,雖然提供了一套規範的狀態管理流程,但卻有著讓人飽受詬病的問題:概念太多、上手成本高、重複的樣板程式碼、需要結合中介軟體使用等。
一個真正易用的狀態管理工具往往不需要過多複雜的概念。Hooks 誕生之後,程式碼優雅簡潔變成一種趨勢。開發者也傾向於用一種小而美、學習成本低的方案來實現狀態管理。因此,除了 React local state hooks 之外,社群還孵化了很多狀態管理庫,如 unstated-next、hox、zustand、jotai 等。
關於狀態管理,有個非常經典的場景:實現一個計數器,點選 + 號的時候將數字加一,點選 - 號的時候將數值減一。這幾乎是所有狀態管理庫標配的入門案例。
本文將從實現「計數器」這個經典場景出發,逐步分析 Hooks 時代下,React 狀態管理方案的演進過程和背後的實現原理。
React local state hooks
React 提供了一些管理狀態的原生 hooks API,簡潔易懂,非常好上手。用原生的 hooks 方法就可以很輕鬆地實現計數器功能,只要通過 useState
方法在根元件定義計數器的狀態和改變狀態的方法,並層層傳遞給子元件就可以了。
// timer.js
const Timer = (props) => {
const { increment, count, decrement } = props;
return (
<>
<button onClick={decrement}>-</button>
<span>{count}</span>
<button onClick={increment}>+</button>
</>
);
};
// app.js
const App = () => {
const [count, setCount] = React.useState(0);
const increment = () => setCount(count + 1);
const decrement = () => setCount(count - 1);
return <Timer count={count} increment={increment} decrement={decrement} />
}
但是這種方法存在很嚴重的缺陷。
首先,計數器的業務邏輯和元件耦合嚴重,需要將邏輯進行抽象分離,保持邏輯與元件的純粹性。
其次,多元件內共享狀態是通過層層傳遞的方式實現的,帶來冗餘程式碼的同時,根元件的狀態將會逐漸變成 “龐然大物”。
unstated-next
React 開發者在設計之初,也考慮到上面提到的兩個問題,本身也提供了對應的解決方案。
React Hooks 就是打著「邏輯複用」的口號而誕生的,自定義 hook 可以解決以前在 Class Component 元件內無法靈活共享邏輯的問題。
因此,針對業務邏輯耦合的問題,可以提取一個自定義計數器 hook useCount
。
function useCount() {
const [count, setCount] = React.useState(0);
const increment = () => setCount(count + 1);
const decrement = () => setCount(count - 1);
return { count, increment, decrement };
}
為了避免元件間層層傳遞狀態,可以使用 Context 解決方案。Context 提供了在元件之間共享狀態的方法,而不必在樹的每個層級顯式傳遞一個 prop 。
因此,只需要將狀態儲存在 StoreContext 中,Provider 下的任意子元件都可以通過useContext
獲取到上下文中的狀態。
// timer.js
import StoreContext from './StoreContext';
const Timer = () => {
const store = React.useContext(StoreContext);
// 元件內 render 部分先省略
}
// app.js
const App = () => {
const StoreContext = React.createContext();
const store = useCount();
return <StoreContext.Provider value={store}><Timer /></StoreContext.Provider>
}
這樣程式碼看起來清爽了一些。
但是在使用的時候還是免不了要先定義很多 Context,並且在子元件中進行引用,略微有點繁瑣。
因此,可以對程式碼進行進一步的封裝,將 Context 定義和引用的步驟抽象成公共的方法 createContainer
。
function createContainer(useHook) {
// 定義 context
const StoreContext = React.createContext();
function useContainer() {
// 子元件引用 context
const store = React.useContext(StoreContext);
return store;
}
function Provider(props) {
const store = useHook();
return <StoreContext.Provider value={store}>{props.children}</StoreContext.Provider>
}
return { Provider, useContainer }
}
createContainer 封裝後會返回兩個物件 Provider 和 useContainer。 Provider 元件可以傳遞狀態給子元件,子元件可以通過 useContainer 方法獲取全域性的狀態。經過改造,元件內的程式碼就會變得非常精簡。
const Store = createContainer(useCount);
// timer.js
const Timer = () => {
const store = Store.useContainer();
// 元件內 render 部分先省略
}
// app.js
const App = () => {
return <Store.Provider><Timer /></Store.Provider>
}
這樣,一個基本的狀態管理方案成型了!體積小,API 簡單,可以說是 React 狀態管理庫的最小集。原始碼可以見這裡。
這種方案也是狀態管理庫 unstated-next 的實現原理。
hox
先不要高興得太早。unstated-next 的方案雖好,但也有缺陷的,這也是 React context 廣為人詬病的兩個問題:
- Context 需要巢狀 Provider 元件,一旦程式碼中使用多個 context,將會造成巢狀地獄,元件的可讀性和純粹性會直線降低,從而導致元件重用更加困難。
- Context 可能會造成不必要的渲染。一旦 context 裡的 value 發生改變,任何引用 context 的子元件都會被更新。
那有沒有什麼方法可以解決上面兩個問題呢?答案是肯定的,目前已經有一些自定義狀態管理庫解決這兩個問題了。
從 context 的解決方案裡,其實可以得到一些啟發。狀態管理的流程可以簡化成三個模型: Store(儲存所有狀態)、Hook (抽象公共邏輯,更改狀態)、Component(使用狀態的元件)。
如果要自定義狀態管理庫,在腦海中可以先構思下, 這三者之前的關係應該是怎麼樣的?
- 訂閱更新: 初始化執行 Hook 的時候,需要收集哪些 Component 使用了 Store
- 感知變更: Hook 中的行為能夠改變 Store 的狀態,也要能被 Store 所感知到
- 釋出更新: Store 一旦變更,需要驅動所有訂閱更新的 Component 更新
只要完成這三步,狀態管理基本上就完成了。大致思路有了,下面就可以具體實現了。
狀態初始化
首先需要初始化 Store 的狀態,也就是 Hook 方法執行返回的結果。同時定義一個 API 方法,供子元件獲取 Store 的狀態。這樣狀態管理庫的模型就搭出來了。
從業務程式碼使用方法上可以看出,API 簡潔的同時,也避免了 Provider 元件巢狀。
// 狀態管理庫的框架
function createContainer(hook) {
const store = hook();
// 提供給子元件的 API 方法
function useContainer() {
const storeRef = useRef(store);
return storeRef.current;
}
return useContainer;
}
// 業務程式碼使用:API簡潔
const useContainer = createContainer(useCount);
const Timer = () => {
const store = useContainer();
// 元件內 render 部分先省略
}
訂閱更新
為了實現 Store 狀態更新的時候,能夠驅動元件更新。需要定義一個 listeners 集合,在元件初始化的時候往陣列新增 listener 回撥,訂閱狀態的更新。
function createContainer(hook){
const store = hook();
const listeners = new Set(); // 定義回撥集合
function useContainer() {
const storeRef = useRef(store);
useEffect(() => {
listeners.add(listener); // 初始化的時候新增回撥,訂閱更新
return () => listeners.delete(listener) // 元件銷燬的時候移除回撥
},[])
return storeRef.current;
}
return useContainer;
}
那麼當狀態更新後,如何驅動元件更新呢? 這裡可以利用 useReducer
hook 定義一個自增函式,使用 forceUpdate 方法即可讓元件重刷。
const [, forceUpdate] = useReducer((c) => c + 1, 0);
function listener(newStore) {
forceUpdate();
storeRef.current = newStore;
}
感知狀態變更
狀態變更驅動元件更新的部分已經完成。現在比較重要的問題是,如何感知到狀態發生變更了呢?
狀態變更是在 useCount Hook 函式內實現的,用的是 React 原生的 setState
方法,也只能在 React 元件內執行。因此,很容易想到,如果使用一個函式元件 Executor 引用這個 Hook,那麼在這個元件內就可以初始化狀態,並感知狀態變更了。
考慮到狀態管理庫的通用性,可以通過 react-reconciler
構造一個 react 渲染器來掛載 Executor 元件,這樣就可以分別支援 React、ReactNative 等不同框架。
// 構造 react 渲染器
function render(reactElement: ReactElement) {
const container = reconciler.createContainer(null, 0, false, null);
return reconciler.updateContainer(reactElement, container);
}
// react 元件,感知 hook 內狀態的變更
const Executor = (props) => {
const store = props.hook();
const mountRef = useRef(false);
// 狀態初始化
if (!mountRef.current) {
props.onMount(store);
mountRef.current = true;
}
// store 一旦變更,就會執行 useEffect 回撥
useEffect(() => {
props.onUpdate(store); // 一旦狀態變更,通知依賴的元件更新
});
return null;
};
function createContainer(hook) {
let store;
const onUpdate = () => {};
// 傳遞hook和更新的回撥函式
render(<Executor hook={hook} onMount={val => store = val} onUpdate={onUpdate} />);
function useContainer() {}
return useContainer;
}
精確更新
一旦感知到狀態變更後,在 onUpdate 回撥裡可以通知之前訂閱過更新的元件重新渲染,也就是遍歷 listeners 集合,執行之前新增的更新回撥。
const onUpdate = (store) => {
for (const listener of listeners) {
listener(store);
}
}
但是,元件往往可能只依賴了 Store 裡的某一個狀態,所有元件都更新的操作太粗暴,會帶來不必要的更新,需要進行精確的更新渲染。因此,可以在元件的更新回撥裡判斷當前依賴的狀態是否變化,從而決定是否觸發更新。
// useContainer API 擴充套件增加依賴屬性
const store = useContainer('count'); // 元件僅依賴store.count值
// 更新回撥裡判斷
function listener(newStore) {
const newValue = newStore[dep];
const oldValue = storeRef.current[dep];
// 僅僅在依賴發生變更,才會元件進行更新
if (compare(newValue, oldValue)) {
forceUpdate();
}
storeRef.current = newStore;
}
完成以上的步驟,一個簡單又好用的狀態管理庫就實現啦!原始碼可以看這裡。
狀態更新的流程如下圖所示。
API 簡潔,邏輯和 UI 分離,能跨元件傳輸狀態,沒有冗餘的巢狀元件,並且能實現精確更新。
這也是狀態管理庫 hox 背後的實現原理。
zustand
關於如何感知狀態變更這一節中,因為 useCount 函式中是通過操作 react 原生 hook 方法實現狀態變更的,所以我們需要用 Executor 作為中間橋樑來感知狀態變更。
但是,這其實是一種委屈求全的方案,不得已將方案複雜化了。試想下,如果變更狀態的方法 setState 是由狀態管理庫自身提供的,那麼一旦執行該方法,就可以感知狀態變更,並觸發後續的比較更新操作,整體流程會簡單很多!
// 將改變狀態的 setState 方法傳遞給 hook
// hook內一旦執行該方法,即可感知狀態變更,拿到最新的狀態
function useCount(setState) {
const increment = () => setState((state) => ({ ...state, count: state.count + 1 }));
const decrement = () => setState((state) => ({ ...state, count: state.count - 1 }));
return { count: 0, increment, decrement };
}
function createContainer(hook) {
let store;
const setState = (partial) => {
const nexStore = partial(store);
// hook中一旦執行 setState 的操作,且狀態變更後,將觸發 onUpdate 更新
if(nexStore !== store){
store = Object.assign({}, store, nexStore);
onUpdate(store);
}
};
// 將改變狀態的方法 setState 傳遞給hook函式
store = hook(setState);
}
const useContainer = createContainer(useCount);
這種方案更加高明,讓狀態管理庫的實現更加簡潔明瞭,庫的體積也會小不少。原始碼可見這裡。
這種方案是 zustand 背後的大致原理。雖然需要開發者先熟悉下對應的寫法,但是 API 與 Hooks 類似,學習成本很低,上手容易。
總結
本文從實現一個計數器場景出發,闡述了多種狀態管理的方案和具體實現。不同狀態管理方案產生都有著各自的背景,也有著各自的優劣。
但是自定義狀態管理庫的設計思想都是差不多的。目前開源社群比較活躍的狀態管理庫大多是如此,不同點主要是在如何感知狀態變更這塊做些文章。
看完本文,想必你已經知道如何進行 React Hooks 下的狀態管理了,那就趕緊行動吧!
本文釋出自網易雲音樂技術團隊,文章未經授權禁止任何形式的轉載。我們常年招收各類技術團隊,如果你準備換工作,又恰好喜歡雲音樂,那就加入我們 grp.music-fe (at) corp.netease.com!