Ballade: 重新诠释 Flux 架构

由于 React 的单向数据流的设计,衍生出了单向数据流的架构模式 Flux。

在 MVC 的分层架构中,Flux 属于 M 层,也就是 Model,而在 Flux 中,Store 是关键部分,ActionDispatcher 都是围绕着 Store 来设计的,所以 Flux 架构模式的目标就是基于单向数据流如何更好的管理数据,在 ViewsController-views 与数据之间进行解耦。

我在之前的 React 应用的架构模式 Flux 有详细的介绍过 Flux 架构模式及其应用。

"Flux is more of a pattern than a framework."

这是 Flux 在其 github 主页上截取的一句话,翻译成中文就是:Flux 更像是一种架构模式,而不是一个单纯的框架。

从实际使用的感受来看,Flux 作为一种架构模式还是很不错的,方向是正确的,但是作为框架来使用,从这个层面来说,存在着一些问题。正是由于这些问题促使我重新基于 Flux 的架构模式开发了 Ballade。先来介绍一些 Ballade 的架构模式,之后再说说在使用 Flux 框架时碰到的一些问题以及 Ballade 是如何解决这些问题的。

flux

Ballade 的架构介绍

Store

Store 是一个数据的存储中心,提供了「写入」和「读取」数据的接口,就像一个数据的「访问器」。

ViewsController-views (React 组件)中,只能从 Store 中「读取」数据,在 Store Callbacks 中,才能自由的「写入」和「读取」数据。

当数据变化的时候,Store 会发送一个数据变化的事件。

Store 的数据「访问器」分为 mutable(可变)和 immutable(不可变)的两种,分别对应了mutable 和 immutable 两种不同的数据结构。

Actions

所有的操作,像用户的交互行为或者从服务器获取一个数据,只要会引起数据变化的操作都可以把它看作是一个 Action,引起数据变化的操作可以是「写入」或「更新」数据。

如果想「写入」或「更新」Store 中的数据,只能发起一个 Action。每个 Action 都有一个唯一的 ActionType 和 payload 数据,ActionType 可以理解为这个 Action 唯一的名字,payload 数据就是传递给 Store 的数据。

Dispatcher

Dispatcher 用于连接 ActionsStore 的「调度员」,负责将 Action 产生的 payload 数据分发给指定的 Store

Actions Middleware

在传送 payload 数据到 Store 时,可以注册一些中间件来处理 payload 数据,每个中间件会把处理完的 payload 结果再传递给下一个中间件。假如你想从服务器取数据,可以注册一个中间件。

Store Callbacks

Action 触发的时候,Store 需要一个与该 Action 对应的回调函数来处理 payload 数据,这时可以将数据写入到 Store 中。ActionType 需要与 Store 的回调函数名相对应。

ballade

与其它框架设计的异同

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 中并发出数据变化的通知(这样ViewsController-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.createMutableStoredispatcher.createImmutableStore 创建的 Store 用于在 ViewsController-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 集成了一个简单的事件订阅和发送的系统,在 ViewsController-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 的一些资源。

“Ballade: 重新诠释 Flux 架构”目前已有 3 条评论