前言
各位使用react技術棧的小夥伴都不可避免的接觸過redux
+ react-redux
的這套組合,眾所周知redux是一個非常精簡的庫,它和react是沒有做任何結合的,甚至可以在vue專案中使用。
redux的核心狀態管理實現其實就幾行程式碼
function createStore(reducer) {
let currentState
let subscribers = []
function dispatch(action) {
currentState = reducer(currentState, action);
subscribers.forEach(s => s())
}
function getState() {
return currentState;
}
function subscribe(subscriber) {
subscribers.push(subscriber)
return function unsubscribe() {
...
}
}
dispatch({ type: 'INIT' });
return {
dispatch,
getState,
};
}
複製程式碼
它就是利用閉包管理了state等變數,然後在dispatch的時候通過使用者定義reducer拿到新狀態賦值給state,再把外部通過subscribe的訂閱給觸發一下。
那redux的實現簡單了,react-redux的實現肯定就需要相對複雜,它需要考慮如何和react的渲染結合起來,如何優化效能。
目標
- 本文目標是儘可能簡短的實現
react-redux
v7中的hook用法部分Provider
,useSelector
,useDispatch
方法。(不實現connect
方法) - 可能會和官方版本的一些複雜實現不一樣,但是保證主要的流程一致。
- 用TypeScript實現,並且能獲得完善的型別提示。
預覽
預覽地址:sl1673495.github.io/tiny-react-…
效能
說到效能這個點,自從React Hook推出以後,有了useContext
和useReducer
這些方便的api,新的狀態管理庫如同雨後春筍版的冒了出來,其中的很多就是利用了Context
做狀態的向下傳遞。
舉一個最簡單的狀態管理的例子
export const StoreContext = React.createContext();
function App({ children }) {
const [state, setState] = useState({});
return <StoreContext.Provider value={{ state, setState }}>{children}</StoreContext.Provider>;
}
function Son() {
const { state } = useContext(StoreContext);
return <div>state是{state.xxx}</div>;
}
複製程式碼
利用useState或者useContext,可以很輕鬆的在所有元件之間通過Context共享狀態。
但是這種模式的缺點在於Context會帶來一定的效能問題,下面是React官方文件中的描述:
想像這樣一個場景,在剛剛所描述的Context狀態管理模式下,我們的全域性狀態中有count
和message
兩個狀態分別給通過StoreContext.Provider
向下傳遞
Counter
計數器元件使用了count
Chatroom
聊天室元件使用了message
而在計數器元件通過Context中拿到的setState觸發了count
改變的時候,
由於聊天室元件也利用useContext
消費了用於狀態管理的StoreContext,所以聊天室元件也會被強制重新渲染,這就造成了效能浪費。
雖然這種情況可以用useMemo
進行優化,但是手動優化和管理依賴必然會帶來一定程度的心智負擔,而在不手動優化的情況下,肯定無法達到上面動圖中的重渲染優化。
那麼react-redux
作為社群知名的狀態管理庫,肯定被很多大型專案所使用,大型專案裡的狀態可能分散在各個模組下,它是怎麼解決上述的效能缺陷的呢?接著往下看吧。
缺陷示例
在我之前寫的類vuex語法的狀態管理庫react-vuex-hook中,就會有這樣的問題。因為它就是用了Context
+ useReducer
的模式。
你可以直接在 線上示例 這裡,在左側選單欄選擇需要優化的場景
,即可看到上述效能問題的重現,優化方案也已經寫在文件底部。
這也是為什麼我覺得Context
+ useReducer
的模式更適合在小型模組之間共享狀態,而不是在全域性。
使用
本文的專案就上述效能場景提煉而成,由
聊天室
元件,用了store中的count
計數器
元件,用了store中的message
控制檯
元件,用來監控元件的重新渲染。
redux的定義
redux的使用很傳統,跟著官方文件對於TypeScript的指導走起來,並且把型別定義和store都export出去。
import { createStore } from 'redux';
type AddAction = {
type: 'add';
};
type ChatAction = {
type: 'chat';
payload: string;
};
type LogAction = {
type: 'log';
payload: string;
};
const initState = {
message: 'Hello',
logs: [] as string[],
};
export type ActionType = AddAction | ChatAction | LogAction;
export type State = typeof initState;
function reducer(state: State, action: ActionType): State {
switch (action.type) {
case 'add':
return {
...state,
count: state.count + 1,
};
case 'chat':
return {
...state,
message: action.payload,
};
case 'log':
return {
...state,
logs: [action.payload, ...state.logs],
};
default:
return initState;
}
}
export const store = createStore(reducer);
複製程式碼
在元件中使用
import React, { useState, useCallback } from 'react';
import { Card, Button, Input } from 'antd';
import { Provider, useSelector, useDispatch } from '../src';
import { store, State, ActionType } from './store';
import './index.css';
import 'antd/dist/antd.css';
function Count() {
const count = useSelector((state: State) => state.count);
const dispatch = useDispatch<ActionType>();
// 同步的add
const add = useCallback(() => dispatch({ type: 'add' }), []);
dispatch({
type: 'log',
payload: '計數器元件重新渲染?',
});
return (
<Card hoverable style={{ marginBottom: 24 }}>
<h1>計數器</h1>
<div className="chunk">
<div className="chunk">store中的count現在是 {count}</div>
<Button onClick={add}>add</Button>
</div>
</Card>
);
}
export default () => {
return (
<Provider store={store}>
<Count />
</Provider>
);
};
複製程式碼
可以看到,我們用Provider
元件裡包裹了Count
元件,並且把redux的store傳遞了下去
在子元件裡,通過useDispatch
可以拿到redux的dispatch, 通過useSelector
可以訪問到store,拿到其中任意的返回值。
實現
用最簡短的方式實現程式碼,探究react-redux為什麼能在count
發生改變的時候不讓使用了message
的元件重新渲染。
實現Context
利用官方api構建context,並且提供一個自定義hook: useReduxContext
去訪問這個context,對於忘了用Provider包裹的情況進行一些錯誤提示:
對於不熟悉自定義hook的小夥伴,可以看我之前寫的這篇文章:
使用React Hooks + 自定義Hook封裝一步一步打造一個完善的小型應用。
import React, { useContext } from 'react';
import { Store } from 'redux';
interface ContextType {
store: Store;
}
export const Context = React.createContext<ContextType | null>(null);
export function useReduxContext() {
const contextValue = useContext(Context);
if (!contextValue) {
throw new Error(
'could not find react-redux context value; please ensure the component is wrapped in a <Provider>',
);
}
return contextValue;
}
複製程式碼
實現Provider
import React, { FC } from 'react';
import { Store } from 'redux';
import { Context } from './Context';
interface ProviderProps {
store: Store;
}
export const Provider: FC<ProviderProps> = ({ store, children }) => {
return <Context.Provider value={{ store }}>{children}</Context.Provider>;
};
複製程式碼
實現useDispatch
這裡就是簡單的把dispatch返回出去,通過泛型傳遞讓外部使用的時候可以獲得型別提示。
泛型推導不熟悉的小夥伴可以看一下之前這篇:
進階實現智慧型別推導的簡化版Vuex
import { useReduxContext } from './Context';
import { Dispatch, Action } from 'redux';
export function useDispatch<A extends Action>() {
const { store } = useReduxContext();
return store.dispatch as Dispatch<A>;
}
複製程式碼
實現useSelector
這裡才是重點,這個api有兩個引數。
selector
: 定義如何從state中取值,如state => state.count
equalityFn
: 定義如何判斷渲染之間值是否有改變。
在效能章節也提到過,大型應用中必須做到只有自己使用的狀態改變了,才去重新渲染,那麼equalityFn
就是判斷是否渲染的關鍵了。
關鍵流程(初始化):
- 根據傳入的selector從redux的store中取值。
- 定義一個
latestSelectedState
儲存上一次selector返回的值。 - 定義一個
checkForceUpdate
方法用來控制當狀態發生改變的時候,讓當前元件的強制渲染。 - 利用
store.subscribe
訂閱一次redux的store,下次redux的store發生變化執行checkForceUpdate
。
關鍵流程(更新)
- 當使用者使用
dispatch
觸發了redux store的變動後,store會觸發checkForceUpdate
方法。 checkForceUpdate
中,從latestSelectedState
拿到上一次selector的返回值,再利用selector(store)拿到最新的值,兩者利用equalityFn
進行比較。- 根據比較,判斷是否需要強制渲染元件。
有了這個思路,就來實現程式碼吧:
import { useReducer, useRef, useEffect } from 'react';
import { useReduxContext } from './Context';
type Selector<State, Selected> = (state: State) => Selected;
type EqualityFn<Selected> = (a: Selected, b: Selected) => boolean;
// 預設比較的方法
const defaultEqualityFn = <T>(a: T, b: T) => a === b;
export function useSelector<State, Selected>(
selector: Selector<State, Selected>,
equalityFn: EqualityFn<Selected> = defaultEqualityFn,
) {
const { store } = useReduxContext();
// 強制讓當前元件渲染的方法。
const [, forceRender] = useReducer(s => s + 1, 0);
// 儲存上一次selector的返回值。
const latestSelectedState = useRef<Selected>();
// 根據使用者傳入的selector,從store中拿到使用者想要的值。
const selectedState = selector(store.getState());
// 檢查是否需要強制更新
function checkForUpdates() {
// 從store中拿到最新的值
const newSelectedState = selector(store.getState());
// 如果比較相等,就啥也不做
if (equalityFn(newSelectedState, latestSelectedState.current)) {
return;
}
// 否則更新ref中儲存的上一次渲染的值
// 然後強制渲染
latestSelectedState.current = newSelectedState;
forceRender();
}
// 元件第一次渲染後 執行訂閱store的邏輯
useEffect(() => {
// ?重點,去訂閱redux store的變化
// 在使用者呼叫了dispatch後,執行checkForUpdates
const unsubscribe = store.subscribe(checkForUpdates);
// 元件被銷燬後 需要呼叫unsubscribe停止訂閱
return unsubscribe;
}, []);
return selectedState;
}
複製程式碼
總結
本文涉及到的原始碼地址:
github.com/sl1673495/t…
原版的react-redux的實現肯定比這裡的簡化版要複雜的多,它要考慮class元件的使用,以及更多的優化以及邊界情況。
從簡化版的實現入手,我們可以更清晰的得到整個流程脈絡,如果你想進一步的學習原始碼,也可以考慮多花點時間去看官方原始碼並且單步除錯。