React中的Redux

学习必备要点:

  1. 首先弄明白,Redux在使用React开发应用时,起到什么作用——状态集中管理
  2. 弄清楚Redux是如何实现状态管理的——store、action、reducer三个概念
  3. 在React中集成Redux:redux + react-redux(多了一个概念——selector)
  4. Redux调试工具:redux devtools
  5. redux相关很好用的插件:redux-saga的相关先容

redux结构图

react-redux.png

其中红色虚线部分为redux的内部集成,不能显示的看到。

  • action:是事件,它本质上是JavaScript的普通对象,它描述的是“发生了什么”。action由type:string和其他构成。
  • reducer是一个监听器,只有它可以改变状态。是一个纯函数,它不能修改state,所以必须是生成一个新的state。在default情况下,必须但会旧的state。
  • store是一个类似数据库的存储(或者可以叫做状态树),需要设计自己的数据结构来在状态树中存储自己的数据。

Redux入门

Redux概况

Redux是一个状态集中管理库。

安装

npm install --save redux

附加包

多数情况下大家需要使用 React 绑定库和开发者工具。

npm install --save react-redux
npm install --save-dev redux-devtools

三大原则

单一数据源

整个应用的state被存储在一棵object tree中,并且这个object tree只存在于唯一一个store中。

State是只读的

惟一改变 state 的方法就是触发 action,action 是一个用于描述已发生事件的普通对象。

使用纯函数来实行修改

为了描述action如何改变状态树,大家需要编写reducers。Reducer只是一些纯函数,他接受先前的state和action,并返回新的state对象。

react-redux.png

上图是Redux如何实现状态管理的框架,View(视图) 可以通过store.dispatch()方法传递action。 Action相当于事件模型中的事件,它描述发生了什么。Reducer相当于事件模型中的监听器,它接收一个旧的状态和一个action,从而处理state的更新逻辑,返回一个新的状态,存储到Store中。而从store-->view 的部分,则是通过mapStateToProps 这个函数来从Store中读取状态,然后通过props属性的方式注入到展示组件中。图中红色虚线部分是Redux内部处理,大家不必过多考虑这部分的实现。

Action

Action 是把数据从应用传到store的有效载荷,它是store数据的唯一来源,一般来说,大家通过store.dispatch()将action传到store。

Action创建函数

Action 创建函数 就是生成 action 的方法。“action” 和 “action 创建函数” 这两个概念很容易混在一起,使用时最好注意区分。

Redux中action创建函数只是简单返回一个action。

改变userName的示例:

export function changeUserName(userName) {  // action创建函数
    return {                             // 返回一个action
        type: 'CHANGE_USERNAME',
        payload: userName,
    };
}

Action 本质上是JavaScript 普通对象。大家规定,action 内必须使用一个字符串类型的 type 字段来表示将要实行的动作。多数情况下,type 会被定义成字符串常量。当应用规模越来越大时,建议使用单独的模块或文件来存放 action。

除了 type 字段外,action 对象的结构完全由你自己决定。参照 Flux 标准 Action 获取关于如何构造 action 的建议,另外还需要注意的是,大家应该尽量减少在action中传递数据

Reducer

Action只是描述有事情发生这一事实,而Reducer用来描述应用是如何更新state。

设计State结构

在 Redux 应用中,所有的 state 都被保存在一个单一对象中。在写代码之前大家首先要想清楚这个对象的结构,要用最简单的形式把应用中的state用对象描述出来。

HelloApp应用的state结构很简单,只需要保存userName即可:

{userName: 'World'}
处理 Reducer 关系时的注意事项

开发复杂的应用时,不可避免会有一些数据相互引用。建议你尽可能地把 state 范式化,不存在嵌套。把所有数据放到一个对象里,每个数据以 ID 为主键,不同实体或列表间通过 ID 相互引用数据。把应用的 state 想像成数据库。这种方法在 normalizr 文档里有详细阐述

Action处理

确定了 state 对象的结构,就可以开始开发 reducer。reducer 就是一个纯函数,接收旧的 state 和 action,返回新的 state。

(state, action) => newState

之所以称作 reducer 是因为它将被传递给 Array.prototype.reduce(reducer, ?initialValue) 方法。保持 reducer 纯净非常重要。永远不要在 reducer 里做以下操作:

  • 修改传入参数;
  • 实行有副作用的操作,如 API 请求和路由跳转;
  • 调用非纯函数,如 Date.now()Math.random()

在后续的学习终将会先容如何实行有副作用的操作,现在只需谨记reducer一定要保持纯净。只要传入参数相同,返回计算得到的下一个 state 就一定相同。没有特殊情况、没有副作用,没有 API 请求、没有变量修改,单纯实行计算。

