在最近的一个项目中,我们团队尝试了Flux + React.js的架构,在这种架构中我们获得了很多的好处:
- 数据流更加清晰和简单,使得我们的开发和debug也可以按照一个清晰和标准的方式进行;
- 数据处理这一层的职责更加清晰,使得我们可以更容易的进行数据维护、缓存的处理;
- 在界面的处理上只用关心界面的最终状态,不需要维护中间过程;
- ……
下面我们就来聊一聊我们团队在这种架构中的一些实践,希望可以对大家有用。
基础架构以及Why
在这个项目中我们采用的基础架构是reflux.js + react.js + 一些小的library,例如:director.js,jquery.js,lodash.js。
reflux.js
选用reflux.js作为Flux的实现,是因为现在reflux.js是Github上最受欢迎的一个实现,并且提供了非常实际的便捷。它和Facebook Flux主要的不同在于:
- 没有了dispatcher这一层,actions直接是listenable的;
- stores可以直接listen actions,而不需要用swtich去区分一大堆Action types;
- stores提供了很多方便的方法使得view可以很方便的监听;
- 提供了一种比较好的思路来处理API请求这种异步actions。
下面是一个例子:
var Reflux = require('reflux');
var React = require('react');
var UserAction = Reflux.createAction({
'login': {children: ['success', 'failed']}
});
UsersAction.login.listen(function(data) {
$.post('/api/users/Action/login', data).then(this.success, this.failed);
});
var UserStore = Reflux.createStore({
listenables: UserAction,
onLoginSuccess: function(payload) {
this.trigger(payload);
},
onLoginFailed: function(payload) {
this.trigger(payload);
}
});
var UserComponent = React.createClass({
mixins: [Reflux.connect(UserStore, 'user')],
render: function() {
return <span>{this.state.user.name}</span>;
}
});
在我看来最大的好处就是,少写了很多代码,并且代码的可读性还挺好的。
libraries
在这个项目中我们选用了很多小而专的library,而不是选用一个大而全的framework(例如:Angular.js,Ember.js),是因为选用那样的framework风险比较大,替换成本很高,一旦出现了像Angular.js 2.0这样的升级,对团队来说比较痛苦。而选用小library的集合,要替换其中某一部分是很容易的。并且不会被framework的principle和DSL所绑架,比较好行程适用于自己项目domain的principle和DSL。
下面来介绍一下我们用到的libraries:
- director.js是一个Server端和Client端通用的router工具。
- jquery.js就不用介绍了。选这个主要是用来做来项目中的Ajax call、promise工具,原因也是被逼无奈,我们用到的很多插件都基于它,为了不增加额外的加载量,也就只有将就用它了。
- lodash.js这个也不用介绍了,比underscore.js性能更高,功能更强。
架构的演进
前面介绍了我们项目的基础架构,由于我们是用了各种小library,并且都是我们自己选的,那么就没有一个现成的架构来告诉我们这样架构的最佳实践是什么,一切都需要我们自己去探索和演进。下面我就来介绍一下项目各个部分的演进路线是什么样的,以及为什么会出现这样的演进。
页面render的lifecircle
项目开始时是非常简单的render方式,就是当route改变时,router根据最新的route去选择某个component render到页面中:
var Router = require('director').Router;
var $ = require('jquery');
var router = new Router({
'/login': function() {
React.render(React.createElement(LoginComponent), $('.container').get(0));
}
}).configure();
然后我发现在很多时候我需要在程序中去控制页面跳转,例如:登录成功以后跳转到首页。于是我就在登录后用 window.location.hash = '/'
去做跳转。后来我发现程序中到处都是 window.location.hash = 'xxx'
,到处修改这种全局变量不是一个好的实践,并且这样在未来做isomophic也会很难。于是我决定用Flux的方式来处理这一部分逻辑。很显然,这里的Store存储的是当前的route,Action所触发的是route的改变,于是我们增加了RouteStore和RouteAction:
var RouteAction = Reflux.createAction(['navigateTo']);
var RouteStore = Reflux.createStore({
listenables: RouteActions,
onNavigateTo: function(newRoute) {
this.trigger(newRoute);
}
});
RouteStore.listen(function(newRoute) {
router.setRoute(newRoute);
});
这样所有的 window.location.hash = 'xxx'
都被替换成了 RouteAction.navigateTo('xxx')
。
后来当页面增加,我发现在route配置中出现了很多重复的代码,例如:
var router = new Router({
'/login': function() {
React.render(React.createElement(HeaderComponent), $('.header').get(0));
React.render(React.createElement(LoginComponent), $('.container').get(0));
},
'/register': function() {
React.render(React.createElement(HeaderComponent), $('.header').get(0));
React.render(React.createElement(RegisterComponent), $('.container').get(0));
},
'/profile': function() {
React.render(React.createElement(HeaderComponent), $('.header').get(0));
React.render(React.createElement(ProfileComponent), $('.container').get(0));
}
}).configure();
同样,我希望把这种layout和page的render也用Flux的方式来进行管理。那么这里Store所存储的就是页面的component,Action所触发的就是页面component的改变,于是我增加了PageStore和PageAction,同时把各种layout放到PageComponent中管理:
var PageAction = Reflux.createAction(['render']);
var PageStore = Reflux.createStore({
listenables: PageActions,
onRender: function(component, props) {
this.trigger({
component: component,
props: props
});
}
});
var PageComponent = React.createClass({
mixins: [Reflux.connect(PageStore, 'page')],
render: function() {
return (
<div>
<HeaderComponent />
<PageComponent {...this.state.page.props} />
</div>
);
}
});
然后我们route的配置就可以很简单了:
var router = new Router({
'/login': function() {
PageAction.render(LoginComponent);
},
'/register': function() {
PageAction.render(RegisterComponent);
},
'/profile': function() {
PageAction.render(ProfileComponent);
}
}).configure();
最近我们还加上了一个需求,就是对于profile页面,只能让登录的用户进入,对于这种需求在这种架构下就很好添加了,只需要修改PageAction:
var PageAction = Reflux.createAction(['render', 'renderIfLogin']);
PageAction.renderIfLogin.preEmit = function(component, props) {
if (userIsLogin) {
PageAction.render(component, props);
} else {
RouteAction.navigateTo('/login');
}
}
然后在profile页面,我们调用PageAction.renderIfLogin(ProfileComponent)
这样如果用户没有登录,就会被自动跳转到登录页面。
现在我们来总结一下当前的页面render lifecircle:
URL ===trigger===> Router ===call===> PageAction.render
/\ ||
|| trigger
tirgger ||
|| \/
界面 <==render== PageComponent <==trigger== PageStore
整个就是一个基于事件的单向数据流了!
Store与Action
这里用UsersStore和UsersAction作为示例。其实最开始的时候,它们是UserStore以及UserAction,因为系统中最开始只需要记录和操作当前登录的user:
var UserAction = Reflux.createAction({
asyncResult: true,
children: ['login', 'register']
});
UserAction.login.listen(function(data) {
$.post('/api/users/Action/login', data).then(this.loginCompleted);
});
UserAction.register.listen(function(data) {
$.post('/api/users', data).then(this.registerCompleted);
});
var UserStore = Reflux.createStore({
listenables: UserAction,
onLoginCompleted: function(payload) {
this.trigger(payload);
},
onRegisterCompleted: function(payload) {
this.trigger(payload);
}
});
当时的UserStore非常简单,没有任何逻辑,只是把API返回的数据trigger给View就完了。但是当我们增加了显示当前所有user list的需求,我们就必须又增加一个UsersStore和UsersAction:
var UsersAction = Reflux.createAction({
asyncResult: true,
children: ['fetch']
});
UsersAction.fetch.listen(function(data) {
$.get('/api/users', data).then(this.fetchCompleted);
});
var UsersStore = Reflux.createStore({
listenables: UsersAction,
onFetchCompleted: function(payload) {
this.trigger(payload);
}
});
但是如果只是简单的这么写,就会有一个陷阱,因为UsersStore其实是包含了UserStore的,也就是说当前user的数据需要在两个地方维护;并且同样一个Domain,被分成了两个Action + 两个Store,也非常奇怪。基于以上两点,我决定针对同一个Domain,只会有一个Action和一个Store与之对应,这样概念上更好理解,并且不会出样同一份数据,要在两处维护的麻烦。于是UserAction和UserStore就被合并到了UsersAction和UsersStore中:
var UsersAction = Reflux.createAction({
asyncResult: true,
children: ['fetchAll', 'login', 'register']
});
UsersAction.fetchAll.listen(function(data) {
$.get('/api/users', data).then(this.fetchCompleted);
});
UsersAction.login.listen(function(data) {
$.post('/api/users/Action/login', data).then(this.loginCompleted);
});
UsersAction.register.listen(function(data) {
$.post('/api/users', data).then(this.registerCompleted);
});
var users = [];
var UsersStore = Reflux.createStore({
listenables: UsersAction,
onFetchAllCompleted: function(payload) {
users = payload;
this.trigger(users);
},
onLoginCompleted: function(payload) {
var index = users.findIndx({id: payload.id});
if (index < 0) {
users.push(payload);
} else {
users[index] = payload;
}
this.trigger(users);
},
onRegisterCompleted: function(payload) {
users.push(payload);
this.trigger(users);
}
});
这样的架构我们使用了很长一段时间,但是当Domain的数量增加以后,我们发现每个Store做的事情其实都一样:把API返回的数据,根据ID merge进他自己的list里面。对于这种重复性很高,通用性有很强的逻辑,我们把它抽出来做成了一个Node Package,叫做traction。它可以根据一个指定的key,将两个数据进行merge,可以是从Object到Array,也可以是Array到Array,具体说明可以参考它的README。
于是我们的Store代码就可以进一步简化为:
var traction = require('traction');
var users = [];
var UsersStore = Reflux.createStore({
listenables: UsersAction,
onFetchAllCompleted: function(payload) {
users = traction.merge(payload).to(users).basedOn('id');
this.trigger(users);
},
onLoginCompleted: function(payload) {
users = traction.merge(payload).to(users).basedOn('id');
this.trigger(users);
},
onRegisterCompleted: function(payload) {
users = traction.merge(payload).to(users).basedOn('id');
this.trigger(users);
}
});
然后我们发现其实Store里面监听不同的Action所做的事情都是一样的,那么我们可以进一步简化:
var UsersAction = Reflux.createAction(['fetchAll', 'login', 'register', 'save']);
UsersAction.fetchAll.listen(function(data) {
$.get('/api/users', data).then(this.save);
});
UsersAction.login.listen(function(data) {
$.post('/api/users/Action/login', data).then(this.save);
});
UsersAction.register.listen(function(data) {
$.post('/api/users', data).then(this.save);
});
var users = [];
var UsersStore = Reflux.createStore({
listenables: UsersAction,
onSave: function(payload) {
users = traction.merge(payload).to(users).basedOn('id');
this.trigger(users);
}
});
然后我们又遇到新的问题了,就是在很多地方我要拿到当前的user,而在上面那样统一处理以后我就没有办法拿到了。针对这个问题有两种解决方案:
- 添加一个CurrentUserStore,要那当前user,就可以监听这个Store。但是这样就会又导致同一份数据在两个地方维护的问题,所以这并不是一个推荐的解决方案。
- 在UsersStore中,针对当前user的那一条数据添加一个flag,例如:isLogin,然后我在其他地方就可以使用
users.find('isLogin')
来拿到当前登录的那个user了。
要使用第二个解决方案,我们需要对login的action和UsersStore都进行一些改造,下面是一个示例:
UsersAction.login.listen(function(data) {
$.post('/api/users/Action/login', data).then(function(data) {
this.save(data, true);
});
});
var users = [];
var UsersStore = Reflux.createStore({
listenables: UsersAction,
onSave: function(payload, isLogin) {
if (isLogin) {
payload.isLogin = true;
}
users = traction.merge(payload).to(users).basedOn('id');
this.trigger(users);
}
});
看到这里好像还少了点什么?对,就是错误处理。很多时候我们需要显示Server端的错误,或者是当401的时候跳转到登录页面。针对这个我们的处理方式是有一个全局的ExceptionAction和ExceptionStore:
var ExceptionAction = Reflux.createAction(['serverError']);
ExceptionAction.serverError.preEmit = function(error) {
if (error.status === 401) {
RouteActions.navigateTo('/login');
}
}
var ExceptionStore = Reflux.createStore({
listenables: ExceptionActions,
onServerError: function(payload) {
this.trigger(payload);
}
});
需要显示Server端错误信息的Component就可以监听ExceptionStore了。
到这里为之就是我们Action和Store的当前形态了。其实现在可以看出来,当merge数据的逻辑被抽出来以后,Store就是一个缓存了,后面要做的一件事情可能就是,Action需要根据Store的状态来决定到底是从API去读数据,还是从某个Client缓存(例如:LocalStorage)读数据。
最后我们来总结一下关于Action和Store的一些最佳实践:
- 不要在Action做任何针对数据的逻辑处理,把最纯粹的数据交给Store来处理;
- 针对同一个Domain只能有一个Store与之对应,对Action同理;
- Store在维护自身数据的时候需要考虑到很多情况,例如:单个数据的添加/修改、多个数据的添加/修改。推荐使用traction来进行处理;
- 对于Store中需要特殊存储的数据,建议使用一个flag来标识,而不是再增加一个Store。
遗留的一些问题
重复的数据请求
由于我们现在项目中,fetchAction的触发都是由需要那个数据的Component自己触发的,这样就有可能导致一个页面中重复发出同一个请求。例如:每个页面都会有一个header,header中会显示用户名,那么header就会去调用 UsersAction.fetchCurrent
;在profile页面,显示profile信息的component肯定也需要user信息,那么他也会去调用 UsersAction.fetchCurrent
。这样在profile页面就会有两个同样的请求发出。
如何做数据缓存?
对于数据缓存我们已经有了一些方向,例如:对于缓存的操作,是由Store来进行;对于是从缓存来读取数据,还是从API读取数据,是由Action来决定。但问题是如果要由Action来决定,那么Action又需要知道Store的状态,现在能想到的方法就是Store上有个 getData
的接口让Action来获取数据,然后Action就可以做判断了。不过我们希望可以有更好的方式,也许Action可以不用持有Store就可以完成这个判断?
大家对于这两个问题有什么看法,欢迎大家一起来讨论~。
另外前段时间还写了两篇博客,是关于如何重构Backbone.js项目到React.js的,如果感兴趣的话,欢迎大家来我的博客看看~