本文通过实现一个 TodoMVC 应用来说明一个 Flux 应用的结构是怎样的。本文会告诉你如何一步一步地实现这个应用,完整的源代码可以从 Github 下载。
首先,我们需要
我们可以从 react-boilerplate 这个模板开始。你应该已经安装了 Node.js 了吧,那么就直接从 github 将 react-boilerplate clone 下来,进入目录,依次运行 npm install
npm run build
和 npm start
,然后我们的 jsx 文件就会被持续地编译为 js 文件了。
我们的 TodoMVC 应用也是基于这个模板。现在你可以按照 TodoMVC 的 package.json 来编辑你自己的 package.json 文件,确保你的文件结构和 dependencies 与 TodoMVC 的一致(译注:最好直接拷贝 package.json 到你自己的目录),否则下文中的代码可能无法运行。
index.html
文件是应用的入口,它会加载 bundle.js
,js 目录下的文件会被自动合并为 bundle.js
,合成工作由 Browserify 负责。我们的项目目录的结构如下所示:
myapp
|
+ ...
+ js
|
+ actions
+ components // React 组件都放在这样,包括 views 和 controller-views
+ constants
+ dispatcher
+ stores
+ app.js
+ bundle.js // 由 Browserify 自动生成
+ index.html
+ ...
现在我们就可以创建派发器了。下面实现了一个简单的派发器,用到了 Promise,对于不支持 ES6 Promise 的浏览器,使用 es6-promise 来兼容。
// js/dispatcher/Dispatcher.js
var Promise = require('es6-promise').Promise;
var assign = require('object-assign');
var _callbacks = [];
var _promises = [];
var Dispatcher = function() {};
Dispatcher.prototype = assign({}, Dispatcher.prototype, {
/**
* 注册一个数据仓库的回调,可以被一个 action 触发。
* Register a Store's callback so that it may be invoked by an action.
* [@param](/user/param) {function} callback 需要注册的回调函数.
* [@return](/user/return) {number} 返回 callback 在 _callback 数组中的索引.
*/
register: function(callback) {
_callbacks.push(callback);
return _callbacks.length - 1; // index
},
/**
* 派发
* dispatch
* [@param](/user/param) {object} payload The data from the action.
*/
dispatch: function(payload) {
// First create array of promises for callbacks to reference.
var resolves = [];
var rejects = [];
_promises = _callbacks.map(function(_, i) {
return new Promise(function(resolve, reject) {
resolves[i] = resolve;
rejects[i] = reject;
});
});
// Dispatch to callbacks and resolve/reject promises.
_callbacks.forEach(function(callback, i) {
// Callback can return an obj, to resolve, or a promise, to chain.
// See waitFor() for why this might be useful.
Promise.resolve(callback(payload)).then(function() {
resolves[i](payload);
}, function() {
rejects[i](new Error('Dispatcher callback unsuccessful'));
});
});
_promises = [];
}
});
module.exports = Dispatcher;
这个派发器只有两个方法:注册(register)和派发(dispatch)。register() 用于注册一个回调函数。dispatch() 用于在动作(actions)发生后触发这些回调。
接下来我们创建 AppDispatcher,它基于 Dispatcher,只不过在 Dispatcher 的基础上添加了 handleViewAction 方法:
js/dispatcher/AppDispatcher.js
var Dispatcher = require('./Dispatcher');
var assign = require('object-assign');
var AppDispatcher = assign({}, Dispatcher.prototype, {
/**
* A bridge function between the views and the dispatcher, marking the action
* as a view action. Another variant here could be handleServerAction.
* [@param](/user/param) {object} action The data coming from the view.
*/
handleViewAction: function(action) {
this.dispatch({
source: 'VIEW_ACTION',
action: action
});
}
});
module.exports = AppDispatcher;
现在我们拥有了一个简单好用的派发器,它有一个 handleViewAction 方法用来处理视图(View)中发出的动作(Action)。如果之后我们需要向服务器发出请求,可以再添加一个方法,不过现在这样已经可以了。
你可以借助 Node 的事件发射器(EventEmitter)来创建数据仓库。事件发射器用来向控制视图(Controller-View)广播 change 事件。代码如下,为了简洁我删除了部分代码,完整的代码见 TodoStore.js。
var AppDispatcher = require('../dispatcher/AppDispatcher');
var EventEmitter = require('events').EventEmitter;
var TodoConstants = require('../constants/TodoConstants');
var assign = require('object-assign');
var CHANGE_EVENT = 'change';
var _todos = {}; // collection of todo items
/**
* 创建一个 Todo 项
* [@param](/user/param) {string} text The content of the TODO
*/
function create(text) {
// Using the current timestamp in place of a real id.
var id = Date.now();
_todos[id] = {
id: id,
complete: false,
text: text
};
}
/**
* 删除一个 Todo 项
* [@param](/user/param) {string} id
*/
function destroy(id) {
delete _todos[id];
}
var TodoStore = assign({}, EventEmitter.prototype, {
/**
* 获取所有 Todo 项
* [@return](/user/return) {object}
*/
getAll: function() {
return _todos;
},
emitChange: function() {
this.emit(CHANGE_EVENT);
},
/**
* [@param](/user/param) {function} callback
*/
addChangeListener: function(callback) {
this.on(CHANGE_EVENT, callback);
},
/**
* [@param](/user/param) {function} callback
*/
removeChangeListener: function(callback) {
this.removeListener(CHANGE_EVENT, callback);
},
dispatcherIndex: AppDispatcher.register(function(payload) {
var action = payload.action;
var text;
switch(action.actionType) {
case TodoConstants.TODO_CREATE:
text = action.text.trim();
if (text !== '') {
create(text);
TodoStore.emitChange();
}
break;
case TodoConstants.TODO_DESTROY:
destroy(action.id);
TodoStore.emitChange();
break;
// add more cases for other actionTypes, like TODO_UPDATE, etc.
}
return true; // No errors. Needed by promise in Dispatcher.
})
});
module.exports = TodoStore;
代码中有一下几点需要注意:
一,我们用一个私有数组 _todos
来存储所有 Todo 项。由于它是闭包内的一个局部变量,所以外部不能直接访问它,只能通过动作(action)来更新数据。借此我们就控制了数据的流动(the flow of data)。
二,注意数据仓库是如何注册回调的:把回调函数传给 AppDispather.register()
,然后保留派发器的索引(index)。目前回调函数只处理两种动作(TODO_CREATE
和 TODO_DESTROY
),不过很快我们就会添加更多动作。
我们需要在组件的顶层添加一个组件来监听数据的所有变化。在大型项目中,你可能需要不止一个这样的组件,比如为页面的每个区块创建一个控制视图。在 Facebook 的广告创建工具中,我们有很多这样的控制视图,每个视图负责页面上的一块 UI。在我们的视频编辑器项目中,我们只有两个这样的组件,一个负责动画预览界面,一个负责图片选取界面。
下面是我们的实现。同样它被简化过,完整的代码见 TodoApp.react.js。
var Footer = require('./Footer.react');
var Header = require('./Header.react');
var MainSection = require('./MainSection.react');
var React = require('react');
var TodoStore = require('../stores/TodoStore');
function getTodoState() {
return {
allTodos: TodoStore.getAll()
};
}
var TodoApp = React.createClass({
getInitialState: function() {
return getTodoState();
},
componentDidMount: function() {
TodoStore.addChangeListener(this._onChange);
},
componentWillUnmount: function() {
TodoStore.removeChangeListener(this._onChange);
},
/**
* [@return](/user/return) {object}
*/
render: function() {
return (
<div>
<Header />
<MainSection
allTodos={this.state.allTodos}
areAllComplete={this.state.areAllComplete}
/>
<Footer allTodos={this.state.allTodos} />
</div>
);
},
_onChange: function() {
this.setState(getTodoState());
}
});
module.exports = TodoApp;
你终于看到了你熟悉的 React 代码了,这里用到了 React 的诸多与组件生命周期相关的方法:
getInitialSate()
中对视图进行初始化componentDidMount()
中创建事件监听componentWillUnmount()
中清理现场Header 组件只包含文字输入框,不需要数据; MainSection 组件和 Footer 组件则需要数据。
我们整个应用的 React 组件结构是这样的:
<TodoApp>
<Header>
<TodoTextInput />
</Header>
<MainSection>
<ul>
<TodoItem />
</ul>
</MainSection>
</TodoApp>
当 TodoItem 组件处于编辑状态时,它还会渲染出一个 TodoTextInput 组件作为其子元素。
现在我们来看看
MainSection 通过遍历 TodoApp 传来的数据,来创建一组 TodoItem。在 MainSection 的 render() 方法如下:
var allTodos = this.props.allTodos;
for (var key in allTodos) {
todos.push(<TodoItem key={key} todo={allTodos[key]} />);
}
return (
<section id="main">
<ul id="todo-list">{todos}</ul>
</section>
);
每个 TodoItem 显示自己的内容,触发动作时会带上自己的 ID。本文不打算把 TodoItem 触发的所有动作都讲到,只以删除动作为例。下面是一个 TodoItem 的简单实现:
var React = require('react');
var TodoActions = require('../actions/TodoActions');
var TodoTextInput = require('./TodoTextInput.react');
var TodoItem = React.createClass({
propTypes: {
todo: React.PropTypes.object.isRequired
},
render: function() {
var todo = this.props.todo;
return (
<li
key={todo.id}>
<label>
{todo.text}
</label>
<button className="destroy" onClick={this._onDestroyClick} />
</li>
);
},
_onDestroyClick: function() {
TodoActions.destroy(this.props.todo.id);
}
});
module.exports = TodoItem;
TodoActions 已经实现了 destroy 方法,而且数据仓库会负责更新数据,这样我们就能很容易地将用户的操作与应用的状态(Application State)连接起来。我们只需在点击事件里调用 destroy 方法,并传入 Todo 项的 ID,就行了。
现在用户一点击删除按钮,Flux 的数据流就会启动,页面的状态就会相应地发生变化。
输入框稍微复杂一点,因为你要在 TodoTextInput 里单独维护组件自己的状态。那么我们就来看看应该如何来实现。
React 建议一旦输入框的值有变化,组件的状态就应该立即做出相应的变化。所以我们可以把值保存在组件的状态中。你要记住这是组件的状态(UI State),而不是应用的状态(Application State)。
应用的状态永远存在数据仓库中,而组件的状态由组件自行维护。理想的情况下,组件的状态应该尽量少。
由于 TodoTextInput 会在应用的多个地方用到,每个地方可能有不同的行为,所以我们需要父组件把 onSave 作为 prop 参数传给 TodoTextInput。
var React = require('react');
var ReactPropTypes = React.PropTypes;
var ENTER_KEY_CODE = 13;
var TodoTextInput = React.createClass({
propTypes: {
className: ReactPropTypes.string,
id: ReactPropTypes.string,
placeholder: ReactPropTypes.string,
onSave: ReactPropTypes.func.isRequired,
value: ReactPropTypes.string
},
getInitialState: function() {
return {
value: this.props.value || ''
};
},
/**
* [@return](/user/return) {object}
*/
render: function() /*object*/ {
return (
<input
className={this.props.className}
id={this.props.id}
placeholder={this.props.placeholder}
onBlur={this._save}
onChange={this._onChange}
onKeyDown={this._onKeyDown}
value={this.state.value}
autoFocus={true}
/>
);
},
/**
* Invokes the callback passed in as onSave, allowing this component to be
* used in different ways.
*/
_save: function() {
this.props.onSave(this.state.value);
this.setState({
value: ''
});
},
/**
* [@param](/user/param) {object} event
*/
_onChange: function(/*object*/ event) {
this.setState({
value: event.target.value
});
},
/**
* [@param](/user/param) {object} event
*/
_onKeyDown: function(event) {
if (event.keyCode === ENTER_KEY_CODE) {
this._save();
}
}
});
module.exports = TodoTextInput;
Header 组件传给 TodoTextInput 的 onSave 会新建一个 Todo 项。
var React = require('react');
var TodoActions = require('../actions/TodoActions');
var TodoTextInput = require('./TodoTextInput.react');
var Header = React.createClass({
/**
* [@return](/user/return) {object}
*/
render: function() {
return (
<header id="header">
<h1>todos</h1>
<TodoTextInput
id="new-todo"
placeholder="What needs to be done?"
onSave={this._onSave}
/>
</header>
);
},
/**
* Event handler called within TodoTextInput.
* Defining this here allows TodoTextInput to be used in multiple places
* in different ways.
* [@param](/user/param) {string} text
*/
_onSave: function(text) {
TodoActions.create(text);
}
});
module.exports = Header;
以此类推,在 TodoItem 的编辑模式中,我们可以把 TodoActions.update(text) 作为 onSave 的值。
下面我们用到的两个动作 create 和 destroy 的简单实现:
/**
* TodoActions
*/
var AppDispatcher = require('../dispatcher/AppDispatcher');
var TodoConstants = require('../constants/TodoConstants');
var TodoActions = {
/**
* [@param](/user/param) {string} text
*/
create: function(text) {
AppDispatcher.handleViewAction({
actionType: TodoConstants.TODO_CREATE,
text: text
});
},
/**
* [@param](/user/param) {string} id
*/
destroy: function(id) {
AppDispatcher.handleViewAction({
actionType: TodoConstants.TODO_DESTROY,
id: id
});
},
};
module.exports = TodoActions;
你可能会说,AppDispatcher.handleViewAction
和 TodoActions.create()
是多余的。确实,理论上我们可以直接调用 AppDispatcher.dispatch()
来达到目的。但是随着我们的项目越来越大,添加这些方法能保持代码干净易读。
传给 TodoAction.create()
的数据结构如下:
{
source: 'VIEW_ACTION',
action: {
type: 'TODO_CREATE',
text: 'Write blog post about Flux'
}
}
数据是通过 TodoStore 注册的回调函数送达给 TodoStore 的。然后 TodoStore 广播 change
事件,MainSection 听到消息,拿到最新数据,更新自己的状态。状态的更新使得 TodoApp 组件的 render() 方法被触发,TodoApp 所有后代组件的 render() 方法也被触发。
应用的启动文件是 app.js,其内容很简单,就是拿到 TodoApp,然后在网页中渲染它。
var React = require('react');
var TodoApp = require('./components/TodoApp.react');
React.render(
<TodoApp />,
document.getElementById('todoapp')
);
上文说过,我们实现的 Dispatcher 太简单。虽然它可以用,但是对大多数应用来说,它还不够好。因为我们需要管理储存时的依赖关系,有些数据的存储要等其他数据存完了才能进行。那么我们给 Dispatcher 添加一个 waitFor() 方法吧。
waitFor() 是一个公共方法,返回一个 Promise 对象:
/**
* [@param](/user/param) {array} promisesIndexes
* [@param](/user/param) {function} callback
*/
waitFor: function(promiseIndexes, callback) {
var selectedPromises = promiseIndexes.map(function(index) {
return _promises[index];
});
return Promise.all(selectedPromises).then(callback);
}
现在我们就可以在 TodoStore 里,等数据更新好了之后,再继续其他操作。
不过,有可能会出现循环依赖。一个更加健壮的 Dispatcher 应该在遇到循环依赖时,在控制台里发出警告。
很多人问 Facebook 是否会将 Flux 开源。请不要搞笑,Flux 只是一种架构,不是框架,如何发布呢?不过发布一套 Flux 模板倒是有可能的,前提是有足够多的人需要。如果你有需要,请告诉我们。
原文(有删改)
(全文完)