由于 React 的单向数据流的设计,衍生出了单向数据流的架构模式 Flux。
在 MVC 的分层架构中,Flux 属于 M 层,也就是 Model,而在 Flux 中,Store 是关键部分,Action 和 Dispatcher 都是围绕着 Store 来设计的,所以 Flux 架构模式的目标就是基于单向数据流如何更好的管理数据,在 Views
或 Controller-views
与数据之间进行解耦。
我在之前的 React 应用的架构模式 Flux 有详细的介绍过 Flux 架构模式及其应用。
"Flux is more of a pattern than a framework."
这是 Flux 在其 github 主页上截取的一句话,翻译成中文就是:Flux 更像是一种架构模式,而不是一个单纯的框架。
从实际使用的感受来看,Flux 作为一种架构模式还是很不错的,方向是正确的,但是作为框架来使用,从这个层面来说,存在着一些问题。正是由于这些问题促使我重新基于 Flux 的架构模式开发了 Ballade。先来介绍一些 Ballade 的架构模式,之后再说说在使用 Flux 框架时碰到的一些问题以及 Ballade 是如何解决这些问题的。
Ballade 的架构介绍
Store
Store 是一个数据的存储中心,提供了「写入」和「读取」数据的接口,就像一个数据的「访问器」。
在 Views
或 Controller-views
(React 组件)中,只能从 Store 中「读取」数据,在 Store Callbacks 中,才能自由的「写入」和「读取」数据。
当数据变化的时候,Store 会发送一个数据变化的事件。
Store 的数据「访问器」分为 mutable(可变)和 immutable(不可变)的两种,分别对应了mutable 和 immutable 两种不同的数据结构。
Actions
所有的操作,像用户的交互行为或者从服务器获取一个数据,只要会引起数据变化的操作都可以把它看作是一个 Action,引起数据变化的操作可以是「写入」或「更新」数据。
如果想「写入」或「更新」Store 中的数据,只能发起一个 Action。每个 Action 都有一个唯一的 ActionType 和 payload 数据,ActionType 可以理解为这个 Action 唯一的名字,payload 数据就是传递给 Store 的数据。
Dispatcher
Dispatcher 用于连接 Actions 和 Store 的「调度员」,负责将 Action 产生的 payload 数据分发给指定的 Store。
Actions Middleware
在传送 payload 数据到 Store 时,可以注册一些中间件来处理 payload 数据,每个中间件会把处理完的 payload 结果再传递给下一个中间件。假如你想从服务器取数据,可以注册一个中间件。
Store Callbacks
当 Action 触发的时候,Store 需要一个与该 Action 对应的回调函数来处理 payload 数据,这时可以将数据写入到 Store 中。ActionType 需要与 Store 的回调函数名相对应。
与其它框架设计的异同
Ballade 在架构上和 Flux 很像,在一些细节问题上比 Flux 处理的更好或者说更具有约束性。
- 强化了 Store 的功能,正如我前面提到的 Flux 架构中 Store 是关键;
- 增加了 Actions Middleware 用于集中处理 Action,一方面简化了 Action 的功能,另一方面便于扩展;
- Store Callbacks 的设计也做了简化,提升了封装性;
Actions Middleware 借鉴了 Redux 的 Middleware 的设计(当我知道 Redux 有 Middleware 但并不知道其实际应用和实现细节的时候,我就很自然的把这种 Middleware 设计用于了 Action)。Store Callbaks 与 Redux 的 Reducer 有些类似,但是对于已经熟悉了 Flux 架构的开发者来说,Store Callbacks 更好理解。
架构介绍完毕之后说说我以及我所在的团队在实际使用 Flux 框架时碰到的一些问题,相信其他使用 Flux 的开发者都会碰到这些类似的问题。
Flux 中的 Actions
先来看一段 Flux 的 Actions 的代码:
注意:本文所有的示例代码都是基于 ES6 语法的 JavaScript 代码。
// Flux 的代码
const actions = {
fetchDatabasesource (query, type) {
const url = `http://${window.__API_DOMAIN__}/api/database/datasource/${type}/${query}`;
dispatcher.dispatchAsync({
url
}, {
request: constants.FETCH_DATABASESOURCE,
success: constants.FETCH_DATABASESOURCE_SUCCESS,
failure: constants.FETCH_DATABASESOURCE_ERROR
}, {
query: query
});
}
};
这是一段从服务端获取数据的 Action 的代码,dispatcher.dispatchAsync
封装了一个 fetch 方法用于取数据,该方法的实现可以查看在 github 的例子 dispatcher.js,该方法实际发起了三个 Action。
这里存在两个问题,一个是代码冗余的问题,(代码冗余的问题并不完全是由于框架本身造成的,也有可能是开发者的实现不够优雅)。还有一个问题就是职责不明的问题,取数据的操作到底该在哪里进行呢,是在 Action 中还是 Store Callback 中?这在 Flux 框架中并没有明确的约束。
注册 Actions 的中间件
在 Ballade 中,我们将从服务端取数据的 fetch 方法封装成一个 Actions Middleware,并注册到 dispatcher
上。
// Ballade 的代码
dispatcher.use((payload, next) => {
// 可以通过中间件中是否包含 uri 字段来判断是否使用 fetch 来取数据
if (payload.uri) {
fetch(payload.uri).then(function(response){
return response.json();
})
.then(function(response){
payload.response = response;
// 异步函数中的 next 回调
next(payload);
});
}
// 如果不包含 uri 字段,则不作任何处理
else {
next(payload);
}
});
Ballade 中简化的 Actions
然后上面的 Actions 代码在 Ballade 中可以简化成下面这样:
// Ballade 的代码
const actions = dispatcher.createActions({
fetchDatabasesource (query, type) ({
uri: `http://${window.__API_DOMAIN__}/api/database/datasource/${type}/${query}`,
query: query
})
});
在 Ballade 的 Action 中,直接返回 ActionType 和 payload 数据即可(这里使用了 ES6 的箭头函数省略了 return
关键字)。
Flux 中的 Store Callback
继续发一段 Flux Store Callback 的代码。
// Flux 的代码
libraryPermissionsStore.dispatchToken = dispatcher.register((action) => {
switch(action.type) {
case constants.FETCH_DATABASESOURCE:
updateDatabasesource({
isLoading: true
},action.query);
libraryPermissionsStore.emit(constants.CHANGE_DATABASESOURCE_EVENT);
break;
case constants.FETCH_DATABASESOURCE_SUCCESS:
action.response.isLoading = false;
updateDatabasesource(action.response, action.query);
libraryPermissionsStore.emit(constants.CHANGE_DATABASESOURCE_EVENT);
break;
...
});
这段代码和上面的创建 Actions 的代码相对应,用于存储数据到 Store 中并发出数据变化的通知(这样Views
或 Controller-views
才能接收到通知),它是一个大的 Callback 函数,使用 switch case 语句与 ActionType 对应,但是 switch case 容易造成代码冗余,并且每个 case 语句中都需要手动发送一次通知。一个大的 Callback 函数中如果 switch case 语句过多还存在着变量无意中共享作用域的隐患。
Ballade 中简化的 Store Callback
Ballade 在 Store Callbacks 中将大的 Callback 和 switch case 语句拆分成了一个个独立的 Callbacks,并且使用函数的 name 与 ActionType 进行对应。
// Ballade 的代码
const exampleStore = dispatcher.createMutableStore(schema, {
'example/update-title': function (store, action) {
return store.mutable.set('title', action.title);
},
...
});
先看看 example/update-title
这个 Store Callback,没有了 switch case 语句,也无需每次都发送一个数据变化的通知,只要返回 Store 的 set 或 delete 操作的结果,框架内部会自动发送通知。
schema
在 Ballade 中,要创建一个 Store 需要先声明 schema
。
const schema = {
title: null,
meta: {
votes: null,
favs: null
}
};
schema
就是该 Store 的数据模型,只有数据的 key 在 schema
中声明过,才能对其进行「写入」或「更新」数据,这可以让开发者知道该 Store 中有哪些数据,能让数据操作更加清晰透明。
假如 schema
中并没有声明 author
,而又想直接存储该数据到 Store 中是不会生效的,或者直接报错。
// 无效的操作
store.mutable.set('author', action.author);
// 报错
store.immutable.set('author', action.author);
mutable & immutable
Ballade 的 Store 对 mutable 和 immutable 两种数据结构都支持。
在默认的 ballade.js 版本中,提供的是 mutable 版本,使用 dispatcher.createMutableStore
就可以创建一个 mutable 的 Store,mutable 数据的「写入」和「读取」都需要通过 store.mutable
这个数据访问器。
在 ballade.immutable.js 版本中,提供了 mutable 和 immutable 两个版本,但是需要依赖 immtable-js。使用 dispatcher.createImmutableStore
就可以创建一个 immutable 的 Store,immutable 数据的「写入」和「读取」都需要通过 store.immutable
这个数据访问器,实际上 store.immutable
就是基于 Immutable 实例的一个封装。
「写入」和「读取」分离
通过 dispatcher.createMutableStore
或 dispatcher.createImmutableStore
创建的 Store 用于在 Views
或 Controller-views
中「读取」数据,它们不能直接「写入」数据。
// 从 store 中取出 titile
exampleStore.mutable.get('title');
console.log(exampleStore.mutable.set) // => undefined
对于 mutable 访问器来说,并没有 set 方法。并且如果返回的数据是一个引用类型的数据(对象或数组),它会返回该数据的拷贝,这样在 Store Callbacks 之外对取出来的数据进行修改并不会影响到 Store 中保存的数据。
// 如果 title 是一个对象, title = { foo: 'bar' }
// 会返回 title 的克隆对象
var title = store.mutable.get('title');
console.log(title.foo) // => 'bar'
title.foo = 'baz';
console.log(title.foo) // => 'baz'
console.log(store.mutable.get('title').foo) // => 'bar'
而对于 immutable 访问器来说,它的 set 类方法只会返回一个新的 immutable 数据,也不会影响 Store 中的数据。
只有在 Store Callbacks 中才能自由的「写入」和「读取」数据。这种分离的设计充分体现了「单向」的特点,使数据操作清晰明了。
Action 真的需要队列吗?
// Flux 的代码
componentDidMount () {
orgPermissionsStore.on(DELETE_APPLICATION_SUCCESS_EVENT, this.deleteSuccess);
}
…
deleteSuccess () {
// 无奈之举
setTimeout(() => {
orgPermissionsActions.fetchApplication(1);
}, 0);
}
这段代码是当一个删除操作的 Action 成功之后,再发起一个请求数据的 Action,如果没有 setTimeout
的包装,Flux 框架就会报如下的错误:
"Dispatch.dispatch(…): Cannot dispatch in the middle of a dispatch"
这是因为 Flux 框架在设计的时候,一次只能发起一个 Action,这么做的目的可能是为了 Store 之间的依赖,也有可能是为了确保数据一致性而设计的类似于「并发锁」,这样每次只会有一个修改数据的操作,如果要同时发起好几个请求数据的 Action 就会有问题,要么简单粗暴的使用 setTimeout
来规避,要么使用 Action 队列来解决。但是 JavaScript 在目前来看,它还是单线程的,不会出现并发的场景。
在 Ballade 并没有这种设计。
订阅事件
Store 集成了一个简单的事件订阅和发送的系统,在 Views
或 Controller-views
中可以通过 store.event
接口来订阅数据变化的事件,并且通常的情况下无需开发者主动发送事件通知,只要数据有变更由框架自动来发送事件通知。
// 如果 titile 有变化,回调函数则会执行
exampleStore.event.subscribe('title', function () {
var title = store.mutable.get('title');
console.log(title);
// or
var title = store.immutable.get('title');
console.log(title);
});
你其实不需要 waitFor
在 Flux 中,它提供了一个 waitFor
来处理 Store 之间的依赖,这并不是一个好的设计,只会让应用变得更复杂。在 flux/examples/flux-chat/js/stores/MessageStore.js 这个例子中,如果删除下面这行代码,该例子仍然能很好的运行。
ChatAppDispatcher.waitFor([ThreadStore.dispatchToken]);
当然,这里并不是说 waitFor
一无是处,只是它让开发者对 Flux 的理解更加困惑。
在 Ballade 中处理 Store 之间的依赖,相当简单,就像你要使用一个变量,那么你得先定义这个变量,它本来就这么简单。
var storeA = dispatcher.createMutableStore(schema, {
'example/update-title': function (store, action) {
return store.mutable.set('title', action.title);
}
});
// 假如 storeB 依赖了 storeA
var storeB = dispatcher.createMutableStore(schema, {
'example/update-title': function (store, action) {
var title = storeA.mutable.get('title') + '!';
return store.mutable.set('title', title);
}
});
好了,Ballade 都介绍完了,如果你对 Ballade 感兴趣,想深入的了解,下面是 Ballade 的一些资源。
- github 主页:https://github.com/chenmnkken/ballade
- 中文文档:https://github.com/chenmnkken/ballade/blob/master/README_CN.md
- Mutable 的例子:TodoMVC
- Immutable 的例子:TodoMVC
- Bug 或问题提交:https://github.com/chenmnkken/ballade/issues
“Ballade: 重新诠释 Flux 架构”目前已有 3 条评论