Menu

  • Home
  • Work
    • Cloud
      • Virtualization
      • IaaS
      • PaaS
    • Java
    • Go
    • C
    • C++
    • JavaScript
    • PHP
    • Python
    • Architecture
    • Others
      • Assembly
      • Ruby
      • Perl
      • Lua
      • Rust
      • XML
      • Network
      • IoT
      • GIS
      • Algorithm
      • AI
      • Math
      • RE
      • Graphic
    • OS
      • Linux
      • Windows
      • Mac OS X
    • BigData
    • Database
      • MySQL
      • Oracle
    • Mobile
      • Android
      • IOS
    • Web
      • HTML
      • CSS
  • Life
    • Cooking
    • Travel
    • Gardening
  • Gallery
  • Video
  • Music
  • Essay
  • Home
  • Work
    • Cloud
      • Virtualization
      • IaaS
      • PaaS
    • Java
    • Go
    • C
    • C++
    • JavaScript
    • PHP
    • Python
    • Architecture
    • Others
      • Assembly
      • Ruby
      • Perl
      • Lua
      • Rust
      • XML
      • Network
      • IoT
      • GIS
      • Algorithm
      • AI
      • Math
      • RE
      • Graphic
    • OS
      • Linux
      • Windows
      • Mac OS X
    • BigData
    • Database
      • MySQL
      • Oracle
    • Mobile
      • Android
      • IOS
    • Web
      • HTML
      • CSS
  • Life
    • Cooking
    • Travel
    • Gardening
  • Gallery
  • Video
  • Music
  • Essay

Redux学习笔记

15
Dec
2016

Redux学习笔记

By Alex
/ in JavaScript
/ tags 学习笔记
0 Comments
简介

Redux是一个存储JavaScript应用状态的容器,它是更早出现的Flux架构的一种变体,Redux弱化了Dispatcher并将其职责转移到全局唯一的Store身上。官网称Redux为可预测的(predictable)——一个操作(Action)会引发应用状态怎么样改变是确定的,这应该是声明在自己对MVVM流派的反对态度。

导致Redux之类的框架出现的原因,主要是越来越复杂的单页面JavaScript应用。开发人员需要维护越来越多的“状态”,这些状态包括:

  1. 业务数据:服务器响应、缓存数据、客户端新创建的数据
  2. UI状态:当前路由、选中的Tab页…

手工管理这些状态是困难的。模型更新模型、视图更新模型、模型更新视图…会让你无法判断系统为什么处于现在的状态,进而导致BUG难以重现、调试困难。新的前端开发需求则加剧了这种困难,这些需求包括服务器端渲染、路由前数据抓取、优化的数据更新。

通过限制状态何时、如何被更新,Redux让应用状态的变更变得可预测。这些限制体现在Redux的三个理念中。

Redux提供的API非常少,实质上Redux并非单一的框架,它更是一套约定。这套约定规定了函数的规格、函数之间应该如何交互。使用Redux时大部分时间你都在编写函数。

Redux支持Web客户端、Web服务器、甚至Native环境,易于测试。尽管Redux经常和React一起使用,但是配合其它视图层的库也是可以的。

何时使用Redux

下面列出可以引入Redux的应用场景:

  1. 视图需要从多个数据源获得数据
  2. 不同视图需要共享状态
  3. 大量服务器交互,使用Websocket
对比Flux
  1. Redux没有Flux架构中的Dispatcher角色
  2. Redux只有唯一的Store,而Flux可以具有很多个。当应用程序规模增大时,你不能增加Store,而应把Reducer切分为多个小的Reducer,这些小的Reducer独立的操控状态树的一部分
理念

Redux遵循三个基本的理念:

  1. Single source of truth:整个应用程序的状态,并存放在单个Store持有的对象树中
  2. State is readonly:修改对象树的唯一办法是触发一个Action,Action是描述所发生事情(用户、定时器操作或者服务器数据到达)的简单对象
  3. Changes are made with pure function:为了基于Action改变状态树,你必须编写纯函数风格的Reducers。Reducer可以接收当前状态 + Action,并返回一个新状态
JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import {createStore} from 'redux'
 
// 这是一个Reducer函数,从当前状态、动作推导新状态,它必须是纯函数
function counter( state = 0, action ) {
    switch ( action.type ) {
        case 'INCREMENT':
            return state + 1
        case 'DECREMENT':
            return state - 1
        default:
            return state
    }
}
/**
* 创建Store时,需要指定其用什么Reducer来处理Action
* 尽管没有硬性限制,你还是应当仅创建一个Store
*/
let store = createStore( counter );
 
/**
* 你可以使用调用Store的subscribe()方法来注册监听器,这样状态树变更后你可以得到通知,以更新UI或者把当前状态
* 存放到LocalStorage
*
* 通常,你会使用某种View层的绑定,例如React绑定,因而不需要直接调用该方法
*/
store.subscribe( () =>
    console.log( store.getState() )
);
// 唯一改变Store内部状态的方法:
store.dispatch( { type: 'INCREMENT' } ); 
安装

执行下面的命令,为当前工程添加Redux支持:

Shell
1
2
3
4
5
6
7
8
# Redux
npm install --save redux
 
# Redux的React绑定
npm install --save react-redux
 
# Redux开发者工具
npm install --save-dev redux-devtools

与Redux不同,其生态系统中的很多包不提供UMD构建,因此建议使用CommonJS模块捆绑器,例如Webpack或者Browserify。

基础
Actions

Action是应用程序发送给Store的数据的载荷(Payloads)。对于Store来说,它是唯一的信息来源。要将Action送给Store处理,需要调用 store.dispatch() 方法。

Action是简单JS对象,必须具有一个type属性,表示Action的类型。type属性通常都是字符串常量,应用程序规模扩大后,你应当把这些type常量独立到模块中:

JavaScript
1
import { ADD_TODO, REMOVE_TODO } from '../actionTypes'

除了type以外Action还需要什么属性,完全取决于你的需求。

规范化Action

Redux推荐遵循Flux标准Action(FSA)写法:

JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
// 一个普通的FSA
var action = {
    type: 'ADD_TODO',
    payload: {
        text: 'Do something.'
    }
};
// 一个代表错误的FSA
var err = {
    type: 'ADD_TODO',
    payload: new Error(),
    error: true
};

FSA规定:

  1. Action必须是简单JS对象,且必须具有type属性
  2. Action可以具有error、payload、meta属性
  3.  其它未提及的属性均不允许出现
Action Creators

这是一类用于创建Action的助手类函数,让Action的发出者免于关注Action的类型:

JavaScript
1
2
3
4
5
6
function addTodo( text ) {
    return {
        type: ADD_TODO,
        text
    }
}

在典型的Flux架构中,Action Creator负责触发dispatch()的调用。Redux却不是这样,你可以创建bound action creator来执行dispatch:

JavaScript
1
const boundAddTodo = (text) => dispatch(addTodo(text))

你可以调用 bindActionCreators() 将多个Action Creator绑定到dispatch()调用。

Reducers

Action描述发生了什么,而Reducer则描述应用状态应该如何依据Action而改变,它根据前一个状态 + Action推导出下一个状态:

JavaScript
1
(previousState, action) => newState

之所以叫做Reducer,是因为这种函数可以传递给数组的reduce函数:

JavaScript
1
Array.prototype.reduce(reducer, ?initialValue);

Reducer必须基于函数式编程范式编写,必须实现为纯函数:

  1. immutable:不要修改参数
  2. 不要调用任何具有边际效应(side effects )的API,例如路由转换
  3. 不要调用任何非纯函数,例如Date.now()
  4. 对于相同的输入,总是返回相同的结果

提醒事项应用中最初的Reducer实现如下:

JavaScript
1
2
3
4
5
6
7
8
9
// 初始状态
const initialState = {
    visibilityFilter: VisibilityFilters.SHOW_ALL,
    todos: []
}
// Reducer最初从初始状态开始处理,这里使用ES6默认参数
function todoApp(state = initialState, action) {
    return state; // 暂时不进行处理,仅仅返回原始状态
}

现在,添加代码,让它能够处理SET_VISIBILITY_FILTER、ADD_TODO这两个Action:

JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
function todoApp( state = initialState, action ) {
    switch ( action.type ) {
        // 设置显示过滤器
        case SET_VISIBILITY_FILTER:
            // 不能改变状态,因此这里调用assign复制对象
            return Object.assign( {}, state, {
                visibilityFilter: action.filter
            } )
        // 添加一个提醒事项
        case ADD_TODO:
            return Object.assign( {}, state, {
                // 也不能改变状态的任何子属性
                todos: [
                    ...state.todos,
                    {
                        text: action.text,
                        completed: false
                    }
                ]
            } )
        // 改变一个提醒事项的完成状态
        case TOGGLE_TODO:
            return Object.assign( {}, state, {
                // 映射出一个新的数组,因为原数组不能被改变
                todos: state.todos.map( ( todo, index ) => {
                    if ( index === action.index ) {
                        // 这个提醒事项需要被改变,因此必须在副本上执行修改
                        return Object.assign( {}, todo, {
                            completed: !todo.completed
                        } )
                    }
                    // 你可以使用原状态树中的节点,只要不改变它
                    return todo
                } )
            } )
        default:
            // 对于未知的Action,应该总是返回前一个状态
            return state
    }
}

