作者的话

本篇教程是Brad Westfall写的三篇文章中的最后一篇。 我们将从中学到如何在我们的应用中高效的管理state状态,并且保证程序的伸缩性和简单性。 站上顶峰往往只差最后一个山头,当你完成这一整个react之旅你会发现,一切都值得。

在javascript应用中,Redux是一种既可以管理数据状态又可以管理UI状态的工具。尤其对于单页面应用, 随着使用时间的增长应用状态的管理将变得愈加困难。虽然我们这里是以react为背景介绍redux, 但是redux其实是可以与其他库一起使用,如Angular或者jquery.

值得一提的是,“时间旅行”将会是一个非常神奇的体验,也是我们最终将会实现的一个构想

在前面也已经提到过,React的数据在组件间流动,更加准确的说,是“单向流动”,数据只从 父组件传递到子组件。由于这个性质,一开始不容易想象两个不是“父子”关系的组件是怎样去通信的。

React本身就不建议直接在这种非父子组件之间建立数据流动。即使它确实可以支持这种做法, 但是这种直接在组件件随意传递数据的做法就不是一个好的实践,它会导致像面条一样纠缠不清的代码逻辑和更容易出错,这是旧的模式中应该摈弃的地方。

React也留了一条后路给你参考,但是他们更加希望你可以有自己的实现方式。引用React文档的说法:

对于非父子关系的组件间的通信,你可以建立自己的全局事件系统,…Flux模式是一种可行的实现

Redux也是由此而来。Redux提供了一个托管应用中所有组件的"state"的存储地方,称为"store". 组件通过派生状态的改变到 store中,而不是直接将数据传递到其他组件。组件可以"订阅"store中的变化来觉察状态的改变。

store可以当做是应用中所有组件state变化的一个"中间人"。应用中加入redux之后,组件不再直接相互传递数据, 而是将所有的state的变化告知唯一的数据源,即store

这种做法区别于那些一个应用中各个部分直接通信的做法. 很多时候那些直接通信的做法很容易出现bug和下图中的混淆:

通过redux,很容易明确所有组件从store中获取他们各自的state.同样明确的是他们state的改变也会统一交给store. 发生改变的组件只需要知道要将这个变化派生到store中,而不需要去过多考虑这个变化会怎样影响其他组件。 这也是redux使数据流变得容易理解的原因。

使用store(s)去协调应用的state是一种称为Flux模式的设计模式。 它崇尚单向数据流,redux与Flux很类似,它们之间有何关系?

Redux is “Flux-like”

Flux是一种模式,而redux是一个你可以下载的工具。Redux是受Flux启发的一个工具,类似的包括Elm. 有多如牛毛的文章在对Redux和Flux进行比较,他们大多数得出的结论是"Redux是Flux的一种实现"或者"Redux类似Flux". 介于此,这也许不是我们真正需要关注的地方。Facebook就对Redux抱有很大的好感,甚至把Redux主要开发者Dan Abramov招入麾下

本文假设你完全不知道什么事Flux模式。如果你有了解Flux,你也许会发现Redux和它之间的一些微小差异,尤其是Redux的 三大原则:

单一数据源

Redux只使用一个store去存储应用中的所有state,因此称之为"单一数据源"。

store的数据结构最终取决于你,但在一个实际应用中一般都是一个深度嵌套的一个对象。

Redux中这种单个store的做法是它与Flux多个store做法的主要不同之处。

state是只读的

根据Redux文档,“修改state的唯一方式是派生一个action,一个描述所发生的事情的对象”。

因此应用不能直接修改state,而是派生"actions"到store去告诉它要修改state.

store对象只提供下面四个api:

  • store.dispatch(action)
  • store.subscribe(listener)
  • store.getState()
  • replaceReducer(nextReducer)

你会发现没有设置state的方法。因为派生一个action是应用表达要修改state的唯一方式:

var action = {
  type: 'ADD_USER',
  user: {name: 'Dan'}
};

// Assuming a store object has been created already
store.dispatch(action);

例子中的dispatch()方法正是将action对象派生到Redux的方法。每个action包含一个type和一些必要的用来 更新state的数据(如例子中的user).要记住,除了type之外,你可以自己定义action对象。

