从Flux到Redux
Flux
在MVC( Model-View-Controller)的世界里,React相当于V(也就是View)的部分,只涉及页面的渲染,一旦涉及应用的数据管理部分,还是交给Model和Controller。
MVC框架提出的数据流很理想,用户请求先到达Controller,由Controller调用Model获得数据,然后把数据交给View,但是,在实际框架实现中,总是允许View和Model可以直接通信:
- 服务器端MVC框架往往就是每个请求就只在Controller-Model-View三者之间走一圈,结果就返回给浏览器去渲染或者其他处理了,然后这个请求生命周期的Controller-Model-View就可以回收销毁了,这是一个严格意义的单向数据流;
- 对于浏览器端MVC框架,存在用户的交互处理,界面渲染出来之后,Model和View依然存在于浏览器中,这时候就会诱惑开发者为了简便,让现存的Model和View直接对话。
对于MVC框架,为了让数据流可控,Controller应该是中心,当View要传递消息给Model时,应该调用Controller的方法,同样,当Model要更新View时,也应该通过Controller引发新的渲染。
Flux的特点:更严格的数据流控制。
一个Flux应用包含四个部分:
- Dispatcher (MVC-Controller),处理动作分发,维持Store之间的依赖关系;
- Store (MVC-Model),负责存储数据和处理数据相关逻辑;
- Action (对应给MVC框架的用户请求),驱动Dispatcher的JavaScript对象;
- View (MVC-View),视图部分,负责显示用户界面。
当需要扩充应用所能处理的“请求”时,MVC方法需要增加新的Controller,而Flux则是增加新的Action。
创建一个Flux应用
1、Dispatcher
// src/AppDispatcher.js
import {Dispatcher} from 'flux';
export default new Dispatcher();
// Dispatcher存在的作用是用来派发action
2、action
action代表一个“动作”,是一个普通的JavaScript对象,代表一个动作的纯数据。action对象不自带方法,就是纯粹的数据。
// 定义action通常需要两个文件,
// ActionTypes定义action的类型,
// Actions定义action的构造函数(也称为action Creator)。
// 分成两个文件的主要原因是在Store中会根据action类型做不同操作,
// 也就有单独导入action类型的需要。
// src/ActionTypes.js
export const INCREMENT = 'increment';
export const DECREMENT = 'decrement';
// src/Actions.js
// 出于业界习惯,这个文件被命名为Actions.js,
// 但是里面定义的并不是action对象本身,
// 而是能够产生并派发action对象的函数。
import * as ActionTypes from './ActionTypes.js';
import AppDispatcher from './AppDispatcher.js';
export const increment = (counterCaption) => {
AppDispatcher.dispatch({
// action对象必须有一个字符串类型的type字段,代表这个action对象的类型。
type: ActionTypes.INCREMENT,
counterCaption: counterCaption
});
};
export const decrement = (counterCaption) => {
AppDispatcher.dispatch({
type: ActionTypes.DECREMENT,
counterCaption: counterCaption
});
};
// Actions.js导出两个action构造函数increment和decrement,
// 当这两个函数被调用的时候,创造了对应的action对象,
// 并立即通过AppDispatcher.dispatch函数派发出去。
3、Store
Store是一个对象,存储应用状态,同时还要接受Dispatcher派发的动作,根据动作来决定是否要更新应用状态。
// src/stores/CounterStore.js
import AppDispatcher from '../AppDispatcher.js';
import * as ActionTypes from '../ActionTypes.js';
import {EventEmitter} from 'events';
const CHANGE_EVENT = 'changed';
const counterValues = {
'First': 0,
'Second': 10,
'Third': 30
};
// 当Store的状态发生变化的时候,需要通知应用的其他部分(View)做必要的响应。
// 应该用消息的方式建立Store和View的联系。
// 因此我们让CounterStore扩展了EventEmitter.prototype,
// 等于让CounterStore成了EventEmitter对象。
// 一个EventEmitter实例对象支持下列相关函数:
// 1.emit(事件名称),广播一个特定事件;
// 2.on(事件名称, 处理函数),增加一个挂在这个EventEmitter对象特定事件上的处理函数;
// 3.removeListener(事件名称, 处理函数),和on相反,删除挂在这个EventEmitter对象特定事件上的处理函数。
// CounterStore对象的emitChange、addChangeListener和removeChangeListener函数,
// 就是利用EventEmitter上述的三个函数完成对CounterStore状态更新的广播、添加监听函数和删除监听函数等操作。
const CounterStore = Object.assign({}, EventEmitter.prototype, {
// CounterStore还提供一个getCounterValues函数,
// 用于让应用中其他模块可以读取当前的计数值,
// 当前的计数值存储在文件模块级的变量counterValues中。
getCounterValues: function(){
// 严格来说,getCountervalue这样的getter函数应该返回一个不可变(Immutable)数据,
// 这样调用者即使通过getCounterValues获得了当前计数值对象,
// 也不能够修改这个对象从而扰乱其他代码的使用。
return counterValues;
// 为简单起见,这里我们并不使用Immutable。
},
emitChange: function(){
this.emit(CHANGE_EVENT);
},
addChangeListener: function(callback){
this.on(CHANGE_EVENT, callback);
},
removeChangeListener: function(callback){
this.removeChangeListener(CHANGE_EVENT, callback);
}
});
// 上面实现的Store只有注册到Dispatcher实例上才能真正发挥作用。
// 当通过register函数把一个回调函数注册到Dispatcher之后,
// 所有派发给Dispatcher的action对象,都会传递到这个回调函数中来,
// 回调函数要做的,就是根据唯一的一个参数action对象来决定改如何更新自己的状态。
CounterStore.dispatchToken = AppDispatcher.register((action) => {
if(action.type === ActionTypes.INCREMENT){
counterValues[action.counterCaption]++;
CounterStore.emitChange();
}else if(action.type === ActionTypes.DECREMENT){
counterValues[action.counterCaption]--;
CounterStore.emitChange();
}
});
// 如果action.type是INCREMENT,就根据action对象字段counterCaption确定是哪个计数器,
// 把counterValues上对应的字段做加一操作;
// 如果action.type代表DECREMENT,就做对应的减一的操作。
// 无论加一减一,最后都要调用CounterStore.emitChange函数,
// 假如有调用者通过Counter.addChangeListner关注了CounterStore的状态变化,
// 这个emitChange函数调用就会引发监听函数的执行。
// src/stores/SummaryStore.js
import AppDispatcher from '../AppDispatcher.js';
import * as ActionTypes from '../ActionTypes.js';
import CounterStore from './CounterStore.js';
import {EventEmitter} from 'events';
const CHANGE_EVENT = 'changed';
function computeSummary(counterValues){
let summary = 0;
for(const key in counterValues){
if(counterValues.hasOwnProperty(key)){
summary += counterValues[key];
}
}
return summary;
}
const SummaryStore = Object.assign({}, EventEmitter.property, {
// SummaryStore没有存储自己的状态,
// 当getSummary被调用时,直接从CounterStore里获取状态计算。
// 可见,虽然名为Store,但并不表示一个Store必须要存储什么东西,
// Store只是提供获取数据的方法,Store提供的数据完全可以由另一个Store计算得来。
getSummary: function(){
return computeSummary(CounterStore.getCounterValues());
},
emitChange: function(){
this.emit(CHANGE_EVENT);
},
addChangeListener: function(callback){
this.on(CHANGE_EVENT, callback);
},
removeChangeListener: function(callback){
this.removeListener(CHANGE_EVENT, callback);
}
});
// Dispatcher的register函数,只提供了注册一个回调函数的功能,
// 但不能让调用者在register时选择只监听某些action。
// 当一个动作被派发的时候,Dispatcher只是简单地把所有注册的回调函数全都调用一遍,
// 至于这个动作是不是对方关心的,Flux的Dispatcher不关心,而是要求每个回调函数去鉴别。
// 这个设计让Flux的Dispatcher逻辑最简单化,Dispatcher的责任越简单,就越不会出现问题。
// 由回调函数全权决定如何处理action对象,也是非常合理的。
SummaryStore.dispatchToken = AppDispatcher.register((action) => {
if((action.type === ActionTypes.INCREMENT) || (action.type === ActionTypes.DECREMENT)){
// 可以认为Dispatcher调用回调函数的顺序完全是无法预期的,
// 不要假设它会按照我们期望的顺序逐个调用。
// Dispatcher的waitFor可以接受一个数组作为参数,
// 数组中每个元素都是一个Dispatcher.register函数的返回结果,即dispatchToken。
// waitFor函数告诉Dispatcher,当前的处理必须要暂停,
// 直到dispatchToken代表的那些已注册回调函数执行结束才能继续。
AppDispatcher.waitFor([CounterStore.dispatchToken]);
// JavaScript是单线程的语言,不存在线程之间的等待,
// waitFor函数当然并用多线程实现的,
// 只是在调用waitFor时,把控制权交给Dispatcher,
// 让Dispatcher检查dispatchToken代表的回调函数有没有被执行,
// 如果已经执行,就直接继续,
// 如果还没执行,就调用dispatchToken代表的回调函数之后waitFor才返回。
SummaryStore.emitChange();
}
});
export default SummaryStore;
4、View
Flux框架下,View并不是说必须要使用React,View本身是一个独立的部分,可以用任何一种UI库来实现。
存在于Flux框架中的React组件需要实现以下几个功能:
- 创建时要读取Store上状态来初始化组件内部状态;
- 当Store上状态发生变化时,组件要立刻同步更新内部状态保持一致;
- View如果要改变Store状态,必须且只能派发action。
// src/views/CounterPanel.js
import React, {Component} from 'react';
import Counter from './Counter.js';
import Summary from './Summary.js';
const style = {
margin: '20px'
};
class ControlPanel extends Component{
render(){
return (
<div style={style}>
<Counter caption="First" />
<Counter caption="Second" />
<Counter caption="Third" />
<hr/>
<Summary />
</div>
);
}
}
export default ControlPanel;
// src/views/Counter.js
import React, {Component, PropTypes} from 'react';
import * as Actions from '../Actions.js';
import CounterStore from '../stores/CounterStore.js';
const buttonStyle = {
margin: '10px'
};
class Counter extends Component{
constructor(props){
super(props);
this.onChange = this.onChange.bind(this);
this.onClickIncrementButton = this.onClickIncrementButton.bind(this);
this.onClickDecrementButton = this.onClickDecrementButton.bind(this);
this.state = {
count: CounterStore.getCounterValues()[props.caption]
}
}
shouldComponentUpdate(nextProps, nextState){
return (nextProps.caption !== this.props.caption) || (nextState.count !== this.state.count);
}
componentDidMount(){
CounterStore.addChangeListener(this.onChange);
}
componentWillUnmount(){
CounterStore.removeChangeListener(this.onClick);
}
onChange(){
const newCount = CounterStore.getCounterValues()[this.propss.caption];
this.setState({count: newCount});
}
onClickIncrementButton(){
Actions.increment(this.props.caption);
}
onClickDecrementButton(){
Actions.decrement(this.props.caption);
}
render(){
const {caption} = this.props;
return (
<div>
<button style={buttonStyle} onClick={this.onClickIncrementButton}>+</button>
<button style={buttonStyle} onClick={this.onClickDecrementButton}>-</button>
<span>{caption} count: {this.state.count}</span>
</div>
)
}
}
// src/views/Summary.js
import React, {Component} from 'react';
import SummaryStore from '../stores/SummaryStore.js';
class Summary extends Component{
constructor(props){
super(props);
this.onUpdate = this.onUpdate.bind(this);
this.state = {
sum: SummaryStore.getSummary()
}
}
componentDidMount(){
SummaryStore.addChangeListener(this.onUpdate);
}
componentWillUnmount(){
SummaryStore.removeChangeListener(this.onUpdate);
}
onUpdate(){
this.setState({
sum: SummaryStore.getSummary()
})
}
render(){
return (
<div>Total Count: {this.state.sum}</div>
);
}
}
export default Summary;
Flux的优势
Flux的架构下,应用的状态被放在Store中,React组件只是扮演View的作用,被动根据Store的状态来渲染。React组件依然有自己的状态,但是已经完全沦为Store组件的一个映射,而不是主动变化的数据。
“单向数据流”的管理方式
在Flux的理念里,如果要改变界面,必须改变Store中的状态,如果要改变Store中的状态,必须派发一个action对象,这就是规矩。在这个规矩之下,想要追溯一个应用的逻辑就变得非常容易。
MVC最大的问题就是无法禁绝View和Model之间直接对话,Flux中的View对应于MVC中的View,Flux中的Store对应于MVC中的Model,在Flux中,Store只有get方法,没有set方法,根本不可能直接去修改其内部状态,View只能通过get方法获取Store的状态,无法直接去修改状态,如果View想要修改Store状态,只有派发一个action对象给Dispatcher。
这看起来是一个“限制”,但却是一个很好的“限制”,杜绝了数据流混乱的可能。
在Flux体系下,驱动界面改变始于一个动作的派发,除此之外,别无他法。
Flux的不足
Store之间依赖关系
- 在Flux体系中,如果两个Store之间有逻辑依赖关系,就必须要用Dispatcher的waitFor函数;
- CounterStore必须要把注册回调函数时产生的dispatchToken公之于众;
SummaryStore必须要在代码里建立对CounterStore的dispatchToken的依赖。
最好的依赖管理是根本不让依赖产生。
难以进行服务器端渲染
如果要在服务器端渲染,输出不是一个DOM树,而是一个全是HTML的字符串。
在Flux体系中,有一个全局的Dispatcher,然后每一个Store都是一个全局唯一的对象,和一个浏览器网页只服务于一个用户不同,在服务器端要同时接受很多用户的请求,如果每个Store都是全局唯一的对象,那不同请求的状态肯定就乱套了。
- Store混杂了逻辑和状态
Store封装了数据和处理数据的逻辑,用面向对象的思维来看,这是一件好事。但是,当我们需要动态替换一个Store的逻辑时,只能把这个Store整体替换掉,那也就无法保持Store中存储的状态。
Redux
Redux基本原则
唯一数据源(Single Source of Truth)
应用的状态数据应该只存储在唯一的一个Store上
如果状态数据分散在多个Store中,容易造成数据冗余;虽然Dispatcher的waitFor方法可以保证多个Store之间的更新顺序,但这又产生了不同Store之间的显式依赖关系,这种依赖关系的存在增加了应用的复杂度,容易带来新的问题。
- 保持状态只读(State is read-only)
- 要修改Store的状态,必须要通过派发一个action对象完成,这点和Flux没什么区别;
- 要驱动用户界面渲染,就要改变应用的状态,但是改变状态的方法不是去修改状态上值,而是创建一个新的状态对象返回给Redux,由Redux完成新的状态的组装。
数据改变只能通过纯函数完成(Changes are made with pure functions)
Redux=Reducer+Flux
在Redux中,每个reducer的函数签名如下所示:
reducer(state, action)
state是当前的状态,action是接收到的action对象,reducer函数要做的事情,就是根据state和action的值产生一个新的对象返回。
reducer必须是纯函数,即函数的返回结果必须完全由参数state和action决定,而且不产生任何副作用,也不能修改参数state和action对象。