要处理更多的Action,可以继续增加case子句。从上面的代码可以看到,为了保证纯函数的特征,需要编写很多数据拷贝代码。你可以使用

  1. immutability-helper:改变对象的拷贝,保持源对象不变
  2. updeep:以声明式/不变性的方法,更新嵌套的冻结对象、数组
  3. Immutable:不变的JavaScript集合

之类支持深层更新(deep update)的库,以降低工作量同时提供安全性(防止意外操作导致纯函数特征破坏)。

分割Reducer

Case语句越来越多以后,上面的Reducer会变得又长又臭,难以读懂。而且,尽管SET_VISIBILITY_FILTER和其它Action操作的数据完全没有交集,它们都被迫处理整个状态树。

我们可以对上面的代码进行重构:

JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
// 这个子Reducer仅仅处理状态树的visibilityFilter子树
function visibilityFilter( state = SHOW_ALL, action ) {
    switch ( action.type ) {
        case SET_VISIBILITY_FILTER:
            return action.filter
        default:
            return state
    }
}
// 这个子Reducer仅仅处理状态树的todos子树
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 Object.assign( {}, todo, {
                        completed: !todo.completed
                    } )
                }
                return todo
            } )
        default:
            // 不是我关心的Action,一定要返回原来的状态
            return state
    }
}
// 父Reducer不再负责提供初始状态,由管理状态子树的子Reducer负责
function todoApp( state = {}, action ) {
    switch ( action.type ) {
        case SET_VISIBILITY_FILTER:
            return Object.assign( {}, state, {
                visibilityFilter: visibilityFilter( state.visibilityFilter, action )
            } )
        case ADD_TODO:
        case TOGGLE_TODO:
            // 父Reducer中的框架代码负责状态子树的抽取、拼回
            return Object.assign( {}, state, {
                todos: todos( state.todos, action )
            } )
        default:
            return state
    }
}

重构以后的代码,具有以下特点:

  1. 原先Reducer的体积变小了,部分工作委托给与Reducer行为类似的子Reducer处理
  2. 子Reducer仅仅处理它关注的状态子树
  3. 父Reducer负责把状态子树抽取出来供子Reducer处理,并把后者返回的新状态子树拼接回去

这段重构,蕴含了Redux应用的一个基础模式:Reducer组合(composition) 。

由于子Reducer仅仅处理它关注的那个子树,上面的父Reducer中switch语句可以安全的移除:

JavaScript
1
2
3
4
5
6
7
8
9
function todoApp( state = {}, action ) {
    // 简单的返回新状态
    return {
        // visibilityFilter这个状态子树由visibilityFilter这个子Reducer处理
        visibilityFilter: visibilityFilter( state.visibilityFilter, action ),
        // todos这个状态子树由todos这个子Reducer处理
        todos: todos( state.todos, action )
    }
}

这样,尽管任意Action需要交给所有子Reducer处理,但是子Reducer遇到其不关心的Action会立即返回未经改变的状态,因此不会引入太多的性能损耗。 

Redux提供了一个工具函数 combineReducers() ,可以更进一步简化上一段代码:

JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { combineReducers } from 'redux';
 
// 如果状态子树的属性名,与子Reducer的函数名一致:
const todoApp = combineReducers({
    visibilityFilter,
    todos
});
// 如果不一致:
const todoApp = combineReducers({
    visibilityFilter : vf
    todos : tds
});
 
// 如果你使用ES6,可以把所有相关的子Reducer编写在一个模块中并全部export,然后:
import * as reducers from './reducers';
const todoApp = combineReducers(reducers);
Store

通过Store,Action才可以传递给Reducer。Store具有以下职责:

  1. 持有应用程序的状态
  2. 允许代码通过 getState() 方法访问状态
  3. 允许代码通过 dispatch(action) 派发新的Action,并交由Reducer处理
  4. 允许代码通过 subscribe(listener) 注册监听器,该方法的返回值用于解除监听器。监听器在状态改变后自动调用
创建Store

记住:整个应用程序(精确来说是页面,如果一个系统由多个页面组成,就对应这里的多个应用程序)只有一个Store。下面的代码示例了如何创建Store:

JavaScript
1
2
3
import { createStore } from 'redux';
import todoApp from './reducers';
let store = createStore(todoApp);

你可以提供第二个可选参数,为状态赋初值:

Java
1
let store = createStore(todoApp, window.STATE_FROM_SERVER);
状态的组织

在编写代码之前应该好好考虑Store中存储的应用程序状态的结构,以“提醒事项”应用为例,状态包括两类不同的东西:

  1. UI状态:过滤规则,是否显示已经完成的提醒事项
  2. 数据:真正的提醒事项的列表

你需要把这两类东西都放在Store中,但是注意将它们分开:

JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
{
    visibilityFilter: 'SHOW_ALL',
    todos: [
        {
            text: 'Consider using Redux',
            completed: true,
        },
        {
            text: 'Keep all state in a single tree',
            completed: false
        }
    ]
}

对于大部分复杂的应用程序,Redux建议尽量规范化(normalized)的存储数据,避免嵌套,就像数据库那样:

  1. 使用ID作为key来引用单条数据,例如: todosById: { id -> todo } 
  2. 使用IDs来引用数据的列表,例如: todos: array<id> 
  3. 数据之间有关联时,通过ID/IDs引用,而不是嵌套关联对象
分发Action

调用 store.dispatch() 即可分发一个Action,甚至不需要View层的参与。这让Redux应用容易自动化的测试。

JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { addTodo, toggleTodo, setVisibilityFilter, VisibilityFilters } from './actions'
// 打印初始状态
console.log(store.getState())
 
// 注册监听器,当状态改变后,打印新的状态
let unsubscribe = store.subscribe(() =>
    console.log(store.getState())
)
 
// 发布一些事件
store.dispatch(addTodo('Learn about actions'))
store.dispatch(addTodo('Learn about reducers'))
store.dispatch(addTodo('Learn about store'))
store.dispatch(toggleTodo(0))
store.dispatch(toggleTodo(1))
store.dispatch(setVisibilityFilter(VisibilityFilters.SHOW_COMPLETED))
 
// 解除监听器
unsubscribe() 

尽管你可以直接调用dispatch方法,但配合React时最好使用react-redux的 connect() 方法。

数据流

Redux架构是围绕构建严格的单向数据流来构建的。 

这意味着,应用程序中的所有数据遵循相同的生命周期模式。这让你的代码逻辑易于理解、行为可预测。Redux同样鼓励数据规范化(normalization)——状态组织类似于遵循范式的数据库,以便减少相互不感知(由不同子Reducer处理)的、实际上是相同数据的状态子树。

如上面章节讨论的那样,Redux中数据的生命周期通常是:

  1. store.dispatch(action)  被调用,新数据作为Action的载荷被发送
  2. Store调用Reducer函数,处理Action
  3. 根Reducer可以合并多个子Reducer的输出,形成完整的状态树
  4. 根Reducer返回状态树给Store,由后者保存
  5. Store发布事件,所有订阅者获得最新状态的通知
联用React

Redux可以与很多其它框架/库联用,包括React、Angular、Ember、jQuery。对于React这样的库来说,Redux特别适用,因为React将UI表示为关于状态的函数,而Redux正好能够很好的管理状态。

本章以提醒事项应用为例来阐述Redux和React的整合。

安装react-redux

Redux的React绑定是独立的项目,可以执行下面的命令添加到当前工程中:

Shell
1
npm install --save react-redux
展示/容器组件

React-Redux将React组件分为展示(Presentational)组件和容器(Container)组件:

  展示组件 容器组件
目的 利用HTML标签、样式表,来确定页面长什么样 进行数据获取、状态更新
是否感知Redux 否 是
能否读取数据 从props读取数据 订阅Redux管理的状态
能否改变数据 从props中执行回调 派发Redux Action
来源 手工编写 通常由react-redux自动生成

我们编写的绝大部分组件都是展示组件,只有少数用来对接到Redux的容器组件。

尽管你可以调用store.subscribe(),来创建自己的容器组件,但是这样做并不被推荐。React-Redux提供了 connect() 函数,调用它就可以生成容器组件了。

设计组件层次
展示组件