大家将写一个reducer,让它来处理之前定义过的action。大家可以首先指定state的初始状态。

const initState = {       /** 指定初始状态 */
    userName: 'World!'
}

export default function helloAppReducer(state=initState, action) {
    switch(action.type) {
        case 'CHANGE_USERNAME':
            return {
                userName: action.payload,   // 改变状态
            };
        default:
            return state;    // 返回旧状态
    }
}

警告:

  1. 不要修改state。如果涉及多个状态时,可以采用对象展开运算符的支撑,来返回一个新的状态。 假设大家的实例中还存在其它状态,但是大家只需要改变userName的值,那么上述示例大家可以采用以下方式返回新的状态:

    return {
      ...state,
      userName: action.payload
    }
    
  2. 在default情况下返回旧的state 遇到未知的action时,一定要返回旧的state

Reducer拆分

这里大家以redux中文文档 中的todo应用为例来说明,在应用的需求中,有添加todo项,设置todo列表的过滤条件等多个action,同理大家就需要写多个reducer来描述状态是怎么改变的,建议把todo列表的更新和设置过滤条件放在两个reducer中去实现:

function todos(state = [], action) {
  switch (action.type) {
    case ADD_TODO:
      return [
        ...state,
        {
          text: action.text,
          completed: false
        }
      ]
    case TOGGLE_TODO:
      return state.map((todo, index) => {
        if (index === action.index) {
          return {
            ...todo,
            completed: !todo.completed
          }
        }
        return todo
      })
    default:
      return state
  }
}

function todoApp(state = initialState, action) {
  switch (action.type) {
    case SET_VISIBILITY_FILTER:
      return {
        ...state,
        visibilityFilter: action.filter
      }
    case ADD_TODO:
    case TOGGLE_TODO:
      return {
        ...state,
        todos: todos(state.todos, action)
      }
    default:
      return state
  }
}

todos 依旧接收 state,但它变成了一个数组!现在 todoApp 只把需要更新的一部分 state 传给 todos 函数,todos 函数自己确定如何更新这部分数据。这就是所谓的 reducer 合成,它是开发 Redux 应用最基础的模式。

现在大家可以开发一个函数来做为主 reducer,它调用多个子 reducer 分别处理 state 中的一部分数据,然后再把这些数据合成一个大的单一对象。主 reducer 并不需要设置初始化时完整的 state。初始时,如果传入 undefined, 子 reducer 将负责返回它们的默认值。这个过程就是reducer合并。

下面的这段代码是reducer合并的两种方式:

export default function todoApp(state = {}, action) {
  return {
    visibilityFilter: visibilityFilter(state.visibilityFilter, action),
    todos: todos(state.todos, action)
  }
}

每个 reducer 只负责管理全局 state 中它负责的一部分。每个 reducer 的 state 参数都不同,分别对应它管理的那部分 state 数据.

import { combineReducers } from 'redux';

const todoApp = combineReducers({
  visibilityFilter,
  todos
})

export default todoApp;

combineReducers() 所做的只是生成一个函数,这个函数来调用你的一系列 reducer,每个 reducer 筛选出 state 中的一部分数据并处理,然后这个生成的函数再将所有 reducer 的结果合并成一个大的对象。

Store

前面的部分,大家学会使用action来描述发生了什么,使用reducers来根据action更新state的用法。

Store则是把action和reducers联系到一起的对象,它有以下职责:

  • 维持应用的 state;
  • 提供 getState() 方法获取 state;
  • 提供 dispatch(action) 方法更新 state;
  • 通过 subscribe(listener) 注册监听器;
  • 通过 subscribe(listener) 返回的函数注销监听器。

再次说明Redux应用只有一个单一的store。 当需要拆分处理数据逻辑时,大家应该使用 reducer 组合 而不是创建多个 store。

根据已有的reducer来创建store是非常容易的。在大家的HelloApp应用中,大家将helloAppReducer 导入,并传递给createStore()

import { createStore } from 'redux'
import helloAppReducer from './reducers'

let store = createStore(helloAppReducer)   // 创建store

createStore() 的第二个参数是可选的, 用于设置 state 初始状态。

备注:
其实这种数据结构是有reducer确定的,就像helloAPP的例子中,

const reducer = combineReducers({
  hello: hello,
  city: cityReducer
})

而由redux-devtools工具查看到的是下图这样的:

store-tree.png

so,存储在store中的数据结构是由reducer确定的。

数据流