使用纯函数来执行修改

前面说过Redux不允许应用直接修改state,而是通过派生action来"描述"state的变化和要修改state的意图。 你通过写Reducers函数,来处理这些派生出来的actions,然后真正去修改state.

一个reducer接收一个当前的state作为参数,返回一个新的state来表示state已经被更新:

// Reducer Function
var someReducer = function(state, action) {
  ...
  return state;
}

reducer应该写成纯函数,表示一个函数有以下特性:

  • 它不会调用外部的网络或者数据库请求
  • 它的返回值只会由它的参数的值影响
  • 它的参数在函数中不应该被修改
  • 多次以相同的参数调用同一个纯函数得到的返回值应该都是相同的

纯函数做的仅仅是根据参数的值来提供返回值,而不是对函数意外的任何东西产生"副作用"

初试redux store

首先通过Redux.createStore()来创建一个store,参数是所有的reducers。看一个小例子:

// Note that using .push() in this way isn't the
// best approach. It's just the easiest to show
// for this example. We'll explain why in the next section.

// The Reducer Function
var userReducer = function(state, action) {
  if (state === undefined) {
    state = [];
  }
  if (action.type === 'ADD_USER') {
    state.push(action.user);
  }
  return state;
}

// Create a store by passing in the reducer
var store = Redux.createStore(userReducer);

// Dispatch our first action to express an intent to change the state
store.dispatch({
  type: 'ADD_USER',
  user: {name: 'Dan'}
});

来看看发生了什么事情:

  1. 以一个reducer创建了一个store
  2. reducer将应用的初始化state设置为空的list
  3. 派生了一个action,里面有一个新用户的数据
  4. reducer将新用户加到state后将新的state返回,表示state已经被更新

reducer在例子中被调用了两次。第一次是在创建store的时候用来初始化state,第二次是派生action之后更新state.

当store刚被创建的时候,Redux马上调用reducers,使用它们的返回值作为初始化的state. 第一次调用reducer的state 参数值是undefined. reducer中已经对这种情况作了处理,返回了一个空的list作为初始化state.

reducers还会在每次派生action的时候调用。因为reducer返回的state会作为store中新的state,请确保reducer永远返回一个state.

例子中,派生action之后第二次调用了reducer函数。记住,一次派生出action表达一个修改state的意图, 而且经常会带上新state需要的一些数据。这次调用reducer的state参数值是空的list和一个action对象。 action对象中的type属性Add_USER信息让reducer知道该如何操作state.

可以将reducers简单理解为隧道,永远接收一个state并返回一个新的state来更新store:

例子中,store会是一个数组,里面有一个user对象

store.getState();   // => [{name: 'Dan'}]

复制state而不要修改它

在刚刚例子中的reducer虽然可以完成功能,但直接修改state的数据是不好的做法。 虽然reducer负责的事情就是修改更新state,但是它们不应该直接将参数中的"当前state"直接修改。 我们不应该使用例子中的.push()函数直接修改对象

传递到reducer函数中的参数不应该被改变(纯函数中的参数都不应该被改变).我们使用非直接修改的方式, 如.cancat()来将原来的数组拷贝,然后只是修改那个备份并将其返回。

var userReducer = function(state = [], action) {
  if (action.type === 'ADD_USER') {
    var newState = state.concat([action.user]);
    return newState;
  }
  return state;
}

新的reducer在添加一个新的user到state的时候,会将原state拷贝然后修改备份后返回。 如果不是添加user的action,会直接原封不动的返回原state.

关于如何使用Immutable Data Structures有关于最佳实践的一系列的话题.

你可能已经注意到新的例子使用ES2015缺省参数来初始化state. 之前我们系列中为了让读者专注于所介绍的内容,避免了引入ES2015。 但是redux与ES2015搭配起来会更如丝般顺滑,因此现在不妨开始使用ES2015, 其实没什么好担心的,每个ES2015新特性的使用都会有专门的说明并附有解释.

多个Reducers

上一个例子是一个很好的引子,但实际中多数的应用需要更加复杂的state.由于Redux只有 一个store,我们需要将应用不同的部分组织成嵌套的对象。现在假设将store组织成下面这样的结构:

{
  userState: { ... },
  widgetState: { ... }
}