React组件层次往往和Redux状态树多少有些对应关系。还是以提醒事项为例,展示组件可以包括:

  1. TodoList 显示所有可见的提醒事项的列表
  2. Todo 单个提醒事项条目
  3. Link 带有回调的链接,点击后其onClick被调用
  4. Footer 切换可以看到全部还是仅未完成的提醒事项
  5. App 根组件
容器组件

为了把展示组件连接到Redux,我们需要一些容器组件:

  1. VisibleTodoList:根据当前的过滤器设置,过滤不显示的提醒事项,并渲染TodoList
  2. FilterLink:根据当前过滤器设置,设置Link的样式和点击事件处理函数
其它组件

某些组件很难严格的划分到上面两类中。例如,某些时候表单字段和功能是紧耦合在一起的:

  1. AddTodo,一个附带了按钮的输入框

我们可以把AddTodo拆分为两个组件,但是由于此组件非常小,混合展示、逻辑在其中也无可厚非。如果该组件以后变大了可以考虑重构。

组件实现
展示组件
components/App.js
JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import React from 'react'
import Footer from './Footer'
import AddTodo from '../containers/AddTodo'
import VisibleTodoList from '../containers/VisibleTodoList'
 
// 顶级展示组件
const App = () => (
    <div>
        // 添加提醒事项条目的输入框和按钮
        <AddTodo />
        // 容器,可见的提醒事项列表
        <VisibleTodoList />
        // 尾部链接区,切换哪些事项可见
        <Footer />
    </div>
)
 
export default App

components/Footer.js
JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import React from 'react'
import FilterLink from '../containers/FilterLink'
 
const Footer = () => (
    <p>
        Show:
        {" "}
        // 每个链接都使用容器包装
        <FilterLink filter="SHOW_ALL">
            All  // 作为Link的children属性传入
        </FilterLink>
        {", "}
        <FilterLink filter="SHOW_ACTIVE">
            Active
        </FilterLink>
        {", "}
        <FilterLink filter="SHOW_COMPLETED">
            Completed
        </FilterLink>
    </p>
)
 
export default Footer

components/Link.js
JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
import React, { PropTypes } from 'react'
 
const Link = ({ active, children, onClick }) => {
    if (active) return <span>{children}</span>
    else return <a href="#" onClick={e => { e.preventDefault(); onClick() }} >{children}</a>
}
 
Link.propTypes = {
    active: PropTypes.bool.isRequired,
    children: PropTypes.node.isRequired,
    onClick: PropTypes.func.isRequired
}
 
export default Link

components/Todo.js
JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import React, {PropTypes} from 'react'
// 提醒事项条目,所有状态、配置、包括事件处理函数,从外部传入
const Todo = ({onClick, completed, text}) => {
    // 样式取决于状态
    let todoStyle = {
        textDecoration: completed ? 'line-through' : 'none'
    }
    return <li onClick={onClick} style={todoStyle}> {text}</li>
}
 
Todo.propTypes = {
    onClick: PropTypes.func.isRequired,
    completed: PropTypes.bool.isRequired,
    text: PropTypes.string.isRequired
}
 
export default Todo

components/TodoList.js
JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import React, { PropTypes } from 'react'
// 引入依赖的组件
import Todo from './Todo'
// 提醒事项列表,所有状态、配置、包括事件处理函数,仍然从外部传入
const TodoList = ({ todos, onTodoClick }) => (
    // 注意展开操作符,可以简化React元素属性传入,注意onClick的绑定
    <ul>
        {todos.map(todo => <Todo key={todo.id} {...todo} onClick={() => onTodoClick(todo.id)} /> )}
    </ul>
)
 
TodoList.propTypes = {
    todos: PropTypes.arrayOf(PropTypes.shape({
        id: PropTypes.number.isRequired,
        completed: PropTypes.bool.isRequired,
        text: PropTypes.string.isRequired
    }).isRequired).isRequired,
    onTodoClick: PropTypes.func.isRequired
}
 
export default TodoList
容器组件

上面的展示组件已经引用了一些容器组件,容器组件用来把某些展示层组件挂钩到Redux。严格的说,容器组件仅仅是通过 store.subscribe() 来读取Redux一部分状态树、并且为其内部的展示组件提供props的普通React组件。

编写容器组件时,一般使用React-Redux提供的 connect() 函数。此函数提供了必要的优化,可以避免不必要的重渲染。

使用connect函数时,你需要定义:

  1. 一个名为 mapStateToProps 函数。该函数指明如何将当前Redux状态转换为当前容器包装的展示组件的props。该函数接收两个参数,第一个是当前Redux Store的状态,第二个是传递给容器(而非它包装的展示组件)的属性集
  2. 一个名为 mapDispatchToProps 的函数,该函数接受Store.dispatch、展示组件的props作为入参,返回展示层组件所需要的那些事件处理函数。这些事件处理函数作为props的组成部分

准备好这两个函数之后,将其作为入参传递给connect函数,即可获得自动创建的容器组件。

链接的容器:

containers/FilterLink.js
JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import { connect } from 'react-redux'
import { setVisibilityFilter } from '../actions'
import Link from '../components/Link'
 
// 将Redux状态转换为容器包裹的展示组件的props
const mapStateToProps = (state, ownProps) => {
    return {
        active: ownProps.filter === state.visibilityFilter
    }
}
// 指定展示组件中的事件处理函数,dispatch为Store的派发函数,ownProps则为事件发生时容器组件的属性
const mapDispatchToProps = (dispatch, ownProps) => {
    return {
        onClick: () => {
            dispatch(setVisibilityFilter(ownProps.filter))
        }
    }
}
// 将上面两个函数传递给connect函数,生成容器
const FilterLink = connect(
    mapStateToProps,
    mapDispatchToProps
)(Link)
 
export default FilterLink

提醒事项列表的容器:

containers/VisibleTodoList.js
JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import {connect} from 'react-redux'
import {toggleTodo} from '../actions'
import TodoList from '../components/TodoList'
 
const getVisibleTodos = (todos, filter) => {
    switch (filter) {
        case 'SHOW_ALL':
            return todos
        case 'SHOW_COMPLETED':
            // 系统状态中,提醒事项只有一个列表。每次展示时,基于此列表返回一个临时的副本
            return todos.filter(t => t.completed)
        case 'SHOW_ACTIVE':
            return todos.filter(t => !t.completed)
    }
}
 
// 根据过滤器判断展示哪些提醒事项条目
const mapStateToProps = (state) => {
    return {
        todos: getVisibleTodos(state.todos, state.visibilityFilter)
    }
}
 
// 指定事件处理函数
const mapDispatchToProps = (dispatch) => {
    return {
        onTodoClick: (id) => {
            dispatch(toggleTodo(id))
        }
    }
}
const VisibleTodoList = connect(
    mapStateToProps,
    mapDispatchToProps
)(TodoList)
 
export default VisibleTodoList
其它组件

上面几个是纯粹的容器组件,它们不包含任何展示性的代码。实现这类组件的关键是基于两个回调提供,将展示组件连接到Redux的全部必要信息。

某些情况下展示和逻辑是自然耦合在一起的,并且组件规模很小,展示、容器组件可以合二为一:

containers/AddTodo.js
JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import React from 'react'
import { connect } from 'react-redux'
import { addTodo } from '../actions'
 
// Store.dispatch方法作为唯一传入的props,“展示组件”自己负责Action的分发
let AddTodo = ({ dispatch }) => {
    let input
 
    return (
        <div>
            <form onSubmit={e => {
                e.preventDefault()
                if (!input.value.trim()) {
                    return
                }
                dispatch(addTodo(input.value))
                input.value = ''
            }}>
                // ref回调会在组件挂载时执行,传递当前元素
                <input ref={node => {
                    input = node
                }} />
                <button type="submit">
                    Add Todo
                </button>
            </form>
        </div>
    )
}
 
// 调用connect时不提供回调入参
// 创建React组件实例时,dispatch由Redux传入
AddTodo = connect()(AddTodo)
 
export default AddTodo
传入Store

上节中AddTodo组件会在运行时自动被Redux注入dispatch方法,这意味着你必须把Store实例告诉给Redux。这可以通过Provider组件完成:

index.js
JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import React from 'react'
import { render } from 'react-dom'
import { Provider } from 'react-redux'
import { createStore } from 'redux'
import todoApp from './reducers'
import App from './components/App'
 
let store = createStore(todoApp)
 
render(
    <Provider store={store}>
        <App />
    </Provider>,
    document.getElementById('root')
)
Redux相关代码

以下代码仅仅和Redux相关。Action Creators:

actions/index.js
JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
let nextTodoId = 0
export const addTodo = (text) => {
    return {
        type: 'ADD_TODO',
        id: nextTodoId++,
        text
    }
}
 
export const setVisibilityFilter = (filter) => {
    return {
        type: 'SET_VISIBILITY_FILTER',
        filter
    }
}
 
