聊一聊基于Flux的前端系统

#1

在最近的一个项目中,我们团队尝试了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,而在上面那样统一处理以后我就没有办法拿到了。针对这个问题有两种解决方案:

  1. 添加一个CurrentUserStore,要那当前user,就可以监听这个Store。但是这样就会又导致同一份数据在两个地方维护的问题,所以这并不是一个推荐的解决方案。
  2. 在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的,如果感兴趣的话,欢迎大家来我的博客看看~

23 Likes
#2

:smile:用心了。。。赞

1 Like
#3

:+1: 不错。 Reflux用起来确实舒服多了。另外在我用的最新的 Reflux ^0.2.7 中:

var UsersAction = Reflux.createActions({
    asyncResult: true,
    children: ['fetchAll', 'login', 'register']
});
// UsersAction.fetchAll.listen 是没有listen方法的。

这儿应该用的是createAction 而不是 createActions吧。

#4

多谢指出错误~已经修改了~

#5

我们路由用的是 react-router, 省了不少时间想, 但是花了很多时间看那个文档.
store 部分没有楼主研究得深入… 也还算够用, Reflux 的逻辑我还有点跟不上.

#6

楼主你好,我最近也在研究相应的问题,是否劳烦你加一下我的qq,我想请教你一些问题,qq583451067

#7

好呀,不过我基本不用QQ的。。。要不我们邮件联系吧,我的邮箱是 zation1@gmail.com

#8

可以试一下这边 Gitter, 虽然访问比较慢 http://nav.react-china.org/

#9

为啥不用Slack。。。

#10

Slack 不如 GitHub 直接登录方便啊~ 如果人多也可以弄一个

#11

强依赖Action,我之前在mail list里面提过,没人理我,原来Reflux就是这样的而实现。但是仔细想想store其实是关注结果而非Action本身,因此多了一层Dispatcher。
在我项目里面采用的官方的Flux模式,没做任何修改。

#12

豁然开朗

#13

有空分享一下你在项目中的用法吗?还有什么叫强依赖Action?

#14

我这里是指store对具体的action的依赖是强烈的,明确的。因为之前我思考过官方Flux的一个问题,就是在store需要的数据需求发生变更的时候,根本没法直接找到其要修改的ActionCreator在哪里。因为store依赖的是一个action object literal的东西,里面有的只是一个ActionType,要通过ActionType反推出ActionCreator进行修改。因此我当时想过是否要引入一个明确的Action Class,抛弃Dispatcher,让store直接对其进行引用,这和Reflux是类似的,貌似我不太喜欢Reflux的方式。

1 Like
#15

非常好的工程实践,建议写得更明白一点啊。

感谢。

另:
reflux 有些个优点:

  1. 概念比较接近 MV** , 代码量小,文档比较全面(虽然是英文的)
  2. 使用上比较方便,特别是国内多数人对界面还停留在 CRUD 这一层上以及项目工程只是 百来个 component 时, reflux 确是很好用
  3. 开发支持与周边支持都不错,比如 morearty.JS 支持 reflux 就非常有意思

reflux 的缺点:

  1. 项目大了( 1000个 component 这一级别) , 测试麻烦了,后期维护基本与 angularJS 一样,没什么大的改善
#16

同时发两个一样的ajax请求,可以共用一个xhr对象吧;如果间隔时间较长,用缓存数据?

#17

测试应该还好吧,stub一下action或者props就测了,测试我感觉还是比不上AngularJS的,人家有Protractor啊

#18

可以在统一的request做一下处理,不过我现在的场景是两个同时发的……和后端的同事讨论了一下,他们倒是觉得无所谓,后端会做自己的缓存处理,每次请求的数据量其实也不大,请求两次就请求两次吧

#19

个人感觉这两个问题还是可以处理的,就是把取数据的操作从 action.listen 中挪到 store 里面去,我对 store 的理解就是一个存放数据的地方。
在 store 中可以把从后端请求到的数据缓存起来,这样下次需要数据的时候,先判断缓存里面有没有,有的话就直接从缓存里面取了。

#21

感谢分享。用心了。这篇帖子是论坛上为数不多的详细分享,放在 inbox 里好些日子上,今天详细读了下。

我简单理解了下 @zation 说的 Flux + React.js 架构的好处:

1 数据流更加清晰,应该指的是 flux 的 un-directional data flow。我理解这个和第三点“界面只处理最终状态”其实是一个概括, un-directional data flow 其实就是, UI = f(x)。而且 x -> UI 中间对 x 没有做 modify。这样的 mental model 才足够 clean,predictable。
2. 数据处理这块我理解是 data agnostic,这块我觉得 @dan_abramov 的文章 https://medium.com/@dan_abramov/the-case-for-flux-379b7d1982c6 讲解得很清楚,对比了 Backbone 的 model。推荐。no fat models。使用 plain data(JSON)。

之后 @zation 使用 flux way 做了两套抽象:

router

router 封装了 window.location.hash = 'xxx' 。这块我认为没有必要,简单封装个 utility 就可以。而且 flux 的优点是清晰的数据流,缺点是要写 boilerplate 代码。到底要不要用,我们来看下 flux 的本质。

components

  • active 直接调用 action creator
  • reactive 监听 store 的变化

action creator & actions

actions 其实就是 data,或者我认为是 mutations,两种,ui 或者 server 的 response。action creator 是一个 help method,调用 dispatcher,传递 mutations。
所以,action creator 是直接调用 dispatcher(passive) 的。

stores

  • reactive 监听 action creators

dispatch 是一个 pub-sub systems。

总结下,就是
component 直接调用 action creator
store 监听 action creator
component 监听 store

了解了 flux 的实质后,我觉得就没有必要做一个这么重的 route store。route change 感觉没有充分利用到 reactive 的好处。

至于是不是 isomorphic,其实和 flux 没有关系,而是 window.location。

Page

这块的封装不是很清楚,因为我使用的是 react-router,没有仔细研究 react router 的实现原理。component 组合 component 是 react way,不过依旧在意,是否需要 flux way。

UserStore 和 UsersStore

这块我也正在想,目前我把这块的逻辑放在了 server 的 API 端,server 直接提供需要的 data 给 client 端。client 端没有复杂的 store 逻辑。我之前也尝试过 normalizr 这样的 library 来整合 store,flatten json,最终因为过于复杂而暂时放弃。这块我在论坛上已经回复过。也有人在思考 full stack flux。或者 relay 等。

因此,数据缓存这块我还没有详细思考。

再次感谢 @zation 分享。

2 Likes