接上回 Redux 的实现,我们在之前的章节当中,从一个简单的示例开始一步一步推导出 Redux
的实现方式,但是之前我们也提到过,它其实跟 React
一点关系都没有,所以在本章当中我们会把 React
和 Redux
结合起来,用 Redux
模式帮助我们来管理 React
的应用状态
在前端当中应用的状态存在的问题就是一个状态可能被多个组件依赖或者影响,而 React
并没有提供好的解决方案,我们只能把状态提升到依赖或者影响这个状态的所有组件的公共父组件上,也就是我们可以把共享状态放到父组件的 Context
上,让这个父组件以下的所有组件都可以从 Context
中直接获取到状态而不需要一层层地进行传递了
但是直接从 Context
里面存放、获取数据增强了组件的耦合性,并且所有组件都可以修改 Context
里面的状态就像谁都可以修改共享状态一样,导致程序运行的不可预料,既然这样,我们为什么不把 Context
和 store
结合起来呢?毕竟 store
的数据不能直接被修改,而是约定只能通过 dispatch
来进行修改,这样的话每个组件既可以去 Context
里面获取 store
从而获取状态,又不用担心它们乱改数据
最终完整代码可见 react-redux 的手动实现
初始化
所以我们就来尝试一下,就拿官方文档当中那个主题色的示例,稍微的调整一下,比如我们要做下面这样的组件树
1 | └─ App |
主体容器 App
它有两个子组件 Header
和 Content
,Header
和 Content
的组件的文本内容会随着主题色的变化而变化,而 Content
下的子组件 ThemeSwitch
有两个按钮,可以切换红色和蓝色两种主题,按钮的颜色也会随着主题色的变化而变化,各组件代码如下
1 | export default class Header extends Component { |
1 | export default class Content extends Component { |
1 | export default class ThemeSwitch extends Component { |
1 | class App extends Component { |
当然现在文本是没有颜色的,而且点击按钮也不会有什么反应,这些内容我们会在后面慢慢来进行完善
结合 Context 和 Store
下面我们就来构建 store
,用的也就是我们在之前章节当中实现的 createStore
方法,然后我们在构建一个 themeReducer
来生成一个 store
,如下
1 | function createStore(reducer) { |
themeReducer
定义了一个表示主题色的状态 themeColor
,并且规定了一种操作 CHNAGE_COLOR
,只能通过这种操作修改颜色,现在我们把 store
放到 App
的 Context
里面,这样每个子组件都可以获取到 store
了
1 | class App extends Component { |
这里有一个看上去可能有些疑惑的 childContextTypes
,它的作用其实与 propsType
验证组件 props
参数的作用类似,不过它验证的是 getChildContext
返回的对象,那么为什么又要验证 Context
呢?
这是因为 Context
是一个危险的特性,所以 React
团队将其使用方式设置的复杂一些,提高使用门槛的同时也会让你注意到它的危险性,如果你要给组件设置 Context
,那么 childContextTypes
是必写的(现在可以使用 useContext()
来简化我们的操作)
下面我们就可以来调整 Header
组件,让它从 Context
里面获取 store
,并且获取里面的 themeColor
状态来设置自己的颜色
1 | export default class Header extends Component { |
同样的,作为子组件的 Header
,想要获取 Context
里面的内容的话,就必须写 contextTypes
来声明和验证你需要获取的状态的类型,它也是必写的,如果你不写就无法获取 Context
里面的状态
运行完成以后可以发现,标题的颜色已经变成了红色,其实上面的代码逻辑很简单,我们在 constructor
里面初始化了组件自己的 themeColor
状态,然后在生命周期中调用 _updateThemeColor,_updateThemeColor
会从 Context
里面把 store
取出来,然后通过 store.getState()
获取当前的 state
对象,并且用里面的 themeColor
字段设置组件的 state.themeColor
同时通过 store.subscribe
进行监听,在数据变化的时候重新调用 _updateThemeColor
,而 _updateThemeColor
会去 store
里面取最新的 themeColor
然后通过 setState
重新渲染组件
同理,我们将 Content
组件和 ThemeSwitch
组件也调整成从 store
当中来获取主题色
1 | export default class Content extends Component { |
1 | export default class ThemeSwitch extends Component { |
运行以后可以发现,主题已经完全生效了,此时整个页面当中的元素都是红色的,当然现在点按钮还是没什么效果,所以我们接下来就是给按钮添加点击事件
1 | export default class ThemeSwitch extends Component { |
我们给两个按钮都加上了 onClick
事件监听,并绑定到了 handleSwitchColor
方法上,在点击的时候分别给这个方法传入不同的颜色,handleSwitchColor
会根据传入的颜色来 store.dispatch
一个 action
去修改颜色
如此一来,我们就完成了自由的切换主题颜色的功能了,但是其中还有不少可以优化的地方,我们下面慢慢来看
Connect 和 mapStateToProps
我们仔细观察我们之前设计的组件,发现有两个比较严重的问题
- 有大量重复的逻辑,它们基本的逻辑都是取出
Context
,得到里面的store
,然后用里面的状态设置自己的状态,这些代码逻辑其实都是相同的 - 对
Context
依赖性过强,这些组件都要依赖Context
来取数据,使得这个组件复用性基本为零
所以我们需要针对以上两点问题来进行处理,关于第一点,我们都知道在 React
当中有一个 HOC
(高阶组件)的概念,我们可以把一些可复用的逻辑放在高阶组件当中,高阶组件包装的新组件和原来组件之间通过 props
传递信息,减少代码的重复程度
至于第二点,我们可以将其改写成为 UI
组件,关于 UI
组件,也称为 Dumb Component
,因为你传递给它什么,它就渲染什么出来,对参数(props
)以外的数据零依赖,也不产生副作用,所以我们需要高阶组件来帮助我们从 Context
取数据,使用高阶组件和 Context
打交道,把里面数据取出来通过 props
传给 UI
组件,也就是如下图当中所示
我们把这个高阶组件起名为 connect
,因为它把 UI
组件和 Context
连接(connect
)起来了
1 | import React, { Component } from 'react' |
connect
函数接受一个组件 WrappedComponent
作为参数,把这个组件包含在一个新的组件 Connect
里面,Connect
会去 Context
里面取出 store
,现在要把 store
里面的数据取出来通过 props
传给 WrappedComponent
,但是每个传进去的组件需要 store
里面的数据都不一样的,所以还需要告诉高级组件我们需要什么数据,高阶组件才能正确地去取数据
为了解决这个问题,我们需要一个映射函数来告诉 store
如何返回我们需要的数据,我们将其命名为 mapStateToProps
,如下
1 | const mapStateToProps = (state) => { |
这个函数会接受 store.getState()
的结果作为参数,然后返回一个对象,这个对象是根据 state
生成的,也就是我们使用 mapStateTopProps
去告知 Connect
应该如何去 store
里面取数据,然后得到我们需要的数据以后,再把这个函数的返回结果传给被包装的组件
1 | import React, { Component } from 'react' |
其中的 {...stateProps}
意思是把这个对象里面的属性全部通过 props
方式传递进去,connect
现在接受一个参数 mapStateToProps
,然后返回一个函数,这个返回的函数才是高阶组件,它会接受一个组件作为参数,然后用 Connect
把组件包装以后再返回,connect
的用法是
1 | // ... |
我们把上面 connect
的函数代码单独分离到一个模块当中,再把之前的监听数据变化重新渲染的逻辑放到其中调整一下,并将其取名为 react-redux.js
1 | export const connect = (mapStateToProps) => (WrappedComponent) => { |
我们在 Connect
组件的 constructor
里面初始化了 state.allProps
,它是一个对象,用来保存需要传给被包装组件的所有的参数,为了让 connect
返回新组件和被包装的组件使用参数保持一致,我们会把所有传给 Connect
的 props
原封不动地传给 WrappedComponent
,所以在 _updateProps
里面会把 stateProps
和 this.props
合并到 this.state.allProps
里面,再通过 render
方法把所有参数都传给 WrappedComponent
mapStateToProps
也发生点变化,它现在可以接受两个参数了,我们会把传给 Connect
组件的 props
参数也传给它,那么它生成的对象配置性就更强了,我们可以根据 store
里面的 state
和外界传入的 props
生成我们想传给被包装组件的参数,接下来我们就可以在 Header
当中来进行使用了
1 | class Header extends Component { |
如上,可以发现我们在 Header
当中删掉了大部分关于 Context
的代码,它除了 props
什么也不依赖,所以它是一个纯粹的 UI
组件,只需要通过 connect
来取得数据,但是我们不需要知道 connect
是怎么和 Context
打交道的,所以只需要传递一个 mapStateToProps
告诉它应该怎么取数据就可以了,再用同样的方式来修改 Content
1 | class Content extends Component { |
修改以后再次刷新界面,发现功能还是跟之前一样,但是我们的 Header
和 Content
的代码都大大减少了,但是我们的事情并没有做完,接下来我们还需要继续重构 ThemeSwitch
mapDispatchToProps
在重构 ThemeSwitch
的时候我们发现,ThemeSwitch
除了需要 store
里面的数据以外,还需要 store
来 dispatch
,但是我们目前版本的 connect
是达不到这个效果的,所以我们需要改进它,但是仔细一想,既然可以通过给 connect
函数传入 mapStateToProps
来告诉它如何获取、整合状态,那么我们也可以给它传入另外一个参数来告诉它我们的组件需要如何触发 dispatch
的,我们把这个参数叫 mapDispatchToProps
1 | const mapDispatchToProps = (dispatch) => { |
和 mapStateToProps
一样,它返回一个对象,这个对象内容会同样被 connect
当作是 props
参数传给被包装的组件,而不一样的是这个函数不是接受 state
作为参数,而是 dispatch
,你可以在返回的对象内部定义一些函数,这些函数会用到 dispatch
来触发特定的 action
,所以我们调整 connect
让它能接受这样的 mapDispatchToProps
1 | import React, { Component } from 'react' |
在 _updateProps
内部,我们把 store.dispatch
作为参数传给 mapDispatchToProps
,它会返回一个对象 dispatchProps
,接着把 stateProps
、dispatchProps
、this.props
三者合并到 this.state.allProps
里面去,这三者的内容都会在 render
函数内全部传给被包装的组件
这时候我们就可以重构 ThemeSwitch
,让它摆脱 store.dispatch
1 | class ThemeSwitch extends Component { |
现在的 ThemeSwitch
只依赖外界传进来的 themeColor
和 onSwitchColor
,但是 ThemeSwitch
内部并不知道这两个参数其实都是我们去 store
里面取的,此时我们的三个组件的重构都已经完成了,代码大大减少、不依赖 Context
,并且功能和原来一样
Provider
至此,我们的大体结构已经搭建的差不多了,但是还有一点就是我们能不能将和 Context
相关的代码从所有业务组件中清除出去,这样一来就可以保证我们的业务组件都是干净的,所以我们来稍微的重构一下我们的 App
组件
在 App
组件当中之所以需要用到 Context
,就是因为要把 store
存放到里面,好让子组件 connect
的时候能够取到 store
,所以我们可以额外构建一个单独的组件专门来做这件事情,然后让这个组件成为组件树的根节点,那么它的子组件都可以获取到 Context
了,我们把这个组件叫 Provider
,因为它提供(provide
)了 store
1 | export class Provider extends Component { |
Provider
做的事情也很简单,它就是一个容器组件,会把嵌套的内容原封不动作为自己的子组件渲染出来,它还会把外界传给它的 props.store
放到 Context
,这样子组件 connect
的时候都可以获取到,下面我们再来调整 App
组件,也就是删除 App
里面所有关于 Context
的代码,整理过的 App
如下所示,可以发现现在已经变得很干净了
1 | // 删除 App 里面所有关于 context 的代码 |
这样我们就把所有关于 Context
的代码从组件里面删除了,然后将之前在 App
组件当中生成 store
等一系列操作移动到我们的主文件当中,如下
1 | import React from 'react' |
至此,我们的整个流程就算是走通了
组件划分
最后我们再来回过头看一下我们设计的组件,我们在之前的 Redux、Flux 和 React-Redux 三者之间的区别 章节当中曾经介绍过,React-Redux
将所有组件分成了两大类,UI
组件和容器组件,UI
组件基本只做一件事情,那就是根据 props
来进行渲染,而容器组件则是负责应用的逻辑、数据,把所有相关的 UI
组件组合起来,通过 props
控制它们
但是我们观察我们的 Header
组件,这个组件其实在执行 connect
之前它一直都是 UI
组件,就是因为 connect
了导致它和 Context
扯上了关系,导致它变成容器组件了,也使得这个组件没有了很好的复用性,所以我们需要来重构一下,我们在 src
目录下新建两个文件夹 components
和 containers
1 | ├─ App |
我们规定所有的 UI
组件都放在 components/
目录下,所有的容器组件都放在 containers/
目录下,所以我们根据这个规则来重构我们的目录,这里以 Header
组件为例,我们将 components/
文件夹下的 Header
组件调整为
1 | import React, { Component } from 'react' |
这样一来,重构后的 Header
是一个纯展示的 UI
组件,下面我们在对应的 container/
文件夹下新建一个与其对应的容器组件,名字也为 Header
1 | import { connect } from 'react-redux' |
它引入 components/
下的 Header
组件,经过 connect
包裹后返回一个新的 Header
,就相当于我们把 Header
组件划分为了两部分,src/components/Header.js
下的负责渲染,而 src/containers/Header.js
则是跟业务相关的,同理,我们在分别重构 ThemeSwitch
和 Content
组件,但是这里有一点需要注意的是,针对 Content
组件可以分为两种情况,即不复用和可复用,这里我们分情况来进行讨论
如果是不复用的情况下,将 Content
移至业务文件夹 container/
下即可,最终的目录结构为
1 | src |
如果可复用,那么 Content
的子组件 ThemeSwitch
就一定要是 UI
组件,所以在这种情况下就不能直接使用 connect
了,所以涉及到的 ThemeSwitch
的数据、onSwitchColor
函数等就要通过它的父组件传递进来,而不是通过 connect
获得,这样一来组件的划分就变为了
1 | src |
这里我们为了简便,就直接采用了不复用的形式,但是我们可以发现,针对复用性的需求不同,我们划分组件的方式也有所不同,当然还有一点要注意,容器组件并不意味着完全不能复用,容器组件的复用性也是依赖场景的,在某些特定的应用场景下还是可以复用容器组件的,最终整合后的完整代码可见 组件划分
总结
我们来简单的总结一下我们在上面做了哪些事情,我们知道 store
里面的内容是不可以随意修改的,而是通过 dispatch
才能变更里面的 state
,所以我们尝试把 store
和 Context
结合起来使用,可以兼顾组件之间共享状态问题和共享状态可能被任意修改的问题
在我们设计的第一个版本当中,因为 store
和 Context
结合有诸多缺陷,有大量的重复逻辑和对 Context
的依赖性过强,所以我们尝试通过构建一个高阶组件 connect
函数的方式,把所有的重复逻辑和对 Context
的依赖放在里面 connect
函数里面,而其他组件则仅仅只负责渲染(UI
组件),让 connect
跟 Context
打交道,然后通过 props
把参数传给普通的组件
而每个组件需要的数据和需要触发的 action
都不一样,所以我们调整了 connect
,让它可以接受两个参数 mapStateToProps
和 mapDispatchToProps
,分别用于告诉 connect
这个组件需要什么数据和需要触发什么 action
最后为了把所有关于 Context
的代码完全从我们业务逻辑里面清除掉,我们构建了一个 Provider
组件,Provider
作为所有组件树的根节点,外界可以通过 props
给它提供 store
,它会把 store
放到自己的 Context
里面,好让子组件 connect
的时候都能够获取到
最后的最后,我们将我们的组件重新的划分了一遍,分为了 UI
组件和容器组件,UI
组件基本只做一件事情,那就是根据 props
来进行渲染,而容器组件则是负责应用的逻辑、数据,把所有相关的 UI
组件组合起来,通过 props
控制它们
当然,我们实现的这版 React-Redux
也是存在着一定问题的,比如不相关的数据变化的时候其实所有组件都会重新渲染的,不过在这里我们就不详细展开了,想了解更多的话可以参考之前整理过的一篇系列文章 Virtual DOM 与 Diff 算法内容总结 来了解更多,但是通过上面的示例,我们知道了为什么要 connect
,为什么要 mapStateToProps
和 mapDispatchToProps
,以及什么是 Provider
,这样在接触官方的 React-Redux
的时候就会变得上手十分简单