export const toggleTodo = (id) => {
    return {
        type: 'TOGGLE_TODO',
        id
    }
}

 Reducers:

reducers/todos.js
JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
const todo = (state = {}, action) => {
    switch (action.type) {
        case 'ADD_TODO':
            return {
                id: action.id,
                text: action.text,
                completed: false
            }
        case 'TOGGLE_TODO':
            if (state.id !== action.id) {
                return state
            }
 
            return Object.assign({}, state, {
                completed: !state.completed
            })
 
        default:
            return state
    }
}
 
const todos = (state = [], action) => {
    switch (action.type) {
        case 'ADD_TODO':
            return [
                ...state,
                todo(undefined, action)
            ]
        case 'TOGGLE_TODO':
            return state.map(t =>
                todo(t, action)
            )
        default:
            return state
    }
}
 
export default todos

reducers/visibilityFilter.js
JavaScript
1
2
3
4
5
6
7
8
9
10
const visibilityFilter = (state = 'SHOW_ALL', action) => {
    switch (action.type) {
        case 'SET_VISIBILITY_FILTER':
            return action.filter
        default:
            return state
    }
}
 
export default visibilityFilter

reducers/index.js
JavaScript
1
2
3
4
5
6
7
8
9
10
import { combineReducers } from 'redux'
import todos from './todos'
import visibilityFilter from './visibilityFilter'
 
const todoApp = combineReducers({
    todos,
    visibilityFilter
})
 
export default todoApp
联用React Router

React-Router为React库添加了路由功能,联合Redux使用该React Router时,Redux作为应用状态、数据的唯一依据(Source of truth),而React Router则是URL的唯一依据。大部分时间里,Redux和React Router可以相互隔离不相关,除非你希望时间旅行( time travel)、rewind触发URL改变的Action。

配置Fallback URL

在集成React Router之前,我们需要配置开发服务器。开发服务器可能对React Router的路由配置一无所知。例如当你访问/todos时,服务器应该知道要访问的页面是index.html——因为这是单页面应用程序。

如果你使用create-react-app,Fallback URL不需要手工配置。

配置Express

如果你使用Express作为服务器,可以这样配置:

JavaScript
1
2
3
app.get( '/*', ( req, res ) => {
    res.sendfile( path.join( __dirname, 'index.html' ) )
} )
配置Webpack开发服务器

如果你使用Webpack提供的开发服务器,可以这样配置:

webpack.config.dev.js
JavaScript
1
2
3
devServer: {
    historyApiFallback: true,
}
连接React Router到Redux

根元素内容如下:

JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { Router, Route, browserHistory } from 'react-router';
import { Provider } from 'react-redux';
 
const Root = ( { store } ) => (
    // 将Router包含在Provider内部,这样Route处理器就可以访问Redux的Store
    <Provider store={store}>
        <Router>
            // Route声明式的映射路由(URL)到应用程序的组件层次中
            <Route path="/" component={App}/>
            // 我们需要读取URL路径变量,因此该写为:
            <Route path="/(:filter)" component={App} />
        </Router>
    </Provider>
);

如果需要移除URL中的#字符(例如 http://localhost:3000/#/... ) ,你需要引入browserHistory。同时为Router组件传入history属性:

JavaScript
1
<Router history={browserHistory}>...</Router>

除非需要支持IE9之类的老旧浏览器,你总是应该使用browserHistory。

基于React Router导航

React Router内置了 <Link/> 组件,使用它可以在应用内自由导航。在提醒实现的例子中,我们可以用此组件代替自己实现的Link:

containers/FilterLink.js
JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import React from 'react';
import { Link } from 'react-router';
 
const FilterLink = ( { filter, children } ) => {
    let activeStyle = {
        textDecoration: 'none',
        color: 'black'
    };
    return (
        // to指定目标URL
        <Link to={filter === 'all' ? '' : filter} activeStyle={activeStyle}>
            {children}
        </Link>
    );
};
 
export default FilterLink;

containers/Footer.js
JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import React from 'react'
import FilterLink from '../containers/FilterLink'
 
const Footer = () => (
    <p>
        Show:
        {" "}
        <FilterLink filter="all">
            All
        </FilterLink>
        {", "}
        <FilterLink filter="active">
            Active
        </FilterLink>
        {", "}
        <FilterLink filter="completed">
            Completed
        </FilterLink>
    </p>
);

现在,当你依次点击三个FilterLink时,浏览器的URL会在/complete、/active、/之间切换。使用后退键在历史记录中导航也是支持的。

从URL中读取状态

仅仅改变URL是不够的,容器组件的代码也要一并修改,这样UI才能与URL同步更新。首先修改:

containers/VisibleTodoList.js
JavaScript
1
2
3
4
5
6
const mapStateToProps = ( state, ownProps ) => {
    return {
        // 原来是getVisibleTodos(state.todos, state.visibilityFilter)
        todos: getVisibleTodos( state.todos, ownProps.filter )
    };
};

目前,我们尚未传递任何东西到<App/> 中,因此ownProps是一个空对象。为了依据URL来过滤提醒事项,我们需要把URL路径变量传递给<VisibleTodoList />组件。

之前声明的路由规则: <Route path="/(:filter)" component={App} /> 导致App内具有一个params属性,该属性是一个对象,其属性与路径变量对应。例如,对于URLlocalhost:3000/completed,params的值为 { filter: 'completed' } 。

下面修改App组件:

JavaScript
1
2
3
4
5
6
7
8
9
10
// 使用ES解构操作符读取props
const App = ( { params } ) => {
    return (
        <div>
            <AddTodo />
            <VisibleTodoList filter={params.filter || 'all'} />
            <Footer />
        </div>
    );
};

路由发生后,Redux驱动React进行重新渲染, 此时App的params属性改变,因而渲染的内容也跟着改变。

Reselect

Reselect是一个小巧的库,用于创建可备忘的(memoized)、可组合(composable)的选择器函数。这些选择器可以用来有效的从Redux Store中计算衍生数据( derived data)。

动机

回顾一下提醒事项的例子,容器VisibleTodoList的mapStateToProps函数调用助手函数getVisibleTodos,来计算需要渲染的todos。这种方法可行,但是有一个缺点:组件每次被更新时,todos都需要重新计算。如果状态树非常大、或者算法本身复杂,那么这种反复计算可能引起性能问题。

Reselect可以帮助避免不必要的重新计算。

创建备忘选择器

我们可以使用一个备忘选择器,来替换提醒事项中的getVisibleTodos函数。此选择器会仅仅在state.todos、state.visibilityFilter之一发生改变时,才触发重新计算。而不是在应用的任何无关部分发生变化时都去反复的计算。

要创建备忘选择器,可以调用createSelector函数。该函数接受一个输入选择器的数组、一个转换函数作为入参。如果Redux状态改变导致输入选择器的值改变,则转换函数会被自动调用并返回计算结果;反之,仅仅返回先前计算好的结果。

selectors/index.js
JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { createSelector } from 'reselect'
 
const getVisibilityFilter = ( state ) => state.visibilityFilter
const getTodos = ( state ) => state.todos
 
export const getVisibleTodos = createSelector(
    // 输入选择器,就是Redux的状态子树的获取函数
    [ getVisibilityFilter, getTodos ],
    // 转换函数,以输入选择器的调用结果为入参
    ( visibilityFilter, todos ) => {
        switch ( visibilityFilter ) {
            case 'SHOW_ALL':
                return todos
            case 'SHOW_COMPLETED':
                return todos.filter( t => t.completed )
            case 'SHOW_ACTIVE':
                return todos.filter( t => !t.completed )
        }
    }
)
组合选择器

从上面的例子中可以看到,所谓选择器就是一个普通函数。 createSelector的返回值则是与其输入选择器相同规格的函数,而createSelector又具有依据多个输入选择器生成新选择器的能力。

因此利用createSelector可以实现复杂的备忘选择器——仅当某个子选择器的值改变,其父选择器才重新计算,并且递归的触发祖代选择器的重新计算。

JavaScript
1
2
3
4
5
6
7
8
9
10
// 为提醒事项添加按关键字过滤的功能
const getKeyword = ( state ) => state.keyword
// 组合选择器,在根据可见性过滤的基础上,添加关键字过滤。这样,无论是切换所有/未完成/已完成可见,还是改变过滤关键字,都会
// 触发重新计算
const getVisibleTodosFilteredByKeyword = createSelector(
    [ getVisibleTodos, getKeyword ],
    ( visibleTodos, keyword ) => visibleTodos.filter(
        todo => todo.text.indexOf( keyword ) > -1
    )
)
在React中使用

如果你使用React-Redux,则可以在mapStateToProps函数中直接调用选择器:

