一点背景

React在设计之初就只关注在View本身上,其余部分如数据的获取事件处理等,全然不在考虑之内。不过构建大型的Web前端应用,这些点又实在不可避免。所以Facebook的工程师提出了前端的Flux架构,这个架构的最大特点是单向数据流(后面详述)。但是Flux本身的实现有很多不合理的地方,比如单例的Dispatcher会在系统中有多种事件时导致臃肿的switch-cases等。

这里是Facebook官方提供的提供Flux的结构图

Flux Diagram

其实整个Flux背后的思想也不是什么新东西。在很久之前,Win32的消息机制(以及很多的GUI系统)就在使用这个模型,而且这也是一种被证实可以用来构建大型软件的模型。

鉴于Flux本身只是一个架构,而且Facebook提供的参考实现又有一些问题,所以社区有了很多版本的Flux实现。比如我们这里会用到的Reflux

Reflux简介

简而言之,Reflux里有两个组件:Store和Action。Store负责和数据相关的内容:从服务器上获取数据,并更新与其绑定的React组件(view controller);Action是一个事件的集合。Action和Store通过convention来连接起来。

具体来说,一个典型的过程是:

  1. 用户的动作(或者定时器)在组件上触发一个Action
  2. Reflux会调用对应的Store上的callback(自动触发)
  3. 这个callback在执行结束之后,会显式的触发(trigger)一个数据
  4. 对应的组件(可能是多个)的state会被更新
  5. React组件检测到state的变化后,会自动重绘自身

reflux data flow

一个例子

我们这里将使用React/Reflux开发一个实际的例子,从最简单的功能开始,逐步将其构建为一个较为复杂的应用。

这个应用是一个书签展示应用(数据来源于我的Google Bookmarks)。第一个版本的界面是这样的:

bookmarks list

要构建这样一个列表应用,我们需要这样几个部分:

  1. 一个用来fetch数据,存储数据的store (BookmarkStore)
  2. 一个用来表达事件的Action(BookmarkActions)
  3. 一个列表组件(BookmarkList)
  4. 一个组件条目组件(Bookmark)

定义Actions

var Reflux = require('reflux');
var BookmarkActions = Reflux.createActions([
	'fetch'
]);

module.exports = BookmarkActions;

第一个版本,我们只需要定义一个fetch事件即可。然后在Store中编写这个Action的回调:

定义Store

var $ = require('jquery');
var Reflux = require('reflux');
var BookmarkActions = require('../actions/bookmark-actions');

var Utils = require('../utils/fetch-client');

var BookmarkStore = Reflux.createStore({
	listenables: [BookmarkActions],

	init: function() {
		this.onFetch();
	},

	onFetch: function() {
		var self = this;
		Utils.fetch('/bookmarks').then(function(bookmarks) {
			self.trigger({
				data: bookmarks,
				match: ''
			});
		});
	}
});

module.exports = BookmarkStore;

此处,我们使用listenables: [BookmarkActions]来将Store和Action关联起来,根据conventionon+事件名称就是回调函数的名称。这样当Action被触发的时候,onFetch会被调用。当获取到数据之后,这里会显式的trigger一个数据。

List组件

var React = require('react');
var Reflux = require('reflux');

var BookmarkStore = require('../stores/bookmark-store.js');
var Bookmark = require('./bookmark.js');

var BookmarkList = React.createClass({
	mixins: [Reflux.connect(BookmarkStore, 'bookmarks')],

	getInitialState: function() {
		return {
			bookmarks: {data: []}
		}
	},

	render: function() {
		var list = [];
		this.state.bookmarks.data.forEach(function(item) {
	      list.push(<Bookmark title={item.title} created={item.created}/>)
	    });
	    
		return <ul>
			{list}
		</ul>
	}
});

module.exports = BookmarkList;

在组件中,我们通过mixins: [Reflux.connect(BookmarkStore, 'bookmarks')]将Store和组件关联起来,这样List组件state上的bookmarks就和BookmarkStore连接起来了。当state.bookmarks变化之后,render方法就会被自动调用。

对于每一个书签,就只是简单的展示内容即可:

Bookmark组件

var React = require('react');
var Reflux = require('reflux');
var moment = require('moment');

var Bookmark = React.createClass({

	render: function() {
		var created = new Date(this.props.created * 1000);
		var date = moment(created).format('YYYY-MM-DD');

		return <li>
			<div className='bookmark'>
				<h5 className='title'>{this.props.title}</h5>
				<span className='date'>Created @ {date}</span>
			</div>
		</li>;
	}
});

module.exports = Bookmark;

这里我使用了moment库将unix timestamp转换为日期字符串,然后写在页面上。

最后,Utils只是一个对jQuery的包装:

var $ = require('jquery');
var Promise = require('promise');