整个应用到现在还是"一个store就是一个对象",但是它使用userStatewidgetState两个部分来存储所有的数据。也许看起来有点简单,但其实也没到需要一个 真正的Redux store的时候。

要创建这样一个store,我们需要将每个部分定义一个reducer:

import { createStore, combineReducers } from 'redux';

// The User Reducer
const userReducer = function(state = {}, action) {
  return state;
}

// The Widget Reducer
const widgetReducer = function(state = {}, action) {
  return state;
}

// Combine Reducers
const reducers = combineReducers({
  userState: userReducer,
  widgetState: widgetReducer
});

const store = createStore(reducers);

注意一下这里使用了ES2015. 例子中的四个主要"变量"因为都不会被修改,因此将它们定义为常量。 这里还使用了ES2015模块导入和解构写法

通过combineReducers()的使用,我们可以用不同的reducers处理不同的逻辑部分,然后最后汇总到同一个store中。 现在,当每个reducer返回初始化的state时,会分别对应store中的userStatewidgetState部分

很重要的一点,现在每个reducer从总的state中获取它对应的那一部分,而不是像前一个例子中的整个store拿过来。 然后每个reducer返回的新的state会应用到整个state中对应那部分。

如何区分一个Dispatch之后会调用哪个Reducer?

其实所有Reducers都会被调用。更形象一点将reducers比喻成很多个串联的通道,每次当一个action 被派生,每个reducer都会被调用并且都有机会更新它们自己对应的那部分state:

这里谨慎的使用"它们的"state这个说法(之前强调只有一个store),因为reducers"当前" 那个传入的state参数和返回的state都仅仅会影响它们store中自己那一部分。记住一点, 每个reducer只会传入自己对应的部分state作为参数,而不是整个state.

Action策略

对于管理actions和actions的类型,实际上有很多的方式。确实有去了解一下它们的必要, 但这还不是本文介绍的重点。简单起见,你一定要知道的在本系列文章github 中介绍的那些。

不可变的数据结构(Immutable Data Stuctures)

你的state长什么样取决于你自己: 可以是原生数据类型,一个数组,一个对象,甚至是一个 Immutable.js数据结构。你只要知道的是不应该直接去修改state对象,而是返回一个新的 state来表示state的更新。 –Redux文档

陈述已经说得足够明白,我们之前其实也一直在暗示这一点。如果我们要讨论 immutable还是mutable, 又可以说整个系列本章那么长,链接. 我就只强调重点。

首先,

  • javascript中的原生数据类型就已经是immutable的(Number, String, Boolean, Undefined, Null)
  • Objects, arrays, functions这些是mutable的类型

数据的修改是bugs的根源。因为我们的store将由objects和arrays的state来组成, 我们需要一个策略来保证state不被修改。

假设要在一个state对象中修改一个属性,有下面三种方法:

// Example One
state.foo = '123';

// Example Two
Object.assign(state, { foo: 123 });

// Example Three
var newState = Object.assign({}, state, { foo: 123 });

例1和2中都会修改到state对象。例2使用Object.assign()方法会将第一个参数 后面的所有参数合并到第一个参数中,这也是例2修改了原state而例3没有修改原state原因。

例3中将当前state的内容和{foo: 123}一起合并到一个新的空对象中,这个小技巧 等于创建了原state的备份然后对备份进行修改。

使用"展开运算符"是另一种保持原state数据不被修改的方式:

// ES2015
const newState = { ...state, foo: 123 };

了解这种方式的详情和使用好处可以参考文档

注意Object.assign()方法和扩展运算符都是ES2015特性。

总之,保持对象和数组不被修改有很多方法。还有一些开发者使用seamless-immutable, Mori, 抑或是Facebook出品的Immutable.js

本文的所有链接都通过精心挑选,如果你不了解不可变性(immutability),可以参考上面链接。数据不可变性是Redux成功之路的一个关键点。

初始state, 时间旅行

如果你阅读文档,可能会注意到createStore()函数可以有 第二个参数"initial state"。这似乎是代替reducers创建初始化state的一个方法。 然而,这个参数只是用来做"state融合"。

想象这样的情景,一个用户在你的SPA上点击刷新键,store的state复原到reducer的初始化state. 这应该不是我们想要的效果。