JavaScript
1
2
3
4
5
6
const mapStateToProps = (state) => {
  return {
    // 像普通函数那样调用
    todos: getVisibleTodos(state)
  }
}
访问React组件属性

前面我们实现的选择器,入参是Redux状态。实际上你可以声明任意个参数:

JavaScript
1
2
3
4
5
const mapStateToProps = ( state, props ) => {
    return {
        todos: getVisibleTodos( state, props )
    }
}
跨组件共享选择器

我们延伸一下提醒事项的需求,现在需要展示三个独立的列表。 代码修改如下:

components/App.js
JavaScript
1
2
3
4
5
6
7
const App = () => (
    <div>
        <VisibleTodoList listId="1"/>
        <VisibleTodoList listId="2"/>
        <VisibleTodoList listId="3"/>
    </div>
)

selectors/todoSelectors.js
JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { createSelector } from 'reselect'
 
const getVisibilityFilter = ( state, props ) => state.todoLists[ props.listId ].visibilityFilter
 
const getTodos = ( state, props ) => state.todoLists[ props.listId ].todos
 
const getVisibleTodos = createSelector(
    [ getVisibilityFilter, getTodos ],
    ( visibilityFilter, todos ) => {
        switch ( visibilityFilter ) {
            case 'SHOW_COMPLETED':
                return todos.filter( todo => todo.completed )
            case 'SHOW_ACTIVE':
                return todos.filter( todo => !todo.completed )
            default:
                return todos
        }
    }
)
 
export default getVisibleTodos

上段代码中的选择器需要两个参数:state、props,使用第二个属性的原因是,需要读取容器组件的listId属性以确定使用哪个提醒事项列表。

containers/VisibleTodoList.js
1
2
3
4
5
6
7
8
9
10
11
12
const mapStateToProps = ( state, props ) => {
    return {
        // 警告:下面的备忘选择器不能正常工作
        todos: getVisibleTodos( state, props )
    }
}
 
const mapDispatchToProps = ( dispatch ) => {/**/}
 
const VisibleTodoList = connect( mapStateToProps, mapDispatchToProps )( TodoList )
 
export default VisibleTodoList

基于以上代码,无法实现“备忘”目的,因为:

  1. 三个 VisibleTodoList组件共享了同一选择器getVisibleTodos
  2. 每当应用程序状态改变后,getVisibleTodos函数的第二参数会以1、2、3依次调用一遍。这导致输入选择器getTodos结果总是变化,因而getVisibleTodos总是需要重新计算

那么,如何让同一组件的多个实例使用不同的选择器实例,以便缓存不会相互干扰呢?

mapStateToProps的返回值

通常mapStateToProps函数的返回值应该是一个对象,该对象作为展示组件的属性使用。但是从React-Redux 4.3.0开始,此函数可以返回一个函数。

如果mapStateToProps返回一个函数,那么在Redux状态变化时,对于每一个容器组件实例,该函数会被调用一次,以计算展示组件的属性。

这样我们就获得创建不同选择器实例的机会了:

selectors/todoSelectors.js
JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
const makeMapStateToProps = () => {
    const getVisibleTodos = createSelector( /**/ )
    // 返回一个箭头函数
    return ( state, props ) => {
        return {
            todos: getVisibleTodos( state, props )
        }
    }
}
 
const VisibleTodoList = connect(
    makeMapStateToProps, // 返回函数
    mapDispatchToProps
)( TodoList ) 

 

异步Action

上面的提醒事项应用,其Action都是同步的——一旦Action被派发,Store立即调用Reducer处理它,应用状态也就立即更新。实际应用中Action常需要触发服务器请求,因而应用状态必须异步更新。

调用异步API时,有两个关键时间点:发起API调用的那一刻;获得API调用结果的(或者超时的)那一刻。通常,这两个时刻都需要改变应用的状态。例如,发起异步调用时可能显示一个遮罩“正在获取数据”,获得调用结果时则需要显示处理后的结果。要基于Redux来处理这种异步调用,你需要派发多个被Reducer同步处理的普通的Action:

  1. 通知Redux请求已经开始的Action。Reducer通常会在Store中设置isFetching标记,以便UI组件显示一个“正在加载”的提示(Spinner)
  2. 通知Redux请求处理成功的Action。Reducer可能把新获得的数据合并到Store中并重置isFetching标记
  3. 通知Redux请求处理失败的Action。Reducer通常重置isFetching标记,有可能把失败原因存储到Store中供UI组件使用

 Action类型和载荷属性命名,通常使用如下风格:

JavaScript
1
2
3
4
5
6
7
8
// 风格一:
{ type: 'FETCH_POSTS' }
{ type: 'FETCH_POSTS', status: 'error', error: 'Oops' }
{ type: 'FETCH_POSTS', status: 'success', response: { ... } }
// 风格二:
{ type: 'FETCH_POSTS_REQUEST' }
{ type: 'FETCH_POSTS_FAILURE', error: 'Oops' }
{ type: 'FETCH_POSTS_SUCCESS', response: { ... } }
示例:新闻订阅

本节我们实现一个新闻订阅的例子,牵涉到网络通信和异步Action。

同步Action Creators

由用户操作触发的Action:

actions.js
JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 选择新闻栏目
export const SELECT_SUBREDDIT = 'SELECT_SUBREDDIT'
 
export function selectSubreddit( subreddit ) {
    return {
        type: SELECT_SUBREDDIT,
        subreddit
    }
}
 
// 点击刷新按钮后触发,更新新闻列表
export const INVALIDATE_SUBREDDIT = 'INVALIDATE_SUBREDDIT'
 
export function invalidateSubreddit( subreddit ) {
    return {
        type: INVALIDATE_SUBREDDIT,
        subreddit
    }
}

除了用户操作以外,网络请求也可以触发Action:

actions.js
JavaScript
1
2
3
4
5
6
7
8
9
// 为指定的栏目请求新数据
export const REQUEST_POSTS = 'REQUEST_POSTS'
 
export function requestPosts( subreddit ) {
    return {
        type: REQUEST_POSTS,
        subreddit
    }
}

尽管用户选择一个栏目后应该自动触发更新,但是我们并没有把REQUEST_POSTS与SELECT_SUBREDDIT或者合并INVALIDATE_SUBREDDIT为单个Action。这很重要,因为随着应用需求的变化,请求新数据的操作可能不依赖于用户操作而发生——例如预读取流行栏目、自动定期更新新为列表。

最后,当网络响应到达后触发的Action:

JavaScript
1
2
3
4
5
6
7
8
9
10
export const RECEIVE_POSTS = 'RECEIVE_POSTS'
 
export function receivePosts( subreddit, json ) {
    return {
        type: RECEIVE_POSTS,
        subreddit,
        posts: json.data.children.map( child => child.data ),
        receivedAt: Date.now()
    }
}

这里我们省略了处理请求错误的Action,真实应用中这通常是需要的。

设计状态树

前面已经提到过,在动手编写代码之前规划好应用程序的状态很重要。对于异步代码来说,你需要维护更多的状态。

新闻订阅的状态树结构如下:

JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
{
    selectedSubreddit: 'frontend',
    postsBySubreddit: {
        frontend: {
            isFetching: true,
            didInvalidate: false,
            items: []
        },
        reactjs: {
            isFetching: false,
            didInvalidate: false,
            lastUpdated: 1439478405547,
            items: [
                {
                    id: 42,
                    title: 'Confusion about Flux and Relay'
                },
                {
                    id: 500,
                    title: 'Creating a Simple Application Using React JS and Flux Architecture'
                }
            ]
        }
    }
}

isFetching指示是否正在请求数据,以便UI显示一个提示;didInvalidate用于提示数据是否过期;lastUpdated可以让用户知道数据更新时间;items存放新闻列表。真实应用中可能还需要fetchedPageCount、nextPageUrl之类的状态,用于分页(pagination)。

我们将每个栏目的信息单独存放。这样当用户切换栏目时,UI可以立即更新而不需要计算或者请求服务器,请求可以仅在需要的时候才进行。

上面的状态树比较简单,不牵涉实体之间的引用(假设新闻与栏目是ManyToOne关系),也就不存在嵌套实体(Nested Entities)的问题。前面的章节我们已经提到过一个设计原则——使用范式化的( normalized)的状态树,避免嵌套实体。嵌套实体带来的数据冗余让状态同步变得很困难,应该从设计的初期就避免。

如果引入新闻作者等额外实体,或者新闻可以归属于多个栏目,特别是应用需要修改作者、新闻等实体,可以修改状态树为:

JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
{
    selectedSubreddit: 'frontend',
    entities: {
        users: {
            2: {
                id: 2,
                name: 'Andrew'
            }
        },
        posts: {
            42: {
                id: 42,
                title: 'Confusion about Flux and Relay',
                author: 2
            },
            100: {
                id: 100,
                title: 'Creating a Simple Application Using React JS and Flux Architecture',
                author: 2
            }
        }
    },
    postsBySubreddit: {
        frontend: {
            isFetching: true,
            didInvalidate: false,
            items: []
        },
        reactjs: {
            isFetching: false,
            didInvalidate: false,
            lastUpdated: 1439478405547,
            items: [
                42,
                100
            ]
        }
    }
}

这是个典型的范式化的状态树设计,就像数据库那样,总是通过“键”引用其它实体。 为了简化代码以突出本章的主题,新闻订阅的状态树不使用范式化状态树设计。

处理Action

在深入如何随着网络请求派发Action的细节之前,我们先编写处理上述Action的Reducers。

reducers.js
JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
import { combineReducers } from 'redux'
import { SELECT_SUBREDDIT, INVALIDATE_SUBREDDIT, REQUEST_POSTS, RECEIVE_POSTS } from '../actions'
 
// 处理切换栏目的动作
function selectedSubreddit( state = 'reactjs', action ) {
    switch ( action.type ) {
        case SELECT_SUBREDDIT:
            return action.subreddit
        default:
            return state
    }
}
 
/**
* 这个函数也是(更次级的)Reducer组合,如何细分Reducer取决于实现的需要
*
* 处理多种Action,一个Reducer可以处理1-N种Action。combineReducers并不能阻止多个Reducer处理同一种Action,尽管比较古怪
*/
function posts( state = { isFetching: false, didInvalidate: false, items: [] }, action ) {
    switch ( action.type ) {
        // 点击刷新按钮,设置数据为过期
        case INVALIDATE_SUBREDDIT:
            return Object.assign( {}, state, {
                didInvalidate: true
            } )
        // 发起网络请求,设置正在请求标记。同时取消数据过期标记
        case REQUEST_POSTS:
            return Object.assign( {}, state, {
                isFetching: true,
                didInvalidate: false
            } )
        // 收到网络响应,取消正在请求标记、数据过期标记。并把新闻条目合并到状态树
        case RECEIVE_POSTS:
            return Object.assign( {}, state, {
                isFetching: false,
                didInvalidate: false,
                items: action.posts,
                lastUpdated: action.receivedAt
            } )
        default:
            return state
    }
}
 
function postsBySubreddit( state = {}, action ) {
    switch ( action.type ) {
        case INVALIDATE_SUBREDDIT:
        case RECEIVE_POSTS:
        case REQUEST_POSTS:
            return Object.assign( {}, state, {
                // 这种ES6语法让代码简洁
                [action.subreddit]: posts( state[ action.subreddit ], action )
            } )
        default:
            return state
    }
}
 
const rootReducer = combineReducers( {
    postsBySubreddit,
    selectedSubreddit
} )
 
export default rootReducer
异步Action Creators

如何让前面创建的同步Action与网络请求一起工作呢?Redux提供的标准解决方案是Thunk这个中间件(Middleware)。关于中间件的细节后面的章节会讨论,这里仅需要知道:使用中间件后,Action Creator可以返回一个函数,而不是Action对象本身。执行下面的命令把Thunk添加为当前工程的依赖:

Shell
1
npm install redux-thunk -save

如果Action Creator返回一个函数,则该函数会被Redux-Thunk这个中间件执行。 该函数:

  1. 不需要是纯函数,可以具有边际效应,例如执行异步API
  2. 可以调用dispatch派发其它Action
actions.js
JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
/**
* 第一个Thunk Action Creator
* 尽管和我们以前编写的Action Creator很不一样,其返回值仍然可以这样派发:
*     store.dispatch(fetchPosts('reactjs'))
*/
export function fetchPosts(subreddit) {
 
    // Thunk将dispatch方法作为入参传递给Creator返回的函数
    // 此函数本身因而具有派发Action的能力
 
    return function (dispatch) {
 
        // 第一次派发,应用状态改变,提示正在请求数据
        dispatch(requestPosts(subreddit))
 
        // 此函数可以具有返回值,此返回值将作为store.dispatch(fetchPosts(*))的返回值
 
        // 执行异步请求,这里,我们返回一个Promise
        // 此返回值不是Thunk限定的,可以根据自己的需要返回某个值、或者不返回值
        return fetch(`http://www.reddit.com/r/${subreddit}.json`)
            .then(response => response.json()) // 返回一个立即解析的对象
            .then(json =>
                // 第二次派发,根据服务器响应更新State
                // 你可以进行任意次数的派发
                dispatch(receivePosts(subreddit, json))
            )
        // 真实应用中,你可能需要对Promise做异常处理
    }
}

上面的代码使用了fetch API,这是一个尚未被广泛支持的、用于代替XMLHttpRequest的API。可以通过垫片库获得此API:

JavaScript
1
import fetch from 'isomorphic-fetch'

isomorphic-fetch在客户端环境会调用whatwg-fetch,在服务器端环境则会调用node-fetch,因此使用它可以方便universal应用的编写。

isomorphic-fetch假设Promise垫片已经存在,某些浏览器尚不能支持Promise,如果你使用Babel,最简单的、启用Promise支持的方法是在入口点代码开头处添加:

JavaScript
1
import 'babel-polyfill'

注意Thunk也支持为函数式Action提供第二个参数——Store.getState方法。这样,你可以根据状态,进行不同的派发操作:

actions.js
JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
function shouldFetchPosts( state, subreddit ) {
    const posts = state.postsBySubreddit[ subreddit ]
    if ( !posts ) {
        return true
    }
    else if ( posts.isFetching ) {
        return false
    }
    else {
        return posts.didInvalidate
    }
}
 
export function fetchPostsIfNeeded( subreddit ) {
    // 在缓存数据可用的情况下避免网络访问
    return ( dispatch, getState ) => {
        if ( shouldFetchPosts( getState(), subreddit ) ) {
            // 在thunk中派发另外一个thunk
            return dispatch( fetchPosts( subreddit ) )
        }
        else {
            // 仍然返回一个Promise,统一处理接口
            return Promise.resolve()
        }
    }
}
 
// 测试代码:
store.dispatch( fetchPostsIfNeeded( 'reactjs' ) ).then( () =>
    console.log( store.getState() )
)
启用中间件

调用applyMiddleware()可以为Store添加中间件:

index.js
JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import thunkMiddleware from 'redux-thunk'
import createLogger from 'redux-logger'
import { createStore, applyMiddleware } from 'redux'
import { selectSubreddit, fetchPosts } from './actions'
import rootReducer from './reducers'
 
const loggerMiddleware = createLogger()
 
const store = createStore(
    rootReducer,
    // 中间件链条
    applyMiddleware(
        thunkMiddleware, // 支持dispath()一个函数而非对象
        loggerMiddleware // 用于记录Action
    )
)
 
// 测试派发
store.dispatch( selectSubreddit( 'reactjs' ) )
store.dispatch( fetchPosts( 'reactjs' ) ).then( () =>
    console.log( store.getState() )
)
服务器渲染

异步Action Creator在服务器端渲染时特别便利。你可以创建一个Store,然后派发单个异步Action,此异步Action可以派发其它的异步Actions以获取所有需要的数据。然后,仅仅在Promise返回、完成后(此时Store状态已经更新完毕)才执行渲染。

其它中间件

Thunk并不是唯一支持异步Action的中间件。其它备选方案包括:

  1. redux-promise、 redux-promise-middleware:支持派发Promise而非Function
  2. redux-observable支持派发可监听对象(Observables)
  3. redux-saga,用于构建复杂的异步Action
连接UI

使用同步、还是异步Action,对如何连接Redux到UI没有影响。

异步数据流

不使用中间件的情况下,Redux仅仅支持简单的同步数据流(synchronous data flow)。当调用applyMiddleware应用一个中间件链条后,情况发生改变。

Redux-Thunk、Redux-Promise之类的异步中间件,可以包装dispatch方法,以允许你派发其它东西——函数、Promise等。不同中间件能够理解你dispatch的不同东西,并将其转换为另外一种形式。例如Promise中间件能够理解你派发的Promise,并将其转换为两个异步的Begin/End Actions传递给下一个中间件。

链条中的最后一个中间件,必须派发简单JS对象,因为Redux本身仅仅理解这种对象,Reducers仅能对这种对象进行处理,以更新应用状态。

中间件

在异步Action一章,我们已经使用过一个中间件,它可以增强dispatch方法的功能,使其支持特定规格的函数的派发。

中间件有点类似AOP编程领域的切面。如果你使用过Express、Koa等服务器端库,你可能已经对中间件的概念有所了解。这两个库中,中间件是插入到接收请求框架、生成响应框架之间的代码。这些代码可以完成:添加CORS头、日志记录、压缩等各种工作。中间件最优秀的特性是它们可以形成一个链条,这样在一个工程中你就可以使用多个第三方开发的中间件。

