3.1.2 Flux应用
您可以在百度里搜索“深入浅出React和Redux 艾草文学(www.321553.xyz)”查找最新章节!
3.1.2 Flux应用
本书讲的是Redux,但是为什么这里要先介绍Flux,甚至要用Flux来实现一个例子呢?
因为Redux其实和Flux一脉相承,Redux出现之后,连Flux的创建者都说“喜欢你创造的Redux”,Redux的创建者Dan Abramov,现在也是在为Facebook的React库工作。
从Flux的例子入手,在理解Redux的时候就会感觉非常顺畅。让我们改进一下前面章节中创造的ControlPanel应用,Flux提供了一些辅助工具类和函数,能够帮助创建Flux应用,但是需要一些学习曲线。在这里,我们只用Facebook官方的基本功能,目的是为了更清晰地看一看Flux的工作原理。
为了使用Flux,首先通过命令行在项目目录下安装Flux。
npm install –-save flux
利用Flux来实现ControlPanel应用的相关代码在https://github.com/mocheng/react-and-redux/tree/master/chapter-03/flux上可以找到,最终应用界面效果和第2章中创造的应用完全一样,通过同一界面不同实现方式的比对,我们可以体会每个方式的优劣。
1.Dispatcher
首先,我们要创造一个Dispatcher,几乎所有应用都只需要拥有一个Dispatcher,对于我们这个简单的应用更不例外。在src/AppDispatcher.js中,我们创造这个唯一的Dispatcher对象,代码如下:
import {Dispatcher} from 'flux';
export default new Dispatcher();
非常简单,我们引入flux库中的Dispatcher类,然后创造一个新的对象作为这个文件的默认输出就足够了。在其他代码中,将会引用这个全局唯一的Dispatcher对象。
Dispatcher存在的作用,就是用来派发action,接下来我们就来定义应用中涉及的action。
2.action
action顾名思义代表一个“动作”,不过这个动作只是一个普通的JavaScript对象,代表一个动作的纯数据,类似于DOM API中的事件(event)。甚至,和事件相比,action其实还是更加纯粹的数据对象,因为事件往往还包含一些方法,比如点击事件就有preventDefault方法,但是action对象不自带方法,就是纯粹的数据。
作为管理,action对象必须有一个名为type的字段,代表这个action对象的类型,为了记录日志和debug方便,这个type应该是字符串类型。
定义action通常需要两个文件,一个定义action的类型,一个定义action的构造函数(也称为action creator)。分成两个文件的主要原因是在Store中会根据action类型做不同操作,也就有单独导入action类型的需要。
在src/ActionTypes.js中,我们定义action的类型,代码如下:
export const INCREMENT = 'increment';
export const DECREMENT = 'decrement';
在这个例子中,用户只能做两个动作,一个是点击“+”按钮,一个是点击“-”按钮,所以我们只有两个action类型INCREMENT和DECREMENT。
现在我们在src/Actions.js文件中定义action构造函数:
import * as ActionTypes from './ActionTypes.js';
import AppDispatcher from './AppDispatcher.js';
export const increment = (counterCaption) => {
AppDispatcher.dispatch({
type: ActionTypes.INCREMENT,
counterCaption: counterCaption
});
};
export const decrement = (counterCaption) => {
AppDispatcher.dispatch({
type: ActionTypes.DECREMENT,
counterCaption: counterCaption
});
};
虽然出于业界习惯,这个文件被命名为Actions.js,但是要注意里面定义的并不是action对象本身,而是能够产生并派发action对象的函数。
在Actions.js文件中,引入了ActionTypes和AppDispatcher,看得出来是要直接使用Dispatcher。
这个Actions.js导出了两个action构造函数increment和decrement,当这两个函数被调用的时候,创造了对应的action对象,并立即通过AppDispatcher.dispatch函数派发出去。
派发出去的action对象最后怎么样了呢?在下面关于Store的部分可以看到。
3.Store
一个Store也是一个对象,这个对象存储应用状态,同时还要接受Dispatcher派发的动作,根据动作来决定是否要更新应用状态。
接下来我们创造Store相关的代码,因为使用Flux之后代码文件数量会增多,再把所有源代码文件都放在src目录下就不容易管理了。所以我们在src下创建一个子目录stores,在这个子目录里面放置所有的Store代码。
在前面章节的ControlPanel应用例子里,有三个Counter组件,还有一个统计三个Counter计数值之和的功能,我们遇到的麻烦就是这两者之间的状态如何同步的问题,现在,我们创造两个Store,一个是为Counter组件服务的CounterStore,另一个就是为总数服务的SummaryStore。
我们首先添加CounterStore,放在src/stores/CounterStore.js文件中。
注意
在本书中我们避免了使用Flux库的任何辅助类或者方法,虽然在flux/utils中提供了一些辅助类方便Store的开发,但是我们并不使用这些辅助类,因为学习这些辅助类需要一些学习曲线,我们选择原始但是简单的方法来构建Store,这样能够更清楚地看到Store的工作原理。
先看定义CounterStore的代码,如下所示:
const counterValues = {
'First': 0,
'Second': 10,
'Third': 30
};
const CounterStore = Object.assign({}, EventEmitter.prototype, {
getCounterValues: function() {
return counterValues;
},
emitChange: function() {
this.emit(CHANGE_EVENT);
},
addChangeListener: function(callback) {
this.on(CHANGE_EVENT, callback);
},
removeChangeListener: function(callback) {
this.removeListener(CHANGE_EVENT, callback);
}
});
当Store的状态发生变化的时候,需要通知应用的其他部分做必要的响应。在我们的应用中,做出响应的部分当然就是View部分,但是我们不应该硬编码这种联系,应该用消息的方式建立Store和View的联系。这就是为什么我们让CounterStore扩展了EventEmitter.prototype,等于让CounterStore成了EventEmitter对象,一个EventEmitter实例对象支持下列相关函数。
·emit函数,可以广播一个特定事件,第一个参数是字符串类型的事件名称;
·on函数,可以增加一个挂在这个EventEmitter对象特定事件上的处理函数,第一个参数是字符串类型的事件名称,第二个参数是处理函数;
·removeListener函数,和on函数做的事情相反,删除挂在这个EventEmitter对象特定事件上的处理函数,和on函数一样,第一个参数是事件名称,第二个参数是处理函数。要注意,如果要调用removeListener函数,就一定要保留对处理函数的引用。
对于CounterStore对象,emitChange、addChangeListener和removeChangeListener函数就是利用EventEmitter上述的三个函数完成对CounterStore状态更新的广播、添加监听函数和删除监听函数等操作。
CounterStore函数还提供一个getCounterValues函数,用于让应用中其他模块可以读取当前的计数值,当前的计数值存储在文件模块级的变量counterValues中。
注意
严格来说,getCounterValues这样的getter函数,应该返回一个不可改变的(Immutable)数据,这样,调用者即使通过getCounterValues获得了当前计数值对象,也不能够修改这个对象从而扰乱其他代码的使用。但是,为了简单起见,这个例子中我们并不使用Immutable,只是在写代码时要注意,不应该去修改通过Store得到的数据。
上面实现的Store只有注册到Dispatcher实例上才能真正发挥作用,所以还需要增加下列代码:
import AppDispatcher from '../AppDispatcher.js';
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();
}
});
这是最重要的一个步骤,要把CounterStore注册到全局唯一的Dispatcher上去。Dispatcher有一个函数叫做register,接受一个回调函数作为参数。返回值是一个token,这个token可以用于Store之间的同步,我们在CounterStore中还用不上这个返回值,在稍后的SummaryStore中会用到,现在我们只是把register函数的返回值保存在CounterStore对象的dispatchToken字段上,待会就会用得到。
现在我们来仔细看看register接受的这个回调函数参数,这是Flux流程中最核心的部分,当通过register函数把一个回调函数注册到Dispatcher之后,所有派发给Dispatcher的action对象,都会传递到这个回调函数中来。
比如通过Dispatcher派发一个动作,代码如下:
AppDispatcher.dispatch({
type: ActionTypes.INCREMENT,
counterCaption: 'First'
});
那在CounterStore注册的回调函数就会被调用,唯一的一个参数就是那个action对象,回调函数要做的,就是根据action对象来决定改如何更新自己的状态。
作为一个普遍接受的传统,action对象中必有一个type字段,类型是字符串,用于表示这个action对象是什么类型,比如上面派发的action对象,type为“increment”,表示是一个计数器的“加一”的动作;如果有必要,一个action对象还可以包含其他的字段。上面的action对象中还有一个counterCaption字段值为“First”,标识名字为“First”的计数器。
在我们的例子中,action对象的type和counter Caption字段结合在一起,可以确定是哪个计数器应该做加一或者减一的动作,上面例子中的动作含义就是:“名字为First的计数器要做加一动作。”
根据不同的type,会有不同的操作,所以注册的回调函数很自然有一个模式,就是函数体是一串if-else条件语句或者switch条件语句,而条件语句的跳转条件,都是针对参数action对象的type字段:
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对象字段counter-Caption确定是哪个计数器,把counterValues上对应的字段做加一操作;同样,如果发现action.type代表DECREMENT,就做对应的减一的操作。
无论是加一或者减一,最后都要调用ounterStore.emitChange函数,假如有调用者通过Counter.addChangeListner关注了CounterStore的状态变化,这个emitChange函数调用就会引发监听函数的执行。
目前,CounterStore只关注INCREMENT和DECREMENT动作,所以if-else判断也只关照了这两种类型的动作,除此之外,其他action对象一律忽略。
接下来,我们再来看看另一个Store,也就是代表所有计数器计数值总和的Store,在src/stores/SummaryStore.js中有源代码。
SummaryStore也有emitChange、addChangeListener还有removeChangeListener函数,功能一样也是用于通知监听者状态变化,这几个函数的代码和CounterStore中完全重复,不同点是对获取状态函数的定义,代码如下:
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.prototype, {
getSummary: function() {
return computeSummary(CounterStore.getCounterValues());
},
});
可以注意到,SummaryStore并没有存储自己的状态,当getSummary被调用时,它是直接从CounterStore里获取状态计算的。
CounterStore提供了getCounterValues函数让其他模块能够获得所有计数器的值,SummaryStore也提供了getSummary让其他模块可以获得所有计数器当前值的总和。不过,既然总可以通过CounterStore.getCounterValues函数获取最新鲜的数据,SummaryStore似乎也就没有必要把计数器当前值总和存储到某个变量里。事实上,可以看到SummaryStore并不像CounterStore一样用一个变量counterValues存储数据,SummaryStore不存储数据,而是每次对getSummary的调用,都实时读取CounterStore.getCounterValues,然后实时计算出总和返回给调用者。
可见,虽然名为Store,但并不表示一个Store必须要存储什么东西,Store只是提供获取数据的方法,而Store提供的数据完全可以另一个Store计算得来。
SummaryStore在Dispatcher上注册的回调函数也和CounterStore很不一样,代码如下:
SummaryStore.dispatchToken = AppDispatcher.register((action) => {
if ((action.type === ActionTypes.INCREMENT) ||
(action.type === ActionTypes.DECREMENT)) {
AppDispatcher.waitFor([CounterStore.dispatchToken]);
SummaryStore.emitChange();
}
});
SummaryStore同样也通过AppDispatcher.register函数注册一个回调函数,用于接受派发的action对象。在回调函数中,也只关注INCREMENT和DECREMENT类型的action对象,并通过emitChange通知监听者,注意在这里使用了waitFor函数,这个函数解决的是下面描述的问题。
既然一个action对象会被派发给所有回调函数,这就产生了一个问题,到底是按照什么顺序调用各个回调函数呢?
即使Flux按照register调用的顺序去调用各个回调函数,我们也完全无法把握各个Store哪个先装载从而调用register函数。所以,可以认为Dispatcher调用回调函数的顺序完全是无法预期的,不要假设它会按照我们期望的顺序逐个调用。
设想一下,当一个INCREMENT类型的动作被派发了,如果首先调用SummaryStore的回调函数,在这个回调函数中立即用emitChange通知了监听者,这时监听者会立即通过SummaryStore的getSummary获取结果,而这个getSummary是通过CounterStore暴露的getCounterValues函数获取当前计数器值,计算出总和返回……然而,这时候,INCREMENT动作还没来得及派发到CounterStore啊!也就是说,CounterStore的getCounterValues返回的还是一个未更新的值,那样SummaryStore的getSummary返回值也就是一个错误的值了。
怎么解决这个问题呢?这就要靠Dispatcher的waitFor函数了。在SummaryStore的回调函数中,之前在CounterStore中注册回调函数时保存下来的dispatchToken终于派上了用场。
Dispatcher的waitFor可以接受一个数组作为参数,数组中每个元素都是一个Dispatc-herregister函数的返回结果,也就所谓的dispatchToken。这个waitFor函数告诉Dispatcher,当前的处理必须要暂停,直到dispatchToken代表的那些已注册回调函数执行结束才能继续。
我们知道,JavaScript是单线程的语言,不可能有线程之间的等待这回事,这个waitFor函数当然并不是用多线程实现的,只是在调用waitFor的时候,把控制权交给Dispatcher,让Dispatcher检查一下dispatchToken代表的回调函数有没有被执行,如果已经执行,那就直接接续,如果还么有执行,那就调用dispatchToken代表的回调函数之后waitFor才返回。
回到我们上面假设的例子,即使SummaryStore比CounterStore提前接收到了action对象,在emitChange中调用waitFor,也就能够保证在emitChange函数被调用的时候,CounterStore也已经处理过这个action对象,一切完美解决。
这里要注意一个事实,Dispatcher的register函数,只提供了注册一个回调函数的功能,但却不能让调用者在register时选择只监听某些action,换句话说,每个register的调用者只能这样请求:“当有任何动作被派发时,请调用我。”但不能够这么请求:“当这种类型还有那种类型的动作被派发的时候,请调用我。”
当一个动作被派发的时候,Dispatcher就是简单地把所有注册的回调函数全都调用一遍,至于这个动作是不是对方关心的,Flux的Dispatcher不关心,要求每个回调函数去鉴别。
看起来,这似乎是一种浪费,但是这个设计让Flux的Dispatcher逻辑最简单化,Dispatcher的责任越简单,就越不会出现问题。毕竟,由回调函数全权决定如何处理action对象,也是非常合理的。
4.View
首先需要说明,Flux框架下,View并不是说必须要使用React,View本身是一个独立的部分,可以用任何一种UI库来实现。
不过,话说回来,既然我们都使用上Flux了,除非项目有大量历史遗留代码需要利用,否则实在没有理由不用React来实现View。
存在于Flux框架中的React组件需要实现以下几个功能:
·创建时要读取Store上状态来初始化组件内部状态;
·当Store上状态发生变化时,组件要立刻同步更新内部状态保持一致;
·View如果要改变Store状态,必须而且只能派发action。
最后让我们来看看例子中的View部分,为了方便管理,所有的View文件都放在src/views目录里。
先看src/views/ControlPanel.js中的ControlPanel组件,其中render函数的实现和上一章很不一样,代码如下:
render() {
return (
);
}
可以注意到,和以前面章节中的ControlPanel不同,Counter组件实例只有caption属性,没有initValue属性。因为我们把计数值包括初始值全都放到CounterStore中去了,所以在创造Counter组件实例的时候就没必要指定initValue了。
接着看src/views/Counter.js中定义的Counter组件,构造函数中初始化this.state的方式有了变化,代码如下:
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]
}
}
在构造函数中,CounterStore.getCounterValues函数获得了所有计数器的当前值,然后把this.state初始化为对应caption字段的值,也就是说Counter组件的store来源不再是prop,而是Flux的Store。
Counter组件中的state应该成为Flux Store上状态的一个同步镜像,为了保持两者一致,除了在构造函数中的初始化之外,在之后当CounterStore上状态变化时,Counter组件也要对应变化,相关代码如下:
componentDidMount() {
CounterStore.addChangeListener(this.onChange);
}
componentWillUnmount() {
CounterStore.removeChangeListener(this.onChange);
}
onChange() {
const newCount = CounterStore.getCounterValues()[this.props.caption];
this.setState({count: newCount});
}
如上面的代码所示,在componentDidMount函数中通过CounterStore.addChange-Listener函数监听了CounterStore的变化之后,只要CounterStore发生变化,Counter组件的onChange函数就会被调用。与componentDidMount函数中监听事件相对应,在componentWillUnmount函数中删除了这个监听。
接下来,要看React组件如何派发action,代码如下:
onClickIncrementButton() {
Actions.increment(this.props.caption);
}
onClickDecrementButton() {
Actions.decrement(this.props.caption);
}
render() {
const {caption} = this.props;
return (
+
-
{caption} count: {this.state.count}
);
}
}
可以注意到,在Counter组件中有两处用到CounterStore的getCounterValues函数的地方,第一处是在构造函数中初始化this.state的时候,第二处是在响应CounterStore状态变化的onChange函数中,同样一个Store的状态,为了转换为React组件的状态,有两次重复的调用,这看起来似乎不是很好。但是,React组件的状态就是这样,在构造函数中要对this.state初始化,要更新它就要调用this.setState函数。
有没有更简洁的方法?比如说只使用CounterStore.getCounterValues一次?可惜,只要我们想用组件的状态来驱动组件的渲染,就不可避免要有这两步。那么如果我们不利用组件的状态呢?
如果不使用组件的状态,那么我们就可以逃出这个必须在代码中使用Store两次的宿命,在接下里的章节里,我们会遇到这种“无状态”组件。
Summary组件,存在于src/views/Summary.js中,和Counter类似,在constructor中初始化组件状态,通过在componetDidMount中添加对SummaryStore的监听来同步状态,因为这个View不会有任何交互功能,所以没有派发出任何action。 深入浅出React和Redux