上一篇文章咱們手寫了一個Redux,可是單純的Redux只是一個狀態機,是沒有UI呈現的,因此通常咱們使用的時候都會配合一個UI庫,好比在React中使用Redux就會用到React-Redux
這個庫。這個庫的做用是將Redux的狀態機和React的UI呈現綁定在一塊兒,當你dispatch action
改變state
的時候,會自動更新頁面。本文仍是從它的基本使用入手來本身寫一個React-Redux
,而後替換官方的NPM庫,並保持功能一致。javascript
本文所有代碼已經上傳GitHub,你們能夠拿下來玩玩:https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/React/react-redux前端
基本用法
下面這個簡單的例子是一個計數器,跑起來效果以下:java
要實現這個功能,首先咱們要在項目裏面添加react-redux
庫,而後用它提供的Provider
包裹整個React
App的根組件:react
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
傳進去:git
import { createStore } from 'redux'; import reducer from './reducer'; let store = createStore(reducer); export default store;
上面代碼中createStore
的參數是一個reducer
,因此咱們還要寫個reducer
:github
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
將它鏈接進去就是這樣:json
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
其實也是一個高階組件。redux
到這裏咱們彙總來看下咱們都用到了哪些API,這些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
,因此這裏咱們須要解決兩個問題:
> 1. 當咱們state
變化的時候檢查最終給到ConnectFunction
的參數有沒有變化 > 2. 若是這個參數有變化,咱們須要從新渲染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
:
> 1. Subscription
負責處理全部的state
變化的回調 > 2. 若是當前鏈接redux
的組件是第一個鏈接redux
的組件,也就是說他是鏈接redux
的根組件,他的state
回調直接註冊到redux store
;同時新建一個Subscription
實例subscription
經過context
傳遞給子級。 > 3. 若是當前鏈接redux
的組件不是鏈接redux
的根組件,也就是說他上面有組件已經註冊到redux store
了,那麼他能夠拿到上面經過context
傳下來的subscription
,源碼裏面這個變量叫parentSub
,那當前組件的更新回調就註冊到parentSub
上。同時再新建一個Subscription
實例,替代context
上的subscription
,繼續往下傳,也就是說他的子組件的回調會註冊到當前subscription
上。 > 4. 當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://github.com/dennis-jiang/Front-End-Knowledges
歡迎關注個人公衆號進擊的大前端第一時間獲取高質量原創~