严格的单向数据流 是Redux架构的核心设计。这就意味着应用中所有的数据都遵循相同的生命周期,这样可以让应用变得更加可预测且容易理解。同时也鼓励做数据范式化,这样可以避免使用多个且独立的无法相互引用的重复数据。

Redux应用中数据的生命周期遵循以下4个步骤:

  1. 调用store.dispatch(action)

    Action 就是一个描述“发生了什么”的普通对象。比如:

    { type: 'CHANGE_USERNAME', payload: "Welcome to Redux" };
    

    大家可以在任何地方调用store.dispatch(action) 包括组件中、XHR回调中、甚至是定时器中。

  2. Redux store 调用传入的 reducer 函数。

    Store 会把两个参数传入 reducer: 当前的 state 树和 action。

    const initState = {       /** 指定初始状态 */
        userName: 'World!'
    }
    
    export default function helloAppReducer(state=initState, action) {  // 传入两个参数
        switch(action.type) {
            case 'CHANGE_USERNAME':
                return {
                    userName: action.payload,   // 改变状态
                };
            default:
                return state;    // 返回当前状态
        }
    }
    

    reducer 是纯函数。它仅仅用于计算下一个 state。它应该是完全可预测的:多次传入相同的输入必须产生相同的输出。它不应做有副作用的操作,如 API 调用或路由跳转。这些应该在 dispatch action 前发生。

  3. 根 reducer 应该把多个子 reducer 输出合并成一个单一的 state 树。

    根 reducer 的结构完全由大家自己决定。Redux 原生提供combineReducers()辅助函数,来把根 reducer 拆分成多个函数,用于分别处理 state 树的一个分支。

  4. Redux store 保存了根 reducer 返回的完整 state 树。

    这个新的树就是应用的下一个state。所有订阅store.subscribe(listener) 的监听器都将被调用;监听器里可以调用store.getState() 获取当前的state。

示例: Hello App

如果想查看示例的源码,请查看这里。Hello App源码
开始之前大家需要清楚实际上Redux和React之间并没有关系。Redux支撑React、Angular、Ember、jQuery甚至纯JavaScript。即便如此,Redux 还是和 React 和 Deku 这类框架搭配起来用最好,因为这类框架允许你以 state 函数的形式来描述界面,Redux 通过 action 的形式来发起 state 变化。

下面大家将用React来开发一个Hello World的简单应用。

安装React Redux

Redux默认并不包含 React 绑定库,需要单独安装。

npm install --save react-redux

容器组件和展示组件

Redux 的 React 绑定库是基于 容器组件和展示组件相分离 的开发思想。而容器组件和展示组件大致有以下不同:

展示组件 容器组件
作用 描述如何展现内容、样式 描述如何运行(数据获取、状态更新)
是否能直接使用Redux
数据来源 props(属性) 监听Redux state
数据修改 从props中调用回调函数 向Redux派发actions
调用方式 手动 通常由React Redux生成

大部分的组件都应该是展示型的,但一般需要少数的几个容器组件把它们和Redux store连接起来。

技术上来说大家可以直接使用 store.subscribe() 来编写容器组件。但不建议这么做,因为这样写就无法使用 React Redux 带来的性能优化。同样,不要手写容器组件,大家直接使用 React Redux 的 connect() 方法来生成,后面会详细先容。

需求分析

大家的需求很简单,大家只是想要展示hello + userName,默认为“Hello World!”,当大家在输入框中输入不同的值时,会显示不同的“hello,___”问候语,由此可以分析出该应用只有一个状态,那就是{ userName: '张三'}

展示组件

该应用只有一个展示组件HelloPanel:

  • HelloPanel 用于显示输入框及展示数据
    • userName: 要展示的数据
    • onChange(userName) : 当输入值发生变化时调用的回调函数

该组件之定义外观并不涉及数据从哪里来,如果改变它,传入什么就渲染什么,如果你把代码从Redux迁移到别的架构,该组件可以不做任何改动直接使用。

容器组件

还需要一个容器组件来把展示组件连接到Redux。例如HelloPanel 组件需要一个状态类似HelloApp的容器来监听Redux store变化并处理如何过滤出要展示的数据。

HelloApp 根据当前显示状态来对展示组件进行渲染。