如果有一种方法能对store持久化,然后你可以在刷新的时候重现它。这就是将"初始state"作为createStore()参数的原因。

一个有意思的概念由此而生。这么简单就可以将一个历史的state进行重现,我们可以大胆想象state的"时间旅行", 任意重现历史的某一个state. 这对于撤销/重做操作尤其有用。 这只是将所有state放到同一个store中和使用不可修改的state的其中一个好处。

一次采访中,Dan Abramov被问到"你开发Redux的动机是什么?"

我没想过要开发一个Flux框架。当React Europe组织成立时,我做了一个演讲"热替换和时间旅行",但老实说,我也不知道怎样去实现时间旅行

Redux搭配React

我们已经知道Redux是不管你使用的是React或者是别的什么框架的。在搭配React使用前你最好弄明白Redux的核心概念。 接下来我们将会用上一篇文章的Container Component并用redux改造它。

首先,下面是没使用redux前的组件:

import React from 'react';
import axios from 'axios';
import UserList from '../views/list-user';

const UserListContainer = React.createClass({
  getInitialState: function() {
    return {
      users: []
    };
  },

  componentDidMount: function() {
    axios.get('/path/to/user-api').then(response => {
      this.setState({users: response.data});
    });
  },

  render: function() {
    return <UserList users={this.state.users} />;
  }
});

export default UserListContainer;

ES2015! 这个组件与之前的稍有不同,使用了ES2015模块化和箭头函数

组件使用Ajax请求然后更新自己的local state已经是显而易见的。但是如果得到这个用户列表之后应用的其他地方(组件) 也要进行更新,这似乎做不到。

使用Redux的思想,当Ajax请求返回的时候,我们不是调用this.setState()(因为这样只能更新这个组件自己的内容), 而是派生出一个action到store中。然后这个组件还有其他组件可以根据需要来订阅state的变化来进行更新。 这里需要处理的问题是: 如何建立store.subscribe()(订阅)来更新组件的state?

我可以提供树种方法将组件关联到Redux store中,你也可以思考自己的实现方式。最后我将介绍一个更好的方法来避免手动做这个事情。 这里讲介绍官方的React/Redux绑定方法叫做react-redux. 来吧!

使用react-redux连接

先声明一下react, reduxreact-redux是npm中三个独立的模块。redux-react给了我们一个方便的 连接React组件和Redux的解决方案,直接看代码:

import React from 'react';
import { connect } from 'react-redux';
import store from '../path/to/store';
import axios from 'axios';
import UserList from '../views/list-user';

const UserListContainer = React.createClass({
  componentDidMount: function() {
    axios.get('/path/to/user-api').then(response => {
      store.dispatch({
        type: 'USER_LIST_SUCCESS',
        users: response.data
      });
    });
  },
  
  render: function() {
      return <UserList users={this.props.users} />;
    }
  });
  
  const mapStateToProps = function(store) {
    return {
      users: store.userState.users
    };
  }
  
  export default connect(mapStateToProps)(UserListContainer);

一些要注意的新内容:

  1. react-redux中导入connect函数
  2. 从下往上看可能更容易看懂代码逻辑。connect()函数接收两个参数,我们这里实际只用了一个。

connect()()这种多出一个括号的写法也许看起来有点古怪,实际上这是两次函数的调用.第一次connect()返回的还是一个函数. 我们当然可以把第一次返回的函数赋值到一个变量名中,然后第二次通过这个名字来调用这个函数. 但第二个函数其实只是马上跟着调用一次, 这里使用连续两个括号的连续写法显然更方便。第二个函数的参数是一个React组件,这里是我们的Container Component. 我理解"你认为这很复杂"。这实际上是"函数式编程"中很常用的思想,不妨去学习一下。

  1. connect()函数的第一个参数是一个"返回一个对象"的函数。返回对象的properties将会成为组件的"props". 函数的名称"mapStateToProps"已经说明了它的功能。mapStateToProps()函数接收整个store作为参数, 返回组件需要的"props"
  2. 有了#3的说明就不难理解我们现在已经不需要getInitialState()函数。现在要使用this.props.users代替this.state.users, 因为现在users数组是组件的props而不是组件自己的state.
  3. Ajax请求现在返回之后不再直接更新组件本地state,而是派生一个action. 简单起见,我们没有使用action creators或者action type constants