Redux中间件要解决的问题与Express、Koa不同,但是在设计理念和工作方式上是类似的。Redux中间件机制提供一个位于派发Action和Reducer处理Action之间的第三方扩展点。你可以使用中间件机制实现错误报告、日志记录、调用异步API、路由等多种功能。

使用中间件,可以在增强Redux的功能的同时,保持接口不变。

问题:记录日志

我们以老生常谈的日志记录需求为切入点,来讲解中间件是如何引入到Redux中的。

Redux的一个优势是状态变更的可预测性和透明性。每当一个同步的Action被派发,相应的新状态就会被计算和保存。如果能记录应用中所有派发的Action连同其引发的状态改变,错误诊断的效率会提高不少。我们如何实现日志记录呢?

手工记录

最简单的方法就是在每次调用store.dispatch时,手工记录Action和结果状态:

JavaScript
1
2
3
console.log('dispatching', action)
store.dispatch(action)
console.log('next state', store.getState())

这种方法的确有效,但是你需要写很多无价值的代码。

封装dispatch

为了避免每次派发都要写一遍日志记录语句,我们可以对dispatch方法进行简单的封装:

JavaScript
1
2
3
4
5
function dispatchAndLog(store, action) {
    console.log('dispatching', action)
    store.dispatch(action)
    console.log('next state', store.getState())
}

封装了代码量减少了不少,但是每次都需要导入dispatchAndLog函数也比较麻烦

Monkeypatching

Monkey patch这个术语表示在程序运行时对支持系统/软件/框架进行扩展或者修改,并且仅仅影响到正在运行中的程序的补丁。

由于Redux中的Store仅仅是具有几个方法的简单JS对象,我们很容易对其进行Monkey patch:

JavaScript
1
2
3
4
5
6
7
let next = store.dispatch
store.dispatch = function dispatchAndLog(action) {
    console.log('dispatching', action)
    let result = next(action)
    console.log('next state', store.getState())
    return result
}

到这一步为止,我们已经实现应用透明的日志记录功能了。

问题:错误报告

如果除了日志记录,我们需要增加“横切”功能,该怎么办?

例如,应用出现问题时的错误报告也是一种横切功能,我们希望它能够自动的进行而非到处try-catch。监听window.onerror虽然可以进行全局的错误捕获,但是该事件在某些老浏览器上不能提供调用栈信息,不利于问题诊断。如果任何时候,因派发Action而导致错误抛出时,能够收集当前Action、当前状态、调用栈信息,则问题诊断、场景重新将变得很容易。

我们可以像实现日志记录时那样,继续进行Monkeypatching。但是,分离日志记录、错误报告的代码很重要,它们是完全不相关的模块,糅合在一起明显违反SRP原则。

模块化Monkeypatching

为了职责分离,我们简单的把给store打补丁的代码分离到各自的模块中:

JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 模块一
function patchStoreToAddLogging(store) {
    store.dispatch = function dispatchAndLog(action) {/**/}
 
}
 
// 模块二
function patchStoreToAddCrashReporting(store) {
    let next = store.dispatch
    store.dispatch = function dispatchAndReportErrors(action) {
        try {
            return next(action)
        } catch (err) {/**/}
    }
}
 
// 使用
patchStoreToAddLogging(store)
patchStoreToAddCrashReporting(store)
隐藏Monkeypatching

Monkeypatching属于一种Hack手段,替换你想替换的方法——store.dispatch。如果把Monkeypatching隐藏到Redux框架内部,上面两个模块至少可以不用牵涉到如何Hack的细节:

JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 模块一
function logger(store) {
    return function dispatchAndLog(action) {/**/}
}
 
// Redux框架内代码
function applyMiddlewareByMonkeypatching(store, middlewares) {
    middlewares = middlewares.slice()
    middlewares.reverse()
 
    middlewares.forEach(middleware =>
        store.dispatch = middleware(store)
    )
}
// 使用
applyMiddlewareByMonkeypatching(store, [ logger, crashReporter ])

在应用中间件时,后面的中间件总是调用被前一个中间件装饰过的dispatch方法,这是chaining的关键。

移除Monkeypatching

chaining可以通过读写store.dispatch字段来实现,也可以使用另外一种方式——传参:

JavaScript
1
2
3
4
5
6
7
8
9
function logger( store ) {
    // 可能已经被装饰过的store.dispatch方法,通过入参传递
    return function wrapDispatchToAddLogging( next ) {
        return function dispatchAndLog( action ) {
            let result = next( action )
            return result
        }
    }
}

这种嵌套的函数可能让你眼晕,可以该写为等价的ES6箭头函数:

JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const logger = store => next => action => {
    console.log( 'dispatching', action )
    let result = next( action )
    console.log( 'next state', store.getState() )
    return result
}
const crashReporter = store => next => action => {
    try {
        return next( action )
    }
    catch ( err ) {
        console.error( 'Caught an exception!', err )
        Raven.captureException( err, {
            extra: { action, state: store.getState() }
        } )
        throw err
    }
}

这种通过参数来使用装饰后的dispatch方法的chaining,与Redux中间件的实际实现很类似了:

  1. 中间件以next()这一派发函数作为入参,返回一个新的派发函数
  2. 新生成的派发函数,作为后一个中间件的入参传入
  3. 为了方便访问store.getState等方法,因此store示例作为最外层函数的入参传入
实现applyMiddleware

下面的函数是对Redux中间件机制的applyMiddleware函数的简化实现:

JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
function applyMiddleware( store, middlewares ) {
    middlewares = middlewares.slice()
    middlewares.reverse()
 
    let dispatch = store.dispatch
    middlewares.forEach( middleware =>
        // 以传参方式进行Chaining
        dispatch = middleware( store )( dispatch )
    )
 
    return Object.assign( {}, store, { dispatch } )
}

注意,Redux实现的applyMiddleware虽然与上面的代码类似,但是它具有以下改进:

  1. 仅仅Store的部分API暴露给中间件:dispatch、getState
  2. 使用了一些编程技巧,确保你的中间件代码执行一个新的Action派发时,调用的时原始的store.dispatch(action)而非装饰过的next(action)。这意味着新派发的Action会重新遍历中间件链条,包括当前中间件。该逻辑对异步中间件非常重要
  3. 为了防止同一中间件被多次apply,applyMiddleware不直接操控store,而是将其返回值作为参数传递给createStore处理
中间件实例
JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// thunk,添加派发函数的能力
const thunk = store => next => action =>
    typeof action === 'function' ?  action( store.dispatch, store.getState ) : next( action )
 
 
// 支持派发带有promise属性的Action,派发两次:立即派发,promise resolve后,再次派发
const readyStatePromise = store => next => action => {
    if ( !action.promise ) {
        return next( action )
    }
 
    function makeAction( ready, data ) {
        let newAction = Object.assign( {}, action, { ready }, data )
        delete newAction.promise
        return newAction
    }
    // 立即派发,UI可能因此显示一个spin
    next( makeAction( false ) )
    // resolve后,再次派发,处理异步响应
    return action.promise.then(
        result => next( makeAction( true, { result } ) ),
        error => next( makeAction( true, { error } ) )
    )
} 
最佳实践
使用对象展开操作符

Redux的信条是永远不要修改状态,而是创建一个对象替换它。因此,本文的代码大量使用了 Object.assign 调用。该调用语法非常啰嗦,推荐使用ES6的对象展开操作符( ... )代替之:

JavaScript
1
2
3
4
// Object.assign调用
return Object.assign( {}, state, { visibilityFilter: action.filter } )
// 等价的展开操作符语法
return { ...state, visibilityFilter: action.filter }

对象展开操作符仍然属于ECMAScript的Stage 2提议,为了得到运行环境的支持,你可以使用Babel之类的编译器。首先安装babel-plugin-transform-object-rest-spread插件,然后修改Babel配置文件:

.babelrc
JavaScript
1
2
3
4
{
    "presets": ["es2015"],
    "plugins": ["transform-object-rest-spread"]
}
隔离子应用

考虑一个大型的应用,例如那种传统的管理信息系统:几十个菜单项的左侧菜单,右侧窗口显示一个“当前”模块。子模块之间完全相互独立,不共享任何状态或者Action。如果用React实现这种应用,可以基于多个隔离的Redux子应用:

JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
import React, { Component } from 'react'
import SubApp from './subapp'
 
class BigApp extends Component {
    render() {
        return (
            <div>
                <SubApp />
                <SubApp />
                <SubApp />
            </div>
        )
    }
}

SubApp对应上述大型应用的“模块”,它们之间相互独立,因而也不会共享同一个Redux Store实例。