组件编码

  • Action创建函数

    action.js

    export function changeUserName(userName) {
        return {
            type: 'CHANGE_USERNAME',
            payload: userName,
        };
    }
    
  • Reducer

    index.js

    const initState = {       /** 指定初始状态 */
        userName: 'World!'
    }
    
    export default function helloAppReducer(state=initState, action) {
        switch(action.type) {
            case 'CHANGE_USERNAME':
                return {
                    userName: action.payload,   // 改变状态
                };
            default:
                return state;    // 返回当前状态
        }
    }
    
  • 展示组件

    HelloPanel.js

    import React from 'react';
    
    export default function HelloPanel(props) {
      let input
      return (
        <div>
        <p>Hello, {props.userName}</p>
        <input ref={node => {
              input = node
            }}  onChange={()=>props.onChange(input.value)}/>
        </div>
      );
    }
    
  • 容器组件

    使用 connect() 创建容器组件前,需要先定义 mapStateToProps 这个函数来指定如何把当前 Redux store state 映射到展示组件的 props 中。例如:HelloApp 中需要计算

    const mapStateToProps = (state) => {
        return { userName: state.userName }  // 返回希望注入到展示组件的props中的参数
    };
    

    除了读取state,容器组件还能分发action。类似的方式,可以定义 mapDispatchToProps() 方法接收 dispatch() 方法并返回希望注入到展示组件的 props 中的回调方法。

    const mapDispatchToProps = (dispatch) => ({
        onChange: (userName) => {
            dispatch(changeUserName(userName))  // 返回希望注入到展示组件的 props 中的回调方法
        }
    })
    

    最后,使用 connect() 创建 HelloApp,并传入这两个函数。

    import { connect } from 'react-redux';
    import HelloPanel from './HelloPanel';
    
    const HelloApp = connect(  // 产生一个新的组件
        mapStateToProps,
        mapDispatchToProps,
    )(HelloPanel)
    
    

    这就是 React Redux API 的基础,但还漏了一些快捷技巧和强大的配置。建议仔细学习 React Redux文档。如果你担心 mapStateToProps 创建新对象太过频繁,可以学习如何使用 reselect 来 计算衍生数据。

传入Store

所有容器组件都可以访问 Redux store,所以可以手动监听它。一种方式是把它以 props 的形式传入到所有容器组件中。但这太麻烦了,因此必须要用 store 把展示组件包裹一层,恰好在组件树中渲染了一个容器组件。

建议的方式是使用指定的 React Redux 组件 <Provider> 来让所有容器组件都可以访问 store,而不必显示地传递它。只需要在渲染根组件时使用即可。

import React from 'react'
import { render } from 'react-dom'
import { createStore } from 'redux'
import { Provider } from 'react-redux'
import HelloApp from './HelloApp'
import HelloReducer from './reducers'

let store = createStore(HelloReducer)

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

到这里,大家已经基本掌握了Redux的基础及核心概念,有了这些,大家就可以开发简单的应用,关于Redux的更多实例、高级应用、技巧、API文档等可以查看redux中文文档 。

子状态树与combineReducers(reducers)

概况

随着应用变得复杂,需要对 reducer 函数 进行拆分,拆分后的每一块独立负责管理 state 的一部分。

combineReducers 辅助函数的作用是,把一个由多个不同 reducer 函数作为 value 的 object,合并成一个最终的 reducer 函数,然后就可以对这个 reducer 调用 createStore

合并后的 reducer 可以调用各个子 reducer,并把它们的结果合并成一个 state 对象。state 对象的结构由传入的多个 reducer 的 key 决定

最终,state 对象的结构会是这样的:

{
  reducer1: ...
  reducer2: ...
}

使用:

combineReducers({
  hello, cityReducer
})

state 对象的结构:

// 实际例子
{
  "hello":{"userName":"张三"}, 
  "cityReducer":{"city":"北京"}
}

通过为传入对象的 reducer 命名不同来控制 state key 的命名。

e.g.:

你可以调用 combineReducers({hello: hello,city: cityReducer}) 将 state 结构变为{ hello, city }

通常的做法是命名 reducer,然后 state 再去分割那些信息,因此你可以使用 ES6 的简写方法:combineReducers({ hello, city })。这与 combineReducers({ hello: hello,city: cityReducer }) 一样。

对于reducer的结构,大家规定只能是一级的,也就是

{
  "hello":{"userName":"张三"}, 
  "cityReducer":{"city":"北京"}
}

这种结构,不能再有子树,这样是为了方便进行管理。

参数

reducers (Object)是一个对象,它的值(value) 对应不同的 reducer 函数,这些 reducer 函数后面会被合并成一个。下面会先容传入 reducer 函数需要满足的规则。

之前的文档曾建议使用 ES6 的 import * as reducers 语法来获得 reducer 对象。这一点造成了很多疑问,因此现在建议在 reducers/index.js 里使用 combineReducers() 来对外输出一个 reducer。下面有示例说明。

返回值

(Function):一个调用 reducers 对象里所有 reducer 的 reducer,并且构造一个与 reducers 对象结构相同的 state 对象。