module.exports = {
    fetch: function(url) {
        var promise = new Promise(function (resolve, reject) {
            $.get(url).done(resolve).fail(reject);
        });

        return promise;
    }
}

我们再来总结一下,BookmarkStore在初始化的时候,显式地调用了onFetch,这个动作会导致BookmarkList组件的state的更新,这个更新会导致BookmarkList的重绘,BookmarkList会依次迭代所有的Bookmark

更复杂一些

当然,Reflux的好处不仅仅是上面描述的这种单向数据流。当StoreActions以及具体的组件被解耦之后,构建大型的应用才能成为可能。我们来对上面的应用做一下扩展:我们为列表添加一个搜索功能。

随着用户的键入,我们发送请求到服务器,将过滤后的数据渲染为新的列表。我们需要这样几个东西

  1. 一个SearchBox组件
  2. 一个新的Actionsearch
  3. BookmarkStore上的一个新方法onSearch
  4. 组件SearchBox需要和BookmarkActions关联起来

为了让用户看到匹配的效果,我们需要将匹配到的关键字高亮起来,这样我们需要在Bookmark组件中监听BookmarkStore,当BookmarkStore发生变化之后,我们就可以即时修改书签的title了。

搜索框组件

var React = require('react');
var BookmarkActions = require('../actions/bookmark-actions');

var SearchBox = React.createClass({
	performSearch: function() {
		var keyword = this.refs.keyword.value;
		BookmarkActions.search(keyword);
	},

	render: function() {
		return <div className="search">
			<input type='text' 
				placeholder='type to search...' 
				ref="keyword"
				onChange={this.performSearch} />	
		</div>
	}
});

module.exports = SearchBox;

BookmarkStore

var $ = require('jquery');
var Reflux = require('reflux');
var BookmarkActions = require('../actions/bookmark-actions');

var Utils = require('../utils/fetch-client');

var BookmarkStore = Reflux.createStore({
	listenables: [BookmarkActions],

	init: function() {
		this.onFetch();
	},

	onFetch: function() {
		var self = this;
		Utils.fetch('/bookmarks').then(function(bookmarks) {
			self.trigger({
				data: bookmarks,
				match: ''
			});
		});
	},

	onSearch: function(keyword) {
		var self = this;

		Utils.fetch('/bookmarks?keyword='+keyword).then(function(bookmarks) {
			self.trigger({
				data: bookmarks,
				match: keyword
			});
		});
	}
});

module.exports = BookmarkStore;

我们在BookmarkStore中添加了onSearch方法,它会根据关键字来调用后台API进行搜索,并将结果trigger出去。由于数据本身的结构并没有变化(只是数量会由于过滤而变少),因此BookmarkList是无需修改的。

书签高亮

当搜索匹配之后,我们可以将对应的关键字高亮起来,这时候我们需要修改Bookmark组件:

var React = require('react');
var Reflux = require('reflux');
var moment = require('moment');

var BookmarkStore = require('../stores/bookmark-store.js');

var Bookmark = React.createClass({
	mixins: [Reflux.listenTo(BookmarkStore, 'onMatch')],

    onMatch: function(data) {
        this.setState({
            match: data.match
        });
    },

	getInitialState: function() {
		return {
			match: ''
		}
	},

	render: function() {
		var created = new Date(this.props.created * 1000);
		var date = moment(created).format('YYYY-MM-DD');

		var title = this.props.title;
		if(this.state.match.length > 0) {
			title = <span
		        dangerouslySetInnerHTML={{
		          __html : this.props.title.replace(new RegExp('('+this.state.match+')', "gi"), '<span class="highlight">$1</span>')
		        }} />
		}

		return <li>
			<div className='bookmark'>
				<h5 className='title'>{title}</h5>
				<span className='date'>Created @ {date}</span>
			</div>
		</li>;
	}
});

module.exports = Bookmark;

mixins: [Reflux.listenTo(BookmarkStore, 'onMatch')]表示,我们需要监听BookmarkStore的变化,当变化发生时,调用OnMatch方法。OnMatch会修改组件的match属性,从而触发render

render中,我们替换关键字为<span class="highlight">$keyword</span>,这样就可以达到预期的效果了:

bookmark search

结论

从上面的例子可以看到,我们从一开始就引入了Reflux。虽然第一个版本和React原生的写法差异并不是很大,但是当加入SearchBox功能之后,需要修改的地方非常清晰:添加Actions,在对应的Store中添加callback,然后在组件中使用。这种方法不仅可以最大程度的使用React的长处(diff render),而且使得代码逻辑变得较为清晰。

随着业务代码的不断增加,Reflux提供的方式确实可以在一定程度上控制代码的复杂性和可读性。

完整的代码地址在这里

其他参考