除了大型MIS系统,某些仪表盘、产品门户之类的应用,也可以考虑用隔离子应用的方式开发。从团队角度来说,如果依据产品或者特性划分开发小组,小组甚至可以使用不同的技术栈,也可以考虑隔离子应用的方式。

对于普通的面向大众的Web应用,最好不要使用隔离子应用,而是利用Redux Reducer组合。

隐藏子应用细节

如果仅仅某些子应用是基于Redux实现的,而且希望将Redux作为它的实现细节来隐藏,可以创建React组件,并把Store的创建、连接封装在其中:

JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import React, { Component } from 'react'
import { Provider } from 'react-redux'
import reducer from './reducers'
import App from './App'
 
class SubApp extends Component {
    constructor( props ) {
        super( props )
        // 在组件内部创建Store
        this.store = createStore( reducer )
    }
 
    render() {
        return (
            // Provider让组件子树关联一个Redux Store
            <Provider store={this.store}>
                <App />
            </Provider>
        )
    }
} 
← Flux架构简介
React Router学习笔记 →

Leave a Reply Cancel reply

Your email address will not be published. Required fields are marked *

You may use these HTML tags and attributes: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code class="" title="" data-url=""> <del datetime=""> <em> <i> <q cite=""> <strike> <strong> <pre class="" title="" data-url=""> <span class="" title="" data-url="">

Related Posts

  • 使用Babel进行JS转码
  • 基于Kurento搭建WebRTC服务器
  • ExtJS 4的事件系统
  • Flux架构简介
  • 基于AngularJS开发Web应用

Recent Posts

  • Investigating and Solving the Issue of Failed Certificate Request with ZeroSSL and Cert-Manager
  • A Comprehensive Study of Kotlin for Java Developers
  • 背诵营笔记
  • 利用LangChain和语言模型交互
  • 享学营笔记
ABOUT ME

汪震 | Alex Wong

江苏淮安人,现居北京。目前供职于腾讯云,专注容器方向。

GitHub:gmemcc

Git:git.gmem.cc

Email:gmemjunk@gmem.cc@me.com

ABOUT GMEM

绿色记忆是我的个人网站,域名gmem.cc中G是Green的简写,MEM是Memory的简写,CC则是我的小天使彩彩名字的简写。

我在这里记录自己的工作与生活,同时和大家分享一些编程方面的知识。

GMEM HISTORY
v2.00:微风
v1.03:单车旅行
v1.02:夏日版
v1.01:未完成
v0.10:彩虹天堂
v0.01:阳光海岸
MIRROR INFO
Meta
  • Log in
  • Entries RSS
  • Comments RSS
  • WordPress.org
Recent Posts
  • Investigating and Solving the Issue of Failed Certificate Request with ZeroSSL and Cert-Manager
    In this blog post, I will walk ...
  • A Comprehensive Study of Kotlin for Java Developers
    Introduction Purpose of the Study Understanding the Mo ...
  • 背诵营笔记
    Day 1 Find Your Greatness 原文 Greatness. It’s just ...
  • 利用LangChain和语言模型交互
    LangChain是什么 从名字上可以看出来,LangChain可以用来构建自然语言处理能力的链条。它是一个库 ...
  • 享学营笔记
    Unit 1 At home Lesson 1 In the ...
  • K8S集群跨云迁移
    要将K8S集群从一个云服务商迁移到另外一个,需要解决以下问题: 各种K8S资源的迁移 工作负载所挂载的数 ...
  • Terraform快速参考
    简介 Terraform用于实现基础设施即代码(infrastructure as code)—— 通过代码( ...
  • 草缸2021
    经过四个多月的努力,我的小小荷兰景到达极致了状态。

  • 编写Kubernetes风格的APIServer
    背景 前段时间接到一个需求做一个工具,工具将在K8S中运行。需求很适合用控制器模式实现,很自然的就基于kube ...
  • 记录一次KeyDB缓慢的定位过程
    环境说明 运行环境 这个问题出现在一套搭建在虚拟机上的Kubernetes 1.18集群上。集群有三个节点: ...
  • eBPF学习笔记
    简介 BPF,即Berkeley Packet Filter,是一个古老的网络封包过滤机制。它允许从用户空间注 ...
  • IPVS模式下ClusterIP泄露宿主机端口的问题
    问题 在一个启用了IPVS模式kube-proxy的K8S集群中,运行着一个Docker Registry服务 ...
  • 念爷爷
      今天是爷爷的头七,十二月七日、阴历十月廿三中午,老人家与世长辞。   九月初,回家看望刚动完手术的爸爸,发

  • 6 杨梅坑

  • liuhuashan
    深圳人才公园的网红景点 —— 流花山

  • 1 2020年10月拈花湾

  • 内核缺陷触发的NodePort服务63秒延迟问题
    现象 我们有一个新创建的TKE 1.3.0集群,使用基于Galaxy + Flannel(VXLAN模式)的容 ...
  • Galaxy学习笔记
    简介 Galaxy是TKEStack的一个网络组件,支持为TKE集群提供Overlay/Underlay容器网 ...
TOPLINKS
  • Zitahli's blue 91 people like this
  • 梦中的婚礼 64 people like this
  • 汪静好 61 people like this
  • 那年我一岁 36 people like this
  • 为了爱 28 people like this
  • 小绿彩 26 people like this
  • 杨梅坑 6 people like this
  • 亚龙湾之旅 1 people like this
  • 汪昌博 people like this
  • 彩虹姐姐的笑脸 24 people like this
  • 2013年11月香山 10 people like this
  • 2013年7月秦皇岛 6 people like this
  • 2013年6月蓟县盘山 5 people like this
  • 2013年2月梅花山 2 people like this
  • 2013年淮阴自贡迎春灯会 3 people like this
  • 2012年镇江金山游 1 people like this
  • 2012年徽杭古道 9 people like this
  • 2011年清明节后扬州行 1 people like this
  • 2008年十一云龙公园 5 people like this
  • 2008年之秋忆 7 people like this
  • 老照片 13 people like this
  • 火一样的六月 16 people like this
  • 发黄的相片 3 people like this
  • Cesium学习笔记 90 people like this
  • IntelliJ IDEA知识集锦 59 people like this
  • 基于Kurento搭建WebRTC服务器 38 people like this
  • Bazel学习笔记 37 people like this
  • PhoneGap学习笔记 32 people like this
  • NaCl学习笔记 32 people like this
  • 使用Oracle Java Mission Control监控JVM运行状态 29 people like this
  • Ceph学习笔记 27 people like this
  • 基于Calico的CNI 27 people like this
  • Three.js学习笔记 24 people like this
Tag Cloud
ActiveMQ AspectJ CDT Ceph Chrome CNI Command Cordova Coroutine CXF Cygwin DNS Docker eBPF Eclipse ExtJS F7 FAQ Groovy Hibernate HTTP IntelliJ IO编程 IPVS JacksonJSON JMS JSON JVM K8S kernel LB libvirt Linux知识 Linux编程 LOG Maven MinGW Mock Monitoring Multimedia MVC MySQL netfs Netty Nginx NIO Node.js NoSQL Oracle PDT PHP Redis RPC Scheduler ServiceMesh SNMP Spring SSL svn Tomcat TSDB Ubuntu WebGL WebRTC WebService WebSocket wxWidgets XDebug XML XPath XRM ZooKeeper 亚龙湾 单元测试 学习笔记 实时处理 并发编程 彩姐 性能剖析 性能调优 文本处理 新特性 架构模式 系统编程 网络编程 视频监控 设计模式 远程调试 配置文件 齐塔莉
Recent Comments
  • qg on Istio中的透明代理问题
  • heao on 基于本地gRPC的Go插件系统
  • 黄豆豆 on Ginkgo学习笔记
  • cloud on OpenStack学习笔记
  • 5dragoncon on Cilium学习笔记
  • Archeb on 重温iptables
  • C/C++编程:WebSocketpp(Linux + Clion + boostAsio) – 源码巴士 on 基于C/C++的WebSocket库
  • jerbin on eBPF学习笔记
  • point on Istio中的透明代理问题
  • G on Istio中的透明代理问题
  • 绿色记忆:Go语言单元测试和仿冒 on Ginkgo学习笔记
  • point on Istio中的透明代理问题
  • 【Maven】maven插件开发实战 – IT汇 on Maven插件开发
  • chenlx on eBPF学习笔记
  • Alex on eBPF学习笔记
  • CFC4N on eBPF学习笔记
  • 李运田 on 念爷爷
  • yongman on 记录一次KeyDB缓慢的定位过程
  • Alex on Istio中的透明代理问题
  • will on Istio中的透明代理问题
  • will on Istio中的透明代理问题
  • haolipeng on 基于本地gRPC的Go插件系统
  • 吴杰 on 基于C/C++的WebSocket库
©2005-2025 Gmem.cc | Powered by WordPress | 京ICP备18007345号-2