注意

本函数设计的时候有点偏主观,就是为了避免新手犯一些常见错误。也因些大家故意设定一些规则,但如果你自己手动编写根 redcuer 时并不需要遵守这些规则。

每个传入 combineReducers 的 reducer 都需满足以下规则:

  • 所有未匹配到的 action,必须把它接收到的第一个参数也就是那个 state 原封不动返回。
  • 永远不能返回 undefined。当过早 return 时非常容易犯这个错误,为了避免错误扩散,遇到这种情况时 combineReducers 会抛异常。
  • 如果传入的 state 就是 undefined,一定要返回对应 reducer 的初始 state。根据上一条规则,初始 state 禁止使用 undefined。使用 ES6 的默认参数值语法来设置初始 state 很容易,但你也可以手动检查第一个参数是否为 undefined

实例:

const hello = (state = {userName: 'Hehe'}, action) => { // 设置了初始值
  switch (action.type) {
    case 'USER_CHANGE':
      return {
        userName: action.userName
      }
    // 所有未匹配到的 action,必须把它接收到的第一个参数也就是那个 state 原封不动返回。
    default: 
      return state
  }
}

export default hello

异步action

学习到这里,大家所接触的下图上的所有实现,都是针对同步事件的。如果只是这样,那么大家肯定不能放心大胆的使用redux在大家的项目中,因为大家实际项目中,更多的都是异步事件。所以接下来,让大家来先容一个复杂的场景,大家来看看redux是如何应用在大型复杂充满异步事件的场景中的。

react-redux.png

大家仍然会遵守上图,这是大家的核心,不能改变,下面大家来看一个实际的例子,工资列表页面。

工资列表页面

也就是一个普通的通过网络请求,去请求列表数据的列表的展示。大家先来分析一下状态,列表页面的状态。

状态(state)

是一种数据结构,存储在store中的数据

异步加载的页面的状态:“加载中;加载成功,展示列表;加载失败” 这三种状态。大家给这三种状态来取一个名字,并设置0,1,2来顺序表示不同的状态。

loadingListStatus:0|1|2

大家主要做的是列表页的展示,那么还有一个最重要的数据结构就是列表数据,大家来取一个名字:

salaryList:[]

接下来大家再来分析一下,action,也就是事件。

事件

列表展示过程中的数据,也就是:“开始加载;加载成功;加载失败”这三个事件。其实整个过程和之前使用promise来实现的异步操作是一样的。大家是监听action,然后产生异步操作,实行dispatch方法,将数据结构保存到store中。

例子

大家来看一个获取列表的请求:

function fetchSalayList(subreddit) {
  return dispatch => {
    dispatch(loadingAction(subreddit))// 开始加载
    return fetch(`http://www.reddit.com/r/${subreddit}.json`)
      .then(response => response.json())
      .then(json => { // 加载成功
        dispatch(loadingSucessAction(subreddit, json))
      }, (error) => { // 加载失败
        dispatch(loadingErroeAction(subreddit))
      }
  }
}

上述这种方式,完全符合大家的核心图表,并且实现了异步操作。

在异步操作这块,大家建议使用 redux-saga 中间件来创建更加复杂的异步 action。其中涉及到es6中的Generators可以在文档中查看。另外,还有 redux-saga的使用的一个例子可以看这里。

异步数据流

默认情况下,createStore() 所创建的 Redux store 没有使用 middleware,所以只支撑 同步数据流。

你可以使用 applyMiddleware() 来增强 createStore()。虽然这不是必须的,但是它可以帮助你用简便的方式来描述异步的 action。

像 redux-thunk 或 redux-promise 这样支撑异步的 middleware 都包装了 store 的 dispatch() 方法,以此来让你 dispatch 一些除了 action 以外的其他内容,例如:函数或者 Promise。你所使用的任何 middleware 都可以以自己的方式解析你 dispatch 的任何内容,并继续传递 actions 给下一个 middleware。比如,支撑 Promise 的 middleware 能够拦截 Promise,然后为每个 Promise 异步地 dispatch 一对 begin/end actions。

当 middleware 链中的最后一个 middleware 开始 dispatch action 时,这个 action 必须是一个普通对象。这是 同步式的 Redux 数据流 开始的地方(译注:这里应该是指,你可以使用任意多异步的 middleware 去做你想做的事情,但是需要使用普通对象作为最后一个被 dispatch 的 action ,来将处理流程带回同步方式)。

参考

  • React-Redux性能优化
  • 官网-中文
  • redux的异步实现
  • es6特性-Generators

推荐阅读更多精彩内容