[译]构建基于Flux的TodoList

原文地址: https://facebook.github.io/flux/docs/todo-list.html

让我们用一个经典的TodoMVC的应用的简单代码来展示一下Flux的结构体系。虽然完整的项目案例你可以在Github中下载得到,但是还是让我们一步步来完成开发。

最开始,我们需要一些模板和模块。基于CommonJS的Node模块系统与react-boilerplate十分适合,有利于快速启动与构建。假设你已经安装了npm,将react-boilerplate从GitHub上clone下来,从终端进入文件根目录,运行npm命令npm install, 然后npm run build, 最后npm start利用Browserify进行构建。

TodoMVC能够在此之中很好地构建,但是你需要确认react-boilerplate的package.json与TodoMVC示例的package.json具有相同的文件结构与依赖描述,否则你的代码将于下面的说明不相符合。

源代码结构

index.html文件是我们应用的顶端入口,其加载了bundle.js结果文件。我们会写入很多自己的代码到js文件夹中,这时Browserify就会帮助我们进行模块打包。当我们现在查看应用的目录,是下面这样的:

1
2
3
4
5
6
7
8
9
myapp
|
+ ...
+ js
|
+ app.js
+ bundle.js // 当我们修改js文件后,Browserify会重新打包生成bundle.js
+ index.html
+ ...

然后我们进入js文件目录,应用的主要目录结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
myapp
|
+ ...
+ js
|
+ actions
+ components // 所有React的组件, 包括视图和视图控制器
+ constants
+ dispatcher
+ stores
+ app.js
+ bundle.js
+ index.html
+ ...

创建Dispatcher(调度)

现在我们准备创建一个Dispatcher,这里有一个Dispatcher类的原生例子,使用了Jake Archibald的ES6-Promises模块。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
var Promise = require('es6-promise').Promise;
var assign = require('object-assign');
var _callbacks = [];
var _promises = [];
var Dispatcher = function() {};
// object-assign 所有自身可枚举属性复制,Dispatcher.prototype与{register, dispatch...}复制给{}
Dispatcher.prototype = assign({}, Dispatcher.prototype, {
/**
* 注册一个Store的回调函数使其能响应action
* @param {function} 要被注册的callback函数
* @return {number} _callback数组的下标值
*/
register: function(callback) {
_callbacks.push(callback);
return _callbacks.length - 1; // index
},
/**
* 调度
* @param {object} payload 来自于action的数据
*/
dispatch: function(payload) {
// 首先使每个callback对应一个promise
var resolves = [];
var rejects = [];
_promises = _callbacks.map(function(_, i) {
return new Promise(function(resolve, reject) {
resolves[i] = resolve;
rejects[i] = reject;
});
});
// 遍历callback数组,向每个callback传入payload,callback成功执行就将参数传入resolve中
_callbacks.forEach(function(callback, i) {
// 将callback(payload)的返回对象转换为promise对象
// 关注下文的waitFor()
Promise.resolve(callback(payload)).then(function() {
// 向resolve传入参数
resolves[i](payload);
}, function() {
// 向reject传入参数
rejects[i](new Error('Dispatcher callback unsuccessful'));
});
});
_promises = [];
}
});
module.exports = Dispatcher;

基础Dispatcher的公共API仅有两个方法组成: register()与dispatch(),我们将使用register()去注册每一个store的回调函数,使用dispatch()让action触发callback的回调。

对于我们的应用而言需要创建一个更为完善的调度,称为AppDispatcher

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var Dispatcher = require('./Dispatcher');
var assign = require('object-assign');
var AppDispatcher = assign({}, Dispatcher.prototype, {
/**
* 连接view与dispatcher的方法,传入action
* @param {object} action 来自view的数据
*/
handleViewAction: function(action) {
// { source: 'VIEW_ACTION', action: action }作为payload传入
this.dispatch({
source: 'VIEW_ACTION',
action: action
});
}
});
module.exports = AppDispatcher;

对于我们的需求而言现在已经比较完善了,建立了一个处理view action的函数。之后我们也可以对此进行扩展,比如对服务器数据进行更新,虽然我们目前还不需要。

创建Stores(存储)

