先弄个什么例子呢?如果是现代的MVVM框架,可能会用双向绑定来吸引你。那react有双向绑定吗?
没有。
也算是有吧,有插件。不过双向绑定跟react不是一个路子的。react强调的是单向数据流。 当然,即便是单向数据流也总要有个数据的来源,如果数据来源于页面自身上的用户输入,那效果也就等同于双向绑定了。
下面就展示一下如何达到这个效果。我们来设计一个登录的场景,用户输入用户名后,会在问候语的位置展示用户名,像下图这样:
预警一下先,我要用这个小东西展示react+redux的数据流工作方式,所以代码看起来比较多, 肯定比一些MVVM框架双向绑定一对双大括号代码要多得多。但正如我前面说的,它俩不是一个路子, react这种模式的好处后面你一定会看出来,这里先耐着性子把这几段貌似很罗嗦的代码看完。 react和redux很多重要的思想在这就开始提现出来了。
先把组件写出来。为了简便,我们把整个登录页面作为一个组件,放在containers目录下。 还记得前面说过containers和components目录吗?把组件放在containers目录下,意味着这个组件要跟外界打交道。 不过一开始,我们先别管打交道的事儿,就写一个简单的,普通的组件:
- import React from 'react'
- class Login extends React.Component{
- render(){
- return (
- <div>
- <div>早上好,{this.props.username}</div>
- <div>用户名:<input/></div>
- <div>密 码:<input type="papssword"/></div>
- <button>登录</button>
- </div>
- )
- }
- }
- export default Login
为了能让我们写的东西显示出来,得改点模板代码,现在来修改一下src/index.js,里面原来的代码都不需要了,改成:
- import React from 'react';
- import { render } from 'react-dom';
- import { Provider } from 'react-redux';
- import configureStore from '../stores';
- import Login from '../containers/Login';
- const store = configureStore();
- render(
- <Provider store={store}>
- <Login />
- </Provider>,
- document.getElementById('app')
- );
搭建环境时自动打开的浏览器页面还没关吧?保存代码后少等片刻就可以看到我们做的登陆页面了。
目前这个登录组件里问候语里显示的用户名和用户输入的用户名毫无关系,如何将它们联系起来呢? 既然看到了{this.props.username}你肯定会想到有一个数据模型。的确是有这么个东西,不过在redux里, 这个数据模型很壮观,整个应用只有一个数据模型,所以更应该管它叫数据仓库。这个仓库的代码在stores/index.js里面。 代码很简单,就是用reducers和initialState两个参数来创建一个仓库。看刚才run.js里面的代码, 有个叫Provider的组件使用了仓库,意思很明显:在provider这个组件内部,已经给我们提供好了仓库的访问条件, 也就是说我们的Login组件已经可以访问仓库了。怎么访问呢?需要把我们的组件跟仓库连接起来。 登录组件代码***一行“export default Login”要改成这样:
- function mapStateToProps(state) {
- return {}
- }
- export default connect(mapStateToProps)(Login);
connect是react-redux这个库提供的函数,功能就是把组件连接到rudux的仓库。注意在文件顶部加上一句“import { connect } from 'react-redux'”。 这里有个函数mapStateToProps,它返回的对象就是从仓库取出的数据,具体的数据等我们写完reducer再补充。
那么reducer是什么呢?
我们考虑一下仓库的数据是要变化的,怎么让它变化呢?我们得给个规则,这个规则描述起来就是: “在发生某一动作(action)时,仓库中的一部分数据要进行相应的变化”。我们管会因动作而变化的这一部分数据叫做状态, 许许多多琐碎的状态组成了仓库数据,所以整个仓库其实就是一个大的状态。在程序运行过程中,我们主要关心的就是这个仓库的状态如何变化。 如何变化?那就要靠reducer。针对一个动作,仓库里会有一个或多个状态发生变化,reducer就是要指导状态如何变化。
等等,那动作是哪来的?从具体上说,动作一般是来源于用户的操作或者网络请求的回应。在代码里需要对动作规范一下, 其实也就是跟reducer进行一个约定,让它知道有动作来了。其实怎样表示动作都可以,只要具有唯一性就行。 一般我们就用字符串就行了,即容易制造唯一,又能够表义,在使用中小心点别重了就行。下面就来定义一个用户输入用户名的动作:
- const INPUT_USERNAME = 'INPUT_USERNAME'
咋不直接用字符串呢?为了避免低级错误,定义了这个常量以后,发起动作时用这个常量,reducer也根据这个常量辨别动作类型。
我们光告诉reducer发生了“用户输入”这个动作还不够,还要告诉reducer用户输入了什么内容。所以完整的动作得是一个具有丰富信息的对象。 为了方便,我们写一个动作生成器,也就是个函数:
- function inputUsername (value) {
- return {
- type: INPUT_USERNAME,
- value: value
- }
- }
现在reducer就能得到足够的信息来指导状态的变化了。reducer要做的就是把仓库里一个叫做“username”的状态的值修改一下。 由于状态可以是一层套一层的,所以reducer也被设计成可以一层套一层。单个reducer就是它上级reducer的一分子。 其实reducer本身也就是个函数:
- function username (state='', action) {
- switch(action.type){
- case INPUT_USERNAME:
- return action.value
- defalut:
- return state
- }
- }
reducer的函数名对应着状态名称,函数接受两个参数:***个是当前状态,如果是程序开始运行的时候, 很可能没有当前状态,就给个默认值,这里是空字符串;第二个是前面动作生成器生成的action对象。 一个reducer可以处理多种动作,目前我们只有一个,以后有别的就直接加case分支。对于每种动作, reducer都要返回一个新的状态值,这个值就可以根据action传来的信息按照业务要求生成了。 ***一定要加一个默认情况返回当前状态。在redux里,任何一个action都会在所有的reducer里过一遍, 所以对于一个reducer来说实际上绝大多数情况action都不是它能处理的,***还是返回当前状态值。 觉得很低效吗?😉别怕,只是空走了一遍分支,这对诸如修改DOM这样的重头戏来说根本不算什么。
reducer是一层又一层的树状结构,怎么把它们组合到一起呢?rudex提供了一个组合工具combineReducers。 加入我们已经写好了另一个名为password的reducer,组合它们就是这个样子:
- combineReducers({username, password})
注意,combineReducers接收的参数是一个对象,而不是多个函数,上面的代码用的是es6的简写方式。
很容易发现,上面的reducer和action生成器都是非常死板的代码,今后我们会写大量的这样的代码, 那会出现满篇样板代码的情形,那可有点蠢笨了。所以我们把重复的东西尽可能的抽取出来,写个reucer生成器以及action生成器的生成器:
- // reducer生成器,为了以后使用方便,起名为create reducer的简写
- function cr (initialState, handlers) {
- return function reducer(state = initialState, action) {
- if (handlers.hasOwnProperty(action.type)) {
- return handlers[action.type](state, action);
- } else {
- return state;
- }
- }
- }
- // actiong生成器的生成器,同样原因,起名为create action creator的简写
- return function(...args) {
- let action = { type }
- argNames.forEach((arg, index) => {
- action[argNames[index]] = args[index]
- })
- return action
- }
这俩函数完成的事情跟我们写样板代码做的事情完全相同。具体说明一下:
cr的两个参数:initialState是初始状态;handlers是由一堆函数组成的对象,每个函数的名称对应着一个action的类型, 每个函数接受的参数与reducer一样,是action和当前状态,返回值会被当做新状态。默认情况就不用我们处理了。
cac接受的***个参数是action的类型名称,后面参数是所有附带数据的属性名称。
好了,把代码规整一下。对现在小小的模拟双向绑定的功能来说,我们还不需要记录密码的状态,不过我们也先写上,后面会用到。
***先写action。因为一般来说,只要你想好了你得应用有什么功能,action就可以写了,而且action不依赖其它东西。
src/actions/login.js:
- import {cac} from '../utils'
- export const INPUT_USERNAME = 'INPUT_USERNAME'
- export const INPUT_PASSWORD = 'INPUT_PASSWORD'
- export const inputUsername = cac(INPUT_USERNAME, 'value')
- export const inputPassword = cac(INPUT_PASSWORD, 'value')
action类型名称的常量现在都写到了action文件里,不过也许把所有这些常量放到一个单独的文件里比较好, 这样在es6语法的帮助下就可以避免重复了。
这里我们把所有的东西都导出了,action类型名称reducer会用到,action生成器组件会用到。
然后写reducer。当你想好应用的功能后,接下来就是要考虑背后的数据结构了。而reducer一写出来,数据结构就确定了。
src/reucers/login.js:
- import {combineReducers} from 'redux';
- import {cr} from '../utils'
- import {INPUT_USERNAME, INPUT_PASSWORD} from 'actions/login'
- export default combineReducers({
- username: cr('', {
- [INPUT_USERNAME](state, {value}){return value}
- }),
- password: cr('', {
- [INPUT_PASSWORD](state, {value}){return value}
- })
- })
对action文件的引用,路径里没有用../,这样写是因为actions是一个别名,它代表actions目录的绝对路径,这是webpack帮我们做的。 当然你也可以定义自己的别名,修改cfg/base.js就行,比如在resolve.alias对象里加一个自己的工具集:“utils:srcPath + '/utils.js'”。
rducer最终是要注册到store那里的,这个过程在src/storces/index.js里面已经写了, 可以看到里面的代码用的是../reducers这个文件(这是个目录,实际的文件是里面index.js), 所以我们也需要把新写的reducer注册到这里面去。修改src/reducers/index.js:
- import { combineReducers } from 'redux';
- import login from './login'
- const reducers = {
- login
- };
- module.exports = combineReducers(reducers);
在reducers/index里,所有的reducer也是通过combineReducers组合到一起的,只不过现在我们只有一个孤零零的子reducer:login。
终于,是时候回到组件上来了。src/containers/Login.js现在要修改成这样:
- import React from 'react'
- import { connect } from 'react-redux'
- import {inputUsername, inputPassword} from 'actions/login'
- class Login extends React.Component{
- inputUsernameHandler(evt){
- this.props.dispatch(inputUsername(evt.target.value))
- }
- inputPasswordHandler(evt){
- this.props.dispatch(inputPassword(evt.target.value))
- }
- render(){
- return (
- <div>
- <div>早上好,{this.props.username}</div>
- <div>用户名:<input onChange={this.inputUsernameHandler.bind(this)}/></div>
- <div>密 码:<input type="papssword" onChange={this.inputPasswordHandler.bind(this)}/></div>
- <button>登录</button>
- </div>
- )
- }
- }
- function mapStateToProps(state) {
- return {
- username: state.login.username,
- password: state.login.password
- }
- }
- export default connect(mapStateToProps)(Login);
有几处变化:
首先,前面已经说过,要把组件连接到仓库,就要用connect。并且现在我们已经确定了仓库里login对应状态的数据接口, 那么mapStateToProps返回的内容也就确定了。login状态里的两个属性映射成了组件的属性, 所以用this.props.username就可以访问到仓库里的login.username。
然后两个input上都加上了change事件处理。当change事件被触发时,通过this.props.dispatch函数就可以通知仓库有动作发生了, 仓库此时就会调用所有的reducer来应对这个事件。
好了,到这里小小的双向绑定功能实现了😓试试吧。
在MVVM框架里只需要建立一个视图模型,用一对双大括号就能完成的事情,到react加redux里面为何如此大费周折?
其实我是专门在展示完整的redux+react开发流程。如果只是要单个页面上的这点功能,用事件处理来改变组件的state就行了。 那么redux为什么要引入这么个流程?我在开发中觉得有这么几个特点:从直观上看在视野不一样。还是跟MVVM比吧, MVVM框架的视野在于局部,而redux的视野在于全局。MVVM对一个controller对应一个模型,模型里的数据只能自己用, 模型之间通信需要其它的数据传递方式。redux(或者说是flux的模式)管理着一个大数据仓库, 任何时候都可以从这个仓库中取到一切细节的状态(有没有云的感觉?),当开发单页应用的时候,这一优势会特别明显。 从编程语言角度上看,redux+react方式充分利用了函数式编程的优势。redux(flux)强调单向数据流, 单向数据流就像生产流水线,原料被各个工序依次加工,最终成为产品,而在这个过程中要避免外界因素对各个阶段的原料产生影响, 否则就会出现非预期的产品(次品)。纯函数就像这个流水线中的工序,让数据处理的过程简单明了。 发现了吗?前面的代码中纯函数是主力。reducer很明显是纯函数。组件也是纯函数,注意,我们的组件并没有直接被状态控制, 而是有个connect的过程,状态是被映射成组件的属性的,对于组件来说,根本不知道状态为何物。 这样我们的组件、reducer都非常独立,非常容易测试,意义也非常直白。
吹嘘了这么多,靠目前这点简单的代码也不容易看出来。毕竟这些代码还没啥实际意义,作为一个现代的前端应用,连异步都没有。。。
那么下一节,我们就加点异步进来。