上一篇文章我們手寫了一個Redux,但是單純的Redux只是一個狀態機,是沒有UI呈現的,所以一般我們使用的時候都會配合一個UI庫,比如在React中使用Redux就會用到React-Redux
這個庫。這個庫的作用是將Redux的狀態機和React的UI呈現綁定在一起,當你dispatch action
改變state
的時候,會自動更新頁面。本文還是從它的基本使用入手來自己寫一個React-Redux
,然后替換官方的NPM庫,並保持功能一致。
本文全部代碼已經上傳GitHub,大家可以拿下來玩玩:https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/React/react-redux
基本用法
下面這個簡單的例子是一個計數器,跑起來效果如下:
要實現這個功能,首先我們要在項目里面添加react-redux
庫,然后用它提供的Provider
包裹整個React
App的根組件:
import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux'
import store from './store'
import App from './App';
ReactDOM.render(
<React.StrictMode>
<Provider store={store}>
<App />
</Provider>
</React.StrictMode>,
document.getElementById('root')
);
上面代碼可以看到我們還給Provider
提供了一個參數store
,這個參數就是Redux的createStore
生成的store
,我們需要調一下這個方法,然后將返回的store
傳進去:
import { createStore } from 'redux';
import reducer from './reducer';
let store = createStore(reducer);
export default store;
上面代碼中createStore
的參數是一個reducer
,所以我們還要寫個reducer
:
const initState = {
count: 0
};
function reducer(state = initState, action) {
switch (action.type) {
case 'INCREMENT':
return {...state, count: state.count + 1};
case 'DECREMENT':
return {...state, count: state.count - 1};
case 'RESET':
return {...state, count: 0};
default:
return state;
}
}
export default reducer;
這里的reduce
會有一個初始state
,里面的count
是0
,同時他還能處理三個action
,這三個action
對應的是UI上的三個按鈕,可以對state
里面的計數進行加減和重置。到這里其實我們React-Redux
的接入和Redux
數據的組織其實已經完成了,后面如果要用Redux
里面的數據的話,只需要用connect
API將對應的state
和方法連接到組件里面就行了,比如我們的計數器組件需要count
這個狀態和加一,減一,重置這三個action
,我們用connect
將它連接進去就是這樣:
import React from 'react';
import { connect } from 'react-redux';
import { increment, decrement, reset } from './actions';
function Counter(props) {
const {
count,
incrementHandler,
decrementHandler,
resetHandler
} = props;
return (
<>
<h3>Count: {count}</h3>
<button onClick={incrementHandler}>計數+1</button>
<button onClick={decrementHandler}>計數-1</button>
<button onClick={resetHandler}>重置</button>
</>
);
}
const mapStateToProps = (state) => {
return {
count: state.count
}
}
const mapDispatchToProps = (dispatch) => {
return {
incrementHandler: () => dispatch(increment()),
decrementHandler: () => dispatch(decrement()),
resetHandler: () => dispatch(reset()),
}
};
export default connect(
mapStateToProps,
mapDispatchToProps
)(Counter)
上面代碼可以看到connect
是一個高階函數,他的第一階會接收mapStateToProps
和mapDispatchToProps
兩個參數,這兩個參數都是函數。mapStateToProps
可以自定義需要將哪些state
連接到當前組件,這些自定義的state
可以在組件里面通過props
拿到。mapDispatchToProps
方法會傳入dispatch
函數,我們可以自定義一些方法,這些方法可以調用dispatch
去dispatch action
,從而觸發state
的更新,這些自定義的方法也可以通過組件的props
拿到,connect
的第二階接收的參數是一個組件,我們可以猜測這個函數的作用就是將前面自定義的state
和方法注入到這個組件里面,同時要返回一個新的組件給外部調用,所以connect
其實也是一個高階組件。
到這里我們匯總來看下我們都用到了哪些API,這些API就是我們后面要手寫的目標:
Provider
: 用來包裹根組件的組件,作用是注入Redux
的store
。
createStore
:Redux
用來創建store
的核心方法,我們另一篇文章已經手寫過了。
connect
:用來將state
和dispatch
注入給需要的組件,返回一個新組件,他其實是個高階組件。
所以React-Redux
核心其實就兩個API,而且兩個都是組件,作用還很類似,都是往組件里面注入參數,Provider
是往根組件注入store
,connect
是往需要的組件注入state
和dispatch
。
在手寫之前我們先來思考下,為什么React-Redux
要設計這兩個API,假如沒有這兩個API,只用Redux
可以嗎?當然是可以的!其實我們用Redux
的目的不就是希望用它將整個應用的狀態都保存下來,每次操作只用dispatch action
去更新狀態,然后UI就自動更新了嗎?那我從根組件開始,每一級都把store
傳下去不就行了嗎?每個子組件需要讀取狀態的時候,直接用store.getState()
就行了,更新狀態的時候就store.dispatch
,這樣其實也能達到目的。但是,如果這樣寫,子組件如果嵌套層數很多,每一級都需要手動傳入store
,比較丑陋,開發也比較繁瑣,而且如果某個新同學忘了傳store
,那后面就是一連串的錯誤了。所以最好有個東西能夠將store
全局的注入組件樹,而不需要一層層作為props
傳遞,這個東西就是Provider
!而且如果每個組件都獨立依賴Redux
會破壞React
的數據流向,這個我們后面會講到。
React的Context API
React其實提供了一個全局注入變量的API,這就是context api。假如我現在有一個需求是要給我們所有組件傳一個文字顏色的配置,我們的顏色配置在最頂級的組件上,當這個顏色改變的時候,下面所有組件都要自動應用這個顏色。那我們可以使用context api注入這個配置:
先使用React.createContext
創建一個context
// 我們使用一個單獨的文件來調用createContext
// 因為這個返回值會被Provider和Consumer在不同的地方引用
import React from 'react';
const TestContext = React.createContext();
export default TestContext;
使用Context.Provider
包裹根組件
創建好了context,如果我們要傳遞變量給某些組件,我們需要在他們的根組件上加上TestContext.Provider
,然后將變量作為value
參數傳給TestContext.Provider
:
import TestContext from './TestContext';
const setting = {
color: '#d89151'
}
ReactDOM.render(
<TestContext.Provider value={setting}>
<App />
</TestContext.Provider>,
document.getElementById('root')
);
使用Context.Consumer
接收參數
上面我們使用Context.Provider
將參數傳遞進去了,這樣被Context.Provider
包裹的所有子組件都可以拿到這個變量,只是拿這個變量的時候需要使用Context.Consumer
包裹,比如我們前面的Counter
組件就可以拿到這個顏色了,只需要將它返回的JSX
用Context.Consumer
包裹一下就行:
// 注意要引入同一個Context
import TestContext from './TestContext';
// ... 中間省略n行代碼 ...
// 返回的JSX用Context.Consumer包裹起來
// 注意Context.Consumer里面是一個方法,這個方法就可以訪問到context參數
// 這里的context也就是前面Provider傳進來的setting,我們可以拿到上面的color變量
return (
<TestContext.Consumer>
{context =>
<>
<h3 style={{color:context.color}}>Count: {count}</h3>
<button onClick={incrementHandler}>計數+1</button>
<button onClick={decrementHandler}>計數-1</button>
<button onClick={resetHandler}>重置</button>
</>
}
</TestContext.Consumer>
);
上面代碼我們通過context
傳遞了一個全局配置,可以看到我們文字顏色已經變了:
使用useContext
接收參數
除了上面的Context.Consumer
可以用來接收context
參數,新版React還有useContext
這個hook可以接收context參數,使用起來更簡單,比如上面代碼可以這樣寫:
const context = useContext(TestContext);
return (
<>
<h3 style={{color:context.color}}>Count: {count}</h3>
<button onClick={incrementHandler}>計數+1</button>
<button onClick={decrementHandler}>計數-1</button>
<button onClick={resetHandler}>重置</button>
</>
);
所以我們完全可以用context api
來傳遞redux store
,現在我們也可以猜測React-Redux
的Provider
其實就是包裝了Context.Provider
,而傳遞的參數就是redux store
,而React-Redux
的connect
HOC其實就是包裝的Context.Consumer
或者useContext
。我們可以按照這個思路來自己實現下React-Redux
了。
手寫Provider
上面說了Provider
用了context api
,所以我們要先建一個context
文件,導出需要用的context
:
// Context.js
import React from 'react';
const ReactReduxContext = React.createContext();
export default ReactReduxContext;
這個文件很簡單,新建一個context
再導出就行了,對應的源碼看這里。
然后將這個context
應用到我們的Provider
組件里面:
import React from 'react';
import ReactReduxContext from './Context';
function Provider(props) {
const {store, children} = props;
// 這是要傳遞的context
const contextValue = { store };
// 返回ReactReduxContext包裹的組件,傳入contextValue
// 里面的內容就直接是children,我們不動他
return (
<ReactReduxContext.Provider value={contextValue}>
{children}
</ReactReduxContext.Provider>
)
}
Provider
的組件代碼也不難,直接將傳進來的store
放到context
上,然后直接渲染children
就行,對應的源碼看這里。
手寫connect
基本功能
其實connect
才是React-Redux中最難的部分,里面功能復雜,考慮的因素很多,想要把它搞明白我們需要一層一層的來看,首先我們實現一個只具有基本功能的connect
。
import React, { useContext } from 'react';
import ReactReduxContext from './Context';
// 第一層函數接收mapStateToProps和mapDispatchToProps
function connect(mapStateToProps, mapDispatchToProps) {
// 第二層函數是個高階組件,里面獲取context
// 然后執行mapStateToProps和mapDispatchToProps
// 再將這個結果組合用戶的參數作為最終參數渲染WrappedComponent
// WrappedComponent就是我們使用connext包裹的自己的組件
return function connectHOC(WrappedComponent) {
function ConnectFunction(props) {
// 復制一份props到wrapperProps
const { ...wrapperProps } = props;
// 獲取context的值
const context = useContext(ReactReduxContext);
const { store } = context; // 解構出store
const state = store.getState(); // 拿到state
// 執行mapStateToProps和mapDispatchToProps
const stateProps = mapStateToProps(state);
const dispatchProps = mapDispatchToProps(store.dispatch);
// 組裝最終的props
const actualChildProps = Object.assign({}, stateProps, dispatchProps, wrapperProps);
// 渲染WrappedComponent
return <WrappedComponent {...actualChildProps}></WrappedComponent>
}
return ConnectFunction;
}
}
export default connect;
觸發更新
用上面的Provider
和connect
替換官方的react-redux
其實已經可以渲染出頁面了,但是點擊按鈕還不會有反應,因為我們雖然通過dispatch
改變了store
中的state
,但是這種改變並沒有觸發我們組件的更新。之前Redux那篇文章講過,可以用store.subscribe
來監聽state
的變化並執行回調,我們這里需要注冊的回調是檢查我們最終給WrappedComponent
的props
有沒有變化,如果有變化就重新渲染ConnectFunction
,所以這里我們需要解決兩個問題:
- 當我們
state
變化的時候檢查最終給到ConnectFunction
的參數有沒有變化- 如果這個參數有變化,我們需要重新渲染
ConnectFunction
檢查參數變化
要檢查參數的變化,我們需要知道上次渲染的參數和本地渲染的參數,然后拿過來比一下就知道了。為了知道上次渲染的參數,我們可以直接在ConnectFunction
里面使用useRef
將上次渲染的參數記錄下來:
// 記錄上次渲染參數
const lastChildProps = useRef();
useLayoutEffect(() => {
lastChildProps.current = actualChildProps;
}, []);
注意lastChildProps.current
是在第一次渲染結束后賦值,而且需要使用useLayoutEffect
來保證渲染后立即同步執行。
因為我們檢測參數變化是需要重新計算actualChildProps
,計算的邏輯其實都是一樣的,我們將這塊計算邏輯抽出來,成為一個單獨的方法childPropsSelector
:
function childPropsSelector(store, wrapperProps) {
const state = store.getState(); // 拿到state
// 執行mapStateToProps和mapDispatchToProps
const stateProps = mapStateToProps(state);
const dispatchProps = mapDispatchToProps(store.dispatch);
return Object.assign({}, stateProps, dispatchProps, wrapperProps);
}
然后就是注冊store
的回調,在里面來檢測參數是否變了,如果變了就強制更新當前組件,對比兩個對象是否相等,React-Redux
里面是采用的shallowEqual
,也就是淺比較,也就是只對比一層,如果你mapStateToProps
返回了好幾層結構,比如這樣:
{
stateA: {
value: 1
}
}
你去改了stateA.value
是不會觸發重新渲染的,React-Redux
這樣設計我想是出於性能考慮,如果是深比較,比如遞歸去比較,比較浪費性能,而且如果有循環引用還可能造成死循環。采用淺比較就需要用戶遵循這種范式,不要傳入多層結構,這點在官方文檔中也有說明。我們這里直接抄一個它的淺比較:
// shallowEqual.js
function is(x, y) {
if (x === y) {
return x !== 0 || y !== 0 || 1 / x === 1 / y
} else {
return x !== x && y !== y
}
}
export default function shallowEqual(objA, objB) {
if (is(objA, objB)) return true
if (
typeof objA !== 'object' ||
objA === null ||
typeof objB !== 'object' ||
objB === null
) {
return false
}
const keysA = Object.keys(objA)
const keysB = Object.keys(objB)
if (keysA.length !== keysB.length) return false
for (let i = 0; i < keysA.length; i++) {
if (
!Object.prototype.hasOwnProperty.call(objB, keysA[i]) ||
!is(objA[keysA[i]], objB[keysA[i]])
) {
return false
}
}
return true
}
在回調里面檢測參數變化:
// 注冊回調
store.subscribe(() => {
const newChildProps = childPropsSelector(store, wrapperProps);
// 如果參數變了,記錄新的值到lastChildProps上
// 並且強制更新當前組件
if(!shallowEqual(newChildProps, lastChildProps.current)) {
lastChildProps.current = newChildProps;
// 需要一個API來強制更新當前組件
}
});
強制更新
要強制更新當前組件的方法不止一個,如果你是用的Class
組件,你可以直接this.setState({})
,老版的React-Redux
就是這么干的。但是新版React-Redux
用hook重寫了,那我們可以用React提供的useReducer
或者useState
hook,React-Redux
源碼用了useReducer
,為了跟他保持一致,我也使用useReducer
:
function storeStateUpdatesReducer(count) {
return count + 1;
}
// ConnectFunction里面
function ConnectFunction(props) {
// ... 前面省略n行代碼 ...
// 使用useReducer觸發強制更新
const [
,
forceComponentUpdateDispatch
] = useReducer(storeStateUpdatesReducer, 0);
// 注冊回調
store.subscribe(() => {
const newChildProps = childPropsSelector(store, wrapperProps);
if(!shallowEqual(newChildProps, lastChildProps.current)) {
lastChildProps.current = newChildProps;
forceComponentUpdateDispatch();
}
});
// ... 后面省略n行代碼 ...
}
connect
這塊代碼主要對應的是源碼中connectAdvanced
這個類,基本原理和結構跟我們這個都是一樣的,只是他寫的更靈活,支持用戶傳入自定義的childPropsSelector
和合並stateProps, dispatchProps, wrapperProps
的方法。有興趣的朋友可以去看看他的源碼:https://github.com/reduxjs/react-redux/blob/master/src/components/connectAdvanced.js
到這里其實已經可以用我們自己的React-Redux
替換官方的了,計數器的功能也是支持了。但是下面還想講一下React-Redux
是怎么保證組件的更新順序的,因為源碼中很多代碼都是在處理這個。
保證組件更新順序
前面我們的Counter
組件使用connect
連接了redux store
,假如他下面還有個子組件也連接到了redux store
,我們就要考慮他們的回調的執行順序的問題了。我們知道React是單向數據流的,參數都是由父組件傳給子組件的,現在引入了Redux
,即使父組件和子組件都引用了同一個變量count
,但是子組件完全可以不從父組件拿這個參數,而是直接從Redux
拿,這樣就打破了React
本來的數據流向。在父->子
這種單向數據流中,如果他們的一個公用變量變化了,肯定是父組件先更新,然后參數傳給子組件再更新,但是在Redux
里,數據變成了Redux -> 父,Redux -> 子
,父
與子
完全可以根據Redux
的數據進行獨立更新,而不能完全保證父級先更新,子級再更新的流程。所以React-Redux
花了不少功夫來手動保證這個更新順序,React-Redux
保證這個更新順序的方案是在redux store
外,再單獨創建一個監聽者類Subscription
:
Subscription
負責處理所有的state
變化的回調- 如果當前連接
redux
的組件是第一個連接redux
的組件,也就是說他是連接redux
的根組件,他的state
回調直接注冊到redux store
;同時新建一個Subscription
實例subscription
通過context
傳遞給子級。- 如果當前連接
redux
的組件不是連接redux
的根組件,也就是說他上面有組件已經注冊到redux store
了,那么他可以拿到上面通過context
傳下來的subscription
,源碼里面這個變量叫parentSub
,那當前組件的更新回調就注冊到parentSub
上。同時再新建一個Subscription
實例,替代context
上的subscription
,繼續往下傳,也就是說他的子組件的回調會注冊到當前subscription
上。- 當
state
變化了,根組件注冊到redux store
上的回調會執行更新根組件,同時根組件需要手動執行子組件的回調,子組件回調執行會觸發子組件更新,然后子組件再執行自己subscription
上注冊的回調,觸發孫子組件更新,孫子組件再調用注冊到自己subscription
上的回調。。。這樣就實現了從根組件開始,一層一層更新子組件的目的,保證了父->子
這樣的更新順序。
Subscription
類
所以我們先新建一個Subscription
類:
export default class Subscription {
constructor(store, parentSub) {
this.store = store
this.parentSub = parentSub
this.listeners = []; // 源碼listeners是用鏈表實現的,我這里簡單處理,直接數組了
this.handleChangeWrapper = this.handleChangeWrapper.bind(this)
}
// 子組件注冊回調到Subscription上
addNestedSub(listener) {
this.listeners.push(listener)
}
// 執行子組件的回調
notifyNestedSubs() {
const length = this.listeners.length;
for(let i = 0; i < length; i++) {
const callback = this.listeners[i];
callback();
}
}
// 回調函數的包裝
handleChangeWrapper() {
if (this.onStateChange) {
this.onStateChange()
}
}
// 注冊回調的函數
// 如果parentSub有值,就將回調注冊到parentSub上
// 如果parentSub沒值,那當前組件就是根組件,回調注冊到redux store上
trySubscribe() {
this.parentSub
? this.parentSub.addNestedSub(this.handleChangeWrapper)
: this.store.subscribe(this.handleChangeWrapper)
}
}
改造Provider
然后在我們前面自己實現的React-Redux
里面,我們的根組件始終是Provider
,所以Provider
需要實例化一個Subscription
並放到context
上,而且每次state
更新的時候需要手動調用子組件回調,代碼改造如下:
import React, { useMemo, useEffect } from 'react';
import ReactReduxContext from './Context';
import Subscription from './Subscription';
function Provider(props) {
const {store, children} = props;
// 這是要傳遞的context
// 里面放入store和subscription實例
const contextValue = useMemo(() => {
const subscription = new Subscription(store)
// 注冊回調為通知子組件,這樣就可以開始層級通知了
subscription.onStateChange = subscription.notifyNestedSubs
return {
store,
subscription
}
}, [store])
// 拿到之前的state值
const previousState = useMemo(() => store.getState(), [store])
// 每次contextValue或者previousState變化的時候
// 用notifyNestedSubs通知子組件
useEffect(() => {
const { subscription } = contextValue;
subscription.trySubscribe()
if (previousState !== store.getState()) {
subscription.notifyNestedSubs()
}
}, [contextValue, previousState])
// 返回ReactReduxContext包裹的組件,傳入contextValue
// 里面的內容就直接是children,我們不動他
return (
<ReactReduxContext.Provider value={contextValue}>
{children}
</ReactReduxContext.Provider>
)
}
export default Provider;
改造connect
有了Subscription
類,connect
就不能直接注冊到store
了,而是應該注冊到父級subscription
上,更新的時候除了更新自己還要通知子組件更新。在渲染包裹的組件時,也不能直接渲染了,而是應該再次使用Context.Provider
包裹下,傳入修改過的contextValue
,這個contextValue
里面的subscription
應該替換為自己的。改造后代碼如下:
import React, { useContext, useRef, useLayoutEffect, useReducer } from 'react';
import ReactReduxContext from './Context';
import shallowEqual from './shallowEqual';
import Subscription from './Subscription';
function storeStateUpdatesReducer(count) {
return count + 1;
}
function connect(
mapStateToProps = () => {},
mapDispatchToProps = () => {}
) {
function childPropsSelector(store, wrapperProps) {
const state = store.getState(); // 拿到state
// 執行mapStateToProps和mapDispatchToProps
const stateProps = mapStateToProps(state);
const dispatchProps = mapDispatchToProps(store.dispatch);
return Object.assign({}, stateProps, dispatchProps, wrapperProps);
}
return function connectHOC(WrappedComponent) {
function ConnectFunction(props) {
const { ...wrapperProps } = props;
const contextValue = useContext(ReactReduxContext);
const { store, subscription: parentSub } = contextValue; // 解構出store和parentSub
const actualChildProps = childPropsSelector(store, wrapperProps);
const lastChildProps = useRef();
useLayoutEffect(() => {
lastChildProps.current = actualChildProps;
}, [actualChildProps]);
const [
,
forceComponentUpdateDispatch
] = useReducer(storeStateUpdatesReducer, 0)
// 新建一個subscription實例
const subscription = new Subscription(store, parentSub);
// state回調抽出來成為一個方法
const checkForUpdates = () => {
const newChildProps = childPropsSelector(store, wrapperProps);
// 如果參數變了,記錄新的值到lastChildProps上
// 並且強制更新當前組件
if(!shallowEqual(newChildProps, lastChildProps.current)) {
lastChildProps.current = newChildProps;
// 需要一個API來強制更新當前組件
forceComponentUpdateDispatch();
// 然后通知子級更新
subscription.notifyNestedSubs();
}
};
// 使用subscription注冊回調
subscription.onStateChange = checkForUpdates;
subscription.trySubscribe();
// 修改傳給子級的context
// 將subscription替換為自己的
const overriddenContextValue = {
...contextValue,
subscription
}
// 渲染WrappedComponent
// 再次使用ReactReduxContext包裹,傳入修改過的context
return (
<ReactReduxContext.Provider value={overriddenContextValue}>
<WrappedComponent {...actualChildProps} />
</ReactReduxContext.Provider>
)
}
return ConnectFunction;
}
}
export default connect;
到這里我們的React-Redux
就完成了,跑起來的效果跟官方的效果一樣,完整代碼已經上傳GitHub了:https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/React/react-redux
下面我們再來總結下React-Redux
的核心原理。
總結
React-Redux
是連接React
和Redux
的庫,同時使用了React
和Redux
的API。React-Redux
主要是使用了React
的context api
來傳遞Redux
的store
。Provider
的作用是接收Redux store
並將它放到context
上傳遞下去。connect
的作用是從Redux store
中選取需要的屬性傳遞給包裹的組件。connect
會自己判斷是否需要更新,判斷的依據是需要的state
是否已經變化了。connect
在判斷是否變化的時候使用的是淺比較,也就是只比較一層,所以在mapStateToProps
和mapDispatchToProps
中不要反回多層嵌套的對象。- 為了解決父組件和子組件各自獨立依賴
Redux
,破壞了React
的父級->子級
的更新流程,React-Redux
使用Subscription
類自己管理了一套通知流程。 - 只有連接到
Redux
最頂級的組件才會直接注冊到Redux store
,其他子組件都會注冊到最近父組件的subscription
實例上。 - 通知的時候從根組件開始依次通知自己的子組件,子組件接收到通知的時候,先更新自己再通知自己的子組件。
參考資料
官方文檔:https://react-redux.js.org/
GitHub源碼:https://github.com/reduxjs/react-redux/
文章的最后,感謝你花費寶貴的時間閱讀本文,如果本文給了你一點點幫助或者啟發,請不要吝嗇你的贊和GitHub小星星,你的支持是作者持續創作的動力。
歡迎關注我的公眾號進擊的大前端第一時間獲取高質量原創~
“前端進階知識”系列文章:https://juejin.im/post/5e3ffc85518825494e2772fd
“前端進階知識”系列文章源碼GitHub地址: https://github.com/dennis-jiang/Front-End-Knowledges