Redux学习笔记
Redux是一个存储JavaScript应用状态的容器,它是更早出现的Flux架构的一种变体,Redux弱化了Dispatcher并将其职责转移到全局唯一的Store身上。官网称Redux为可预测的(predictable)——一个操作(Action)会引发应用状态怎么样改变是确定的,这应该是声明在自己对MVVM流派的反对态度。
导致Redux之类的框架出现的原因,主要是越来越复杂的单页面JavaScript应用。开发人员需要维护越来越多的“状态”,这些状态包括:
- 业务数据:服务器响应、缓存数据、客户端新创建的数据
- UI状态:当前路由、选中的Tab页…
手工管理这些状态是困难的。模型更新模型、视图更新模型、模型更新视图…会让你无法判断系统为什么处于现在的状态,进而导致BUG难以重现、调试困难。新的前端开发需求则加剧了这种困难,这些需求包括服务器端渲染、路由前数据抓取、优化的数据更新。
通过限制状态何时、如何被更新,Redux让应用状态的变更变得可预测。这些限制体现在Redux的三个理念中。
Redux提供的API非常少,实质上Redux并非单一的框架,它更是一套约定。这套约定规定了函数的规格、函数之间应该如何交互。使用Redux时大部分时间你都在编写函数。
Redux支持Web客户端、Web服务器、甚至Native环境,易于测试。尽管Redux经常和React一起使用,但是配合其它视图层的库也是可以的。
下面列出可以引入Redux的应用场景:
- 视图需要从多个数据源获得数据
- 不同视图需要共享状态
- 大量服务器交互,使用Websocket
- Redux没有Flux架构中的Dispatcher角色
- Redux只有唯一的Store,而Flux可以具有很多个。当应用程序规模增大时,你不能增加Store,而应把Reducer切分为多个小的Reducer,这些小的Reducer独立的操控状态树的一部分
Redux遵循三个基本的理念:
- Single source of truth:整个应用程序的状态,并存放在单个Store持有的对象树中
- State is readonly:修改对象树的唯一办法是触发一个Action,Action是描述所发生事情(用户、定时器操作或者服务器数据到达)的简单对象
- Changes are made with pure function:为了基于Action改变状态树,你必须编写纯函数风格的Reducers。Reducer可以接收当前状态 + Action,并返回一个新状态
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支持:
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。
Action是应用程序发送给Store的数据的载荷(Payloads)。对于Store来说,它是唯一的信息来源。要将Action送给Store处理,需要调用 store.dispatch() 方法。
Action是简单JS对象,必须具有一个type属性,表示Action的类型。type属性通常都是字符串常量,应用程序规模扩大后,你应当把这些type常量独立到模块中:
1 |
import { ADD_TODO, REMOVE_TODO } from '../actionTypes' |
除了type以外Action还需要什么属性,完全取决于你的需求。
Redux推荐遵循Flux标准Action(FSA)写法:
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规定:
- Action必须是简单JS对象,且必须具有type属性
- Action可以具有error、payload、meta属性
- 其它未提及的属性均不允许出现
这是一类用于创建Action的助手类函数,让Action的发出者免于关注Action的类型:
1 2 3 4 5 6 |
function addTodo( text ) { return { type: ADD_TODO, text } } |
在典型的Flux架构中,Action Creator负责触发dispatch()的调用。Redux却不是这样,你可以创建bound action creator来执行dispatch:
1 |
const boundAddTodo = (text) => dispatch(addTodo(text)) |
你可以调用 bindActionCreators() 将多个Action Creator绑定到dispatch()调用。
Action描述发生了什么,而Reducer则描述应用状态应该如何依据Action而改变,它根据前一个状态 + Action推导出下一个状态:
1 |
(previousState, action) => newState |
之所以叫做Reducer,是因为这种函数可以传递给数组的reduce函数:
1 |
Array.prototype.reduce(reducer, ?initialValue); |
Reducer必须基于函数式编程范式编写,必须实现为纯函数:
- immutable:不要修改参数
- 不要调用任何具有边际效应(side effects )的API,例如路由转换
- 不要调用任何非纯函数,例如Date.now()
- 对于相同的输入,总是返回相同的结果
提醒事项应用中最初的Reducer实现如下:
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:
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子句。从上面的代码可以看到,为了保证纯函数的特征,需要编写很多数据拷贝代码。你可以使用
- immutability-helper:改变对象的拷贝,保持源对象不变
- updeep:以声明式/不变性的方法,更新嵌套的冻结对象、数组
- Immutable:不变的JavaScript集合
之类支持深层更新(deep update)的库,以降低工作量同时提供安全性(防止意外操作导致纯函数特征破坏)。
Case语句越来越多以后,上面的Reducer会变得又长又臭,难以读懂。而且,尽管SET_VISIBILITY_FILTER和其它Action操作的数据完全没有交集,它们都被迫处理整个状态树。
我们可以对上面的代码进行重构:
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 } } |
重构以后的代码,具有以下特点:
- 原先Reducer的体积变小了,部分工作委托给与Reducer行为类似的子Reducer处理
- 子Reducer仅仅处理它关注的状态子树
- 父Reducer负责把状态子树抽取出来供子Reducer处理,并把后者返回的新状态子树拼接回去
这段重构,蕴含了Redux应用的一个基础模式:Reducer组合(composition) 。
由于子Reducer仅仅处理它关注的那个子树,上面的父Reducer中switch语句可以安全的移除:
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() ,可以更进一步简化上一段代码:
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,Action才可以传递给Reducer。Store具有以下职责:
- 持有应用程序的状态
- 允许代码通过 getState() 方法访问状态
- 允许代码通过 dispatch(action) 派发新的Action,并交由Reducer处理
- 允许代码通过 subscribe(listener) 注册监听器,该方法的返回值用于解除监听器。监听器在状态改变后自动调用
记住:整个应用程序(精确来说是页面,如果一个系统由多个页面组成,就对应这里的多个应用程序)只有一个Store。下面的代码示例了如何创建Store:
1 2 3 |
import { createStore } from 'redux'; import todoApp from './reducers'; let store = createStore(todoApp); |
你可以提供第二个可选参数,为状态赋初值:
1 |
let store = createStore(todoApp, window.STATE_FROM_SERVER); |
在编写代码之前应该好好考虑Store中存储的应用程序状态的结构,以“提醒事项”应用为例,状态包括两类不同的东西:
- UI状态:过滤规则,是否显示已经完成的提醒事项
- 数据:真正的提醒事项的列表
你需要把这两类东西都放在Store中,但是注意将它们分开:
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)的存储数据,避免嵌套,就像数据库那样:
- 使用ID作为key来引用单条数据,例如: todosById: { id -> todo }
- 使用IDs来引用数据的列表,例如: todos: array<id>
- 数据之间有关联时,通过ID/IDs引用,而不是嵌套关联对象
调用 store.dispatch() 即可分发一个Action,甚至不需要View层的参与。这让Redux应用容易自动化的测试。
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中数据的生命周期通常是:
- store.dispatch(action) 被调用,新数据作为Action的载荷被发送
- Store调用Reducer函数,处理Action
- 根Reducer可以合并多个子Reducer的输出,形成完整的状态树
- 根Reducer返回状态树给Store,由后者保存
- Store发布事件,所有订阅者获得最新状态的通知
Redux可以与很多其它框架/库联用,包括React、Angular、Ember、jQuery。对于React这样的库来说,Redux特别适用,因为React将UI表示为关于状态的函数,而Redux正好能够很好的管理状态。
本章以提醒事项应用为例来阐述Redux和React的整合。
Redux的React绑定是独立的项目,可以执行下面的命令添加到当前工程中:
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状态树多少有些对应关系。还是以提醒事项为例,展示组件可以包括:
- TodoList 显示所有可见的提醒事项的列表
- Todo 单个提醒事项条目
- Link 带有回调的链接,点击后其onClick被调用
- Footer 切换可以看到全部还是仅未完成的提醒事项
- App 根组件
为了把展示组件连接到Redux,我们需要一些容器组件:
- VisibleTodoList:根据当前的过滤器设置,过滤不显示的提醒事项,并渲染TodoList
- FilterLink:根据当前过滤器设置,设置Link的样式和点击事件处理函数
某些组件很难严格的划分到上面两类中。例如,某些时候表单字段和功能是紧耦合在一起的:
- AddTodo,一个附带了按钮的输入框
我们可以把AddTodo拆分为两个组件,但是由于此组件非常小,混合展示、逻辑在其中也无可厚非。如果该组件以后变大了可以考虑重构。
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 |
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 |
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 |
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 |
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函数时,你需要定义:
- 一个名为 mapStateToProps 函数。该函数指明如何将当前Redux状态转换为当前容器包装的展示组件的props。该函数接收两个参数,第一个是当前Redux Store的状态,第二个是传递给容器(而非它包装的展示组件)的属性集
- 一个名为 mapDispatchToProps 的函数,该函数接受Store.dispatch、展示组件的props作为入参,返回展示层组件所需要的那些事件处理函数。这些事件处理函数作为props的组成部分
准备好这两个函数之后,将其作为入参传递给connect函数,即可获得自动创建的容器组件。
链接的容器:
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 |
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的全部必要信息。
某些情况下展示和逻辑是自然耦合在一起的,并且组件规模很小,展示、容器组件可以合二为一:
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 |
上节中AddTodo组件会在运行时自动被Redux注入dispatch方法,这意味着你必须把Store实例告诉给Redux。这可以通过Provider组件完成:
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相关。Action Creators:
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:
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 |
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 |
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库添加了路由功能,联合Redux使用该React Router时,Redux作为应用状态、数据的唯一依据(Source of truth),而React Router则是URL的唯一依据。大部分时间里,Redux和React Router可以相互隔离不相关,除非你希望时间旅行( time travel)、rewind触发URL改变的Action。
在集成React Router之前,我们需要配置开发服务器。开发服务器可能对React Router的路由配置一无所知。例如当你访问/todos时,服务器应该知道要访问的页面是index.html——因为这是单页面应用程序。
如果你使用create-react-app,Fallback URL不需要手工配置。
如果你使用Express作为服务器,可以这样配置:
1 2 3 |
app.get( '/*', ( req, res ) => { res.sendfile( path.join( __dirname, 'index.html' ) ) } ) |
如果你使用Webpack提供的开发服务器,可以这样配置:
1 2 3 |
devServer: { historyApiFallback: true, } |
根元素内容如下:
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属性:
1 |
<Router history={browserHistory}>...</Router> |
除非需要支持IE9之类的老旧浏览器,你总是应该使用browserHistory。
React Router内置了 <Link/> 组件,使用它可以在应用内自由导航。在提醒实现的例子中,我们可以用此组件代替自己实现的Link:
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; |
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是不够的,容器组件的代码也要一并修改,这样UI才能与URL同步更新。首先修改:
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组件:
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是一个小巧的库,用于创建可备忘的(memoized)、可组合(composable)的选择器函数。这些选择器可以用来有效的从Redux Store中计算衍生数据( derived data)。
回顾一下提醒事项的例子,容器VisibleTodoList的mapStateToProps函数调用助手函数getVisibleTodos,来计算需要渲染的todos。这种方法可行,但是有一个缺点:组件每次被更新时,todos都需要重新计算。如果状态树非常大、或者算法本身复杂,那么这种反复计算可能引起性能问题。
Reselect可以帮助避免不必要的重新计算。
我们可以使用一个备忘选择器,来替换提醒事项中的getVisibleTodos函数。此选择器会仅仅在state.todos、state.visibilityFilter之一发生改变时,才触发重新计算。而不是在应用的任何无关部分发生变化时都去反复的计算。
要创建备忘选择器,可以调用createSelector函数。该函数接受一个输入选择器的数组、一个转换函数作为入参。如果Redux状态改变导致输入选择器的值改变,则转换函数会被自动调用并返回计算结果;反之,仅仅返回先前计算好的结果。
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可以实现复杂的备忘选择器——仅当某个子选择器的值改变,其父选择器才重新计算,并且递归的触发祖代选择器的重新计算。
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-Redux,则可以在mapStateToProps函数中直接调用选择器:
1 2 3 4 5 6 |
const mapStateToProps = (state) => { return { // 像普通函数那样调用 todos: getVisibleTodos(state) } } |
前面我们实现的选择器,入参是Redux状态。实际上你可以声明任意个参数:
1 2 3 4 5 |
const mapStateToProps = ( state, props ) => { return { todos: getVisibleTodos( state, props ) } } |
我们延伸一下提醒事项的需求,现在需要展示三个独立的列表。 代码修改如下:
1 2 3 4 5 6 7 |
const App = () => ( <div> <VisibleTodoList listId="1"/> <VisibleTodoList listId="2"/> <VisibleTodoList listId="3"/> </div> ) |
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属性以确定使用哪个提醒事项列表。
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 |
基于以上代码,无法实现“备忘”目的,因为:
- 三个 VisibleTodoList组件共享了同一选择器getVisibleTodos
- 每当应用程序状态改变后,getVisibleTodos函数的第二参数会以1、2、3依次调用一遍。这导致输入选择器getTodos结果总是变化,因而getVisibleTodos总是需要重新计算
那么,如何让同一组件的多个实例使用不同的选择器实例,以便缓存不会相互干扰呢?
通常mapStateToProps函数的返回值应该是一个对象,该对象作为展示组件的属性使用。但是从React-Redux 4.3.0开始,此函数可以返回一个函数。
如果mapStateToProps返回一个函数,那么在Redux状态变化时,对于每一个容器组件实例,该函数会被调用一次,以计算展示组件的属性。
这样我们就获得创建不同选择器实例的机会了:
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被派发,Store立即调用Reducer处理它,应用状态也就立即更新。实际应用中Action常需要触发服务器请求,因而应用状态必须异步更新。
调用异步API时,有两个关键时间点:发起API调用的那一刻;获得API调用结果的(或者超时的)那一刻。通常,这两个时刻都需要改变应用的状态。例如,发起异步调用时可能显示一个遮罩“正在获取数据”,获得调用结果时则需要显示处理后的结果。要基于Redux来处理这种异步调用,你需要派发多个被Reducer同步处理的普通的Action:
- 通知Redux请求已经开始的Action。Reducer通常会在Store中设置isFetching标记,以便UI组件显示一个“正在加载”的提示(Spinner)
- 通知Redux请求处理成功的Action。Reducer可能把新获得的数据合并到Store中并重置isFetching标记
- 通知Redux请求处理失败的Action。Reducer通常重置isFetching标记,有可能把失败原因存储到Store中供UI组件使用
Action类型和载荷属性命名,通常使用如下风格:
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:
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:
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:
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,真实应用中这通常是需要的。
前面已经提到过,在动手编写代码之前规划好应用程序的状态很重要。对于异步代码来说,你需要维护更多的状态。
新闻订阅的状态树结构如下:
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)的状态树,避免嵌套实体。嵌套实体带来的数据冗余让状态同步变得很困难,应该从设计的初期就避免。
如果引入新闻作者等额外实体,或者新闻可以归属于多个栏目,特别是应用需要修改作者、新闻等实体,可以修改状态树为:
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的Reducers。
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与网络请求一起工作呢?Redux提供的标准解决方案是Thunk这个中间件(Middleware)。关于中间件的细节后面的章节会讨论,这里仅需要知道:使用中间件后,Action Creator可以返回一个函数,而不是Action对象本身。执行下面的命令把Thunk添加为当前工程的依赖:
1 |
npm install redux-thunk -save |
如果Action Creator返回一个函数,则该函数会被Redux-Thunk这个中间件执行。 该函数:
- 不需要是纯函数,可以具有边际效应,例如执行异步API
- 可以调用dispatch派发其它Action
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:
1 |
import fetch from 'isomorphic-fetch' |
isomorphic-fetch在客户端环境会调用whatwg-fetch,在服务器端环境则会调用node-fetch,因此使用它可以方便universal应用的编写。
isomorphic-fetch假设Promise垫片已经存在,某些浏览器尚不能支持Promise,如果你使用Babel,最简单的、启用Promise支持的方法是在入口点代码开头处添加:
1 |
import 'babel-polyfill' |
注意Thunk也支持为函数式Action提供第二个参数——Store.getState方法。这样,你可以根据状态,进行不同的派发操作:
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添加中间件:
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的中间件。其它备选方案包括:
- redux-promise、 redux-promise-middleware:支持派发Promise而非Function
- redux-observable支持派发可监听对象(Observables)
- redux-saga,用于构建复杂的异步Action
使用同步、还是异步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和结果状态:
1 2 3 |
console.log('dispatching', action) store.dispatch(action) console.log('next state', store.getState()) |
这种方法的确有效,但是你需要写很多无价值的代码。
为了避免每次派发都要写一遍日志记录语句,我们可以对dispatch方法进行简单的封装:
1 2 3 4 5 |
function dispatchAndLog(store, action) { console.log('dispatching', action) store.dispatch(action) console.log('next state', store.getState()) } |
封装了代码量减少了不少,但是每次都需要导入dispatchAndLog函数也比较麻烦
Monkey patch这个术语表示在程序运行时对支持系统/软件/框架进行扩展或者修改,并且仅仅影响到正在运行中的程序的补丁。
由于Redux中的Store仅仅是具有几个方法的简单JS对象,我们很容易对其进行Monkey patch:
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原则。
为了职责分离,我们简单的把给store打补丁的代码分离到各自的模块中:
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属于一种Hack手段,替换你想替换的方法——store.dispatch。如果把Monkeypatching隐藏到Redux框架内部,上面两个模块至少可以不用牵涉到如何Hack的细节:
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的关键。
chaining可以通过读写store.dispatch字段来实现,也可以使用另外一种方式——传参:
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箭头函数:
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中间件的实际实现很类似了:
- 中间件以next()这一派发函数作为入参,返回一个新的派发函数
- 新生成的派发函数,作为后一个中间件的入参传入
- 为了方便访问store.getState等方法,因此store示例作为最外层函数的入参传入
下面的函数是对Redux中间件机制的applyMiddleware函数的简化实现:
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虽然与上面的代码类似,但是它具有以下改进:
- 仅仅Store的部分API暴露给中间件:dispatch、getState
- 使用了一些编程技巧,确保你的中间件代码执行一个新的Action派发时,调用的时原始的store.dispatch(action)而非装饰过的next(action)。这意味着新派发的Action会重新遍历中间件链条,包括当前中间件。该逻辑对异步中间件非常重要
- 为了防止同一中间件被多次apply,applyMiddleware不直接操控store,而是将其返回值作为参数传递给createStore处理
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的对象展开操作符( ... )代替之:
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配置文件:
1 2 3 4 |
{ "presets": ["es2015"], "plugins": ["transform-object-rest-spread"] } |
考虑一个大型的应用,例如那种传统的管理信息系统:几十个菜单项的左侧菜单,右侧窗口显示一个“当前”模块。子模块之间完全相互独立,不共享任何状态或者Action。如果用React实现这种应用,可以基于多个隔离的Redux子应用:
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的创建、连接封装在其中:
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> ) } } |
Leave a Reply