我们使用node的EventEmitter去开展一个Store,我们需要EventEmitter向controller-views广播’change’事件。为了更为清楚地表达目的,我省去了部分代码,详细的可见TodoStore.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
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 = {}; // todo单元的集合
/**
* 创建一个TODO单元,包括id,complete,text
* @param {string} text TODO的内容
*/
function create(text) {
// 使用时间戳代替真实的id
var id = Date.now();
_todos[id] = {
id: id,
complete: false,
text: text
};
}
/**
* 根据id删除TODO单元
* @param {string} id
*/
function destroy(id) {
delete _todos[id];
}
var TodoStore = assign({}, EventEmitter.prototype, {
/**
* 得到TODOs的整个集合
* @return {object}
*/
getAll: function() {
return _todos;
},
// 触发change事件
emitChange: function() {
this.emit(CHANGE_EVENT);
},
/**
* 将callback注册change事件
* @param {function} callback
*/
addChangeListener: function(callback) {
this.on(CHANGE_EVENT, callback);
},
/**
* 移除callback的change事件
* @param {function} callback
*/
removeChangeListener: function(callback) {
this.removeListener(CHANGE_EVENT, callback);
},
// 调度注册,AppDispatcher是flux内部的注册器,上文有所简化
dispatcherIndex: AppDispatcher.register(function(payload) {
var action = payload.action;
var text;
switch(action.actionType) {
// TodoConstants中是TODO各种状态对应的键值
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;
// 更多的动作选项在这里就省略了
}
return true; // 没有出错,返回true使Dispatcher的promise更改状态
})
});
module.exports = TodoStore;

这里有许多重要的事情需要记录一下。我们开始维护_todos的数据结构,其内涵了每一个单独的to-do。虽然_todos存在于TodoStore的外部,但依然位于模块的作用域内,它依然是私有的,无法被模块外部所直接修改。这样没有action就不可能直接更新store了。

另外一个重要的部分是在dispatcher中注册store的callback。我们将带有payload的回调函数传入dispatcher以及在dispatcher内部保护store的索引。callback目前只有两种action类型,但你可以按照你的需求来添加。

监听Controller-View的改变

我们需要一个在各个组件顶部的React组件去监听store的变化。在大型的应用中,我们会拥有更多这样的监听组件,或许是每一个页面的任何部分都会拥有。在Facebook的创建工具中,我们拥有许多类似于控制器的视图(controller-like views),每一个都管理着它所从属的UI。在视频回放编辑器中,我们仅仅拥有两个:一个是动画预览,一个是图片选择接口。这里是我们TodoMVC的例子,这里依然是删减过的,详细代码请见TodoApp.react.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
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({
// 初始化status
getInitialState: function() {
return getTodoState();
},
// onChange事件,每当触发这个事件时,将Todo的状态更改一遍
_onChange: function() {
this.setState(getTodoState());
}
// 组件已经插入DOM,为TodoStore添加onChange事件
componentDidMount: function() {
TodoStore.addChangeListener(this._onChange);
},
// 组件将要被移出DOM,将onChange事件移除
componentWillUnmount: function() {
TodoStore.removeChangeListener(this._onChange);
},
/**
* @return {object}
*/
render: function() {
return (
<div>
<Header />
<MainSection
allTodos={this.state.allTodos}
areAllComplete={this.state.areAllComplete}
/>
<Footer allTodos={this.state.allTodos} />
</div>
);
},
});
module.exports = TodoApp;

现在我们处于我们所熟悉的React范围内,利用React的生命周期方法。我们利用getInitialState()初始化controller-view,利用componentDidMount()注册事件的监听,使用componentWillUnmount()进行清除。我们渲染div容器,从TodoStore得到数据传入state。

Header组件中包含应用主要的input,但是不需要知道store的状态。而组件MainSection、Footer需要这些数据,因此我们向它俩传入。

更多的View

React组件顶层结构应该是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
<TodoApp>
<Header>
<TodoTextInput />
</Header>
<MainSection>
<ul>
<TodoItem />
</ul>
</MainSection>
</TodoApp>

如果TodoItem处于编辑状态,渲染TodoTextInput作为子元素。让我们来看一看这些组件是如何将作为props的数据展示出来的,如何利用dispatcher来action通信的。MainSection需要去遍历接受自TodoApp所创建的to-do单元集合。在组件的render方法中,我们可以看到遍历器是这样的:

1
2
3
4
5
6
7
8
9
10
11
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>
);

如今,每一个Todo单元都展示自己的内容,利用他们自己的ID去执行action。解释Todo单元上所有不同的action已经超出本文的范围,你可以查看代码仓库,但是让我来看一看to-do单元的删除操作,以下是一个简略的版本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
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;

随着TodoAction中删除的action可以使用,store已经可以被操作了,与用户的行为进行交互来改变应用的state再简单不过。我们通过所提供的id,利用click事件触发删除的。现在用户可以点击删除的按钮,开始Flux流程去更新应用了。

另一方面, Text input就有一些复杂因为我们需要等待在React组件中text input本身的state。让我们来看看TodoTextInput是如何工作的。

