上一篇文章咱们手写了一个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-reduxjava
下面这个简单的例子是一个计数器,跑起来效果以下:react
要实现这个功能,首先咱们要在项目里面添加react-redux
库,而后用它提供的Provider
包裹整个React
App的根组件:git
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
传进去:github
import { createStore } from 'redux'; import reducer from './reducer'; let store = createStore(reducer); export default store;
上面代码中createStore
的参数是一个reducer
,因此咱们还要写个reducer
:json
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
将它链接进去就是这样:redux
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
其实也是一个高阶组件。segmentfault
到这里咱们汇总来看下咱们都用到了哪些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其实提供了一个全局注入变量的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小星星,你的支持是做者持续创做的动力。
做者博文GitHub项目地址: https://github.com/dennis-jiang/Front-End-Knowledges