代码示例假设user reducer可以正常工作,留意store中有userState属性,这是哪里来的?

const mapStateToProps = function(store) {
  return {
    users: store.userState.users
  };
}

其实userState名字是在reducers组合的时候就定义了:

const reducers = combineReducers({
  userState: userReducer,
  widgetState: widgetReducer
});

那么userState中的.users属性又是从哪里来?

我们还没给出示例中的reducer怎么写(因为在另一个文件中),其实store中每个state的子属性都是在reducer中定义的。 要保证userState.users这个属性,reducer可能是下面这样:

const initialUserState = {
  users: []
}

const userReducer = function(state = initialUserState, action) {
  switch(action.type) {
  case 'USER_LIST_SUCCESS':
    return Object.assign({}, state, { users: action.users });
  }
  return state;
}

Ajax请求的Dispatches

在我们Ajax例子中,只派生了一个action即'USER_LIST_SUCCESS'. 我们还要在请求开始之前派生'USER_LIST_REQUEST', 在请求失败的时候派生'USER_LIST_FAILED'。请阅读异步actions

事件触发Dispatch

上篇文章已经说明事件应该从Container传递到Presentation Components. react-redux帮助我们完成这个事情:

...

const mapDispatchToProps = function(dispatch, ownProps) {
  return {
    toggleActive: function() {
      dispatch({ ... });
    }
  }
}

export default connect(
  mapStateToProps,
  mapDispatchToProps
)(UserListContainer);

在展示组件中,可以像之前那样使用onClick={this.props.toggleActive},但现在不需要另外写这个事件需要做的事情, 而只是在事件中派生action.

Container Component简写

有时一个容器组件只需要订阅store中的数据传递给展示组件,而不需要像componentDidMount()这种方法来触发Ajax请求。 它只需要render()方法来将state传递到展示组件中。这种情况容器组件写成这样更简单:

import React from 'react';
import { connect } from 'react-redux';
import UserList from '../views/list-user';

const mapStateToProps = function(store) {
  return {
    users: store.userState.users
  };
}

export default connect(mapStateToProps)(UserList);

我们竟然连React.createClass()这种声明组件的方式都不用了?

实际上是connect()方法创建了容器组件。这次直接是将展示组件传递到connect函数中而不是容器组件。 回想容器组件的功能,是让展示组件可以专注于视图而不是state,并且将state作为展示组件的props传递过去。 这其实就是connect()做的–将state传递到展示组件并返回一个包装好的组件,即我们的容器组件。

这样的话前面的例子用connect包装容器组件岂不是对展示组件包了两层?你可以这样认为,实际上只有在容器组件需要 render()以外的方法的才这样去做。这两层的容器组件可以想象为下图: 或许这就是为什么React的logo长得像一个原子的原因呢!

Provider

为了让所有加入react-redux的代码能正常工作,你需要使用<Provider />组件来告知应用。 Provider组件将整个React应用包装,配合React Router使用的实例如下:

import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import store from './store';
import router from './router';

ReactDOM.render(
  <Provider store={store}>{router}</Provider>,
  document.getElementById('root')
);

Provider中的store就是实际通过react-redux连接React和Redux的。这里有一个文件可以作为你的主入口的例子

Redux搭配React Router

你可以参考另一种方式react-router-redux(可选). 因为路由也可以算是UI-state中的一部分,而React Router其实对Redux无感知,这个库提供了连接React Router和Redux的帮助。

不知不觉中我们完成了整个系列文章!回到第一篇

最终项目

这是本系列最后一篇. 现在你已经可以构建一个"Users and Widgets"的小型单页面应用了:

和前两篇一样,每一篇都有一个更加详细的指引告诉你如何应用到github中。

总结

我是真心希望你能享受这一些列文章,正如我的写作过程。我知道有很多React的内容还没有覆盖到(eg, 表单), 但如我的初衷,我尽量让一个刚使用react的人能理解一些基础的内容还有怎样去构建一个单页面应用。

感谢众多人提供的帮助,尤其是Lynn Fisher提供的如此amazing的图片。


系列文章