让我们看看下面这段代码,React期望我们更新component的state随着input每一次的改变。所以当我们最终将文本保存进input时,我们将值储存于组件的state中。这是UI的state,而不是应用的,保持这个差异很好地指引了state应该在哪里被储存。所有应用的state应存在于store,虽然React组件偶尔会保持在UI state中,理想中应尽可能少得保持state。

因为TodoTextInput在我们的应用中被应用于多处,具有不同的行为,我们需要通过onSave方法,传自组件父层的prop。这样就允许通过传入不同的onSave去执行不同的操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
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 {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}
/>
);
},
/**
* 传入onSave能够提高这个组件的可扩展性和复用性,onSave可能是建立一条记录,也可能是修改一条记录
*/
_save: function() {
// app的state
this.props.onSave(this.state.value);
this.setState({
value: ''
});
},
/**
* @param {object} event
*/
_onChange: function(/*object*/ event) {
// UI的state
this.setState({
value: event.target.value
});
},
/**
* @param {object} event
*/
_onKeyDown: function(event) {
if (event.keyCode === ENTER_KEY_CODE) {
this._save();
}
}
});
module.exports = TodoTextInput;

Header中的onSave就是让TodoTextInput去创建一个新的to-do单元:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
var React = require('react');
var TodoActions = require('../actions/TodoActions');
var TodoTextInput = require('./TodoTextInput.react');
var Header = React.createClass({
/**
* @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>
);
},
/**
* 在TodoTextInput内被调用,用于创建
* @param {string} text
*/
_onSave: function(text) {
TodoActions.create(text);
}
});
module.exports = Header;

在不同的环境中,就像编辑一个已经存在的to-do单元,_onSave的回调函数就需要用TodoActions.update(text)来代替。

创建语义化的actions

这是最基础的代码在我们的view中所使用的action:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
/**
* TodoActions
*/
var AppDispatcher = require('../dispatcher/AppDispatcher');
var TodoConstants = require('../constants/TodoConstants');
var TodoActions = {
/**
* @param {string} text
*/
create: function(text) {
AppDispatcher.handleViewAction({
actionType: TodoConstants.TODO_CREATE,
text: text
});
},
/**
* @param {string} id
*/
destroy: function(id) {
AppDispatcher.handleViewAction({
actionType: TodoConstants.TODO_DESTROY,
id: id
});
},
};
module.exports = TodoActions;

你可以看到,我们并不需要AppDispatcher.handleViewAction()或是TodoActions.create()这样的帮助。在理论上,直接定义为AppDispatcher.dispatch(),向其提供payload。但随着我们应用的增长,具有帮助能使我们的代码保持整洁与语义化。清楚地定义TodoActions.destroy(id)好过于写了一堆无法明白的东西。

TodoActions.create()生成payload像这样:

1
2
3
4
5
6
7
{
source: 'VIEW_ACTION',
action: {
type: 'TODO_CREATE',
text: 'Write blog post about Flux'
}
}

payload通过所注册的回调函数被提供给TodoStore,TodoStore广播’change’事件,MainSection会做出响应,从TodoStore中获取最新的to-do集合,然后改变他的state。这个改变来源于TodoApp组件所定义的render()方法,其它组件的render方法都是它的子代。

从顶层开始

在我们的应用中,引导文件是app.js,仅含有TodoApp组件,render作为应用的根元素。

1
2
3
4
5
6
7
8
var React = require('react');
var TodoApp = require('./components/TodoApp.react');
React.render(
<TodoApp />,
document.getElementById('todoapp')
);

为Dispatcher添加依赖管理

在我之前所说的,我们的Dispatcher是原生的,这是十分棒的,但不能满足大多数应用。与Store之间,我们需要一个方法去管理依赖,所以我们设计了waitFor()在Dispatcher内。

waitFor()方法返回一个Promise,这个Promise依次序返回Store的回调函数。

1
2
3
4
5
6
7
8
9
10
/**
* @param {array} promiseIndexes
* @param {function} callback
*/
waitFor: function(promiseIndexes, callback) {
var selectedPromises = promiseIndexes.map(function(index) {
return _promises[index];
});
return Promise.all(selectedPromises).then(callback);
}

现在对于TodoStore的回调函数我们可以明确地等待任何依赖先更新后再向前推进。然而StoreA等待StoreB,StoreB等待StoreA,会出现循环依赖。面对这一情况,一个健壮的dispatcher可在控制台中发出警告。

Flux的未来

很多人问FaceBook是否打算将flux作为一个开源框架。事实上,Flux代表是一种结构,而不是框架。这个项目作为Flux的模板工程会十分有意义。请让我们知道你喜欢我们所做的。

感谢你花时间阅读如何构建客户端应用在Facebook中。我们希望我们能向你证明Flux是有用的。