无 Flux 的响应式 Flux





5.00/5 (5投票s)
使用 Reactive Extensions RxJS 为 React 组件实现简单实用的单向数据流。
引言
正如在《基于组件的 Web 应用程序》中所讨论的,在组件开发中,基于命令式 DOM API 的函数式编程更受青睐。在选择 React 作为我们的组件模型库之后,我们还发现 Reactive Extensions RxJS 在组件内部状态管理方面提供了极高的表达力和简洁性。在《响应式自主状态》一文中讨论了一个具体的例子。这是我们追求有效组件化 Web 应用程序的第三篇文章,我们将重点关注组件间单向数据流的实现。
组件内部状态是自主的,而组件间数据流是单向的。这种设计不仅原生匹配 React 的渲染机制,而且极大地简化了状态管理逻辑。当组件间数据只朝一个方向流动时,就不必担心双向数据绑定的缺点,大型应用程序会变得更具性能,更容易调试,代码也更清晰、更易于维护。通过 RxJS 的异步和非阻塞特性,结合不可变数据结构,实现更好、更简单的 Flux 变得更加容易。
Flux 泛化了大型应用程序和小组件的交互方式。可组合性和单向数据流是其架构的本质,代码库也更容易阅读、调试和维护。最重要的是,状态管理得到了极大的简化,不断演进的应用程序功能和规模化开发生产力比其他不必要的复杂解决方案更有效。
标题中有两个“flux”,第一个指的是 Flux 架构,第二个指的是 Flux 库。我们喜欢 Flux 的架构理念,但不太喜欢它的实现。为了避免默认 Flux 库的一些缺点,我们利用 RxJS 来实现 Flux,结果是 React 组件变得更具响应性,数据流更容易管理和调试,代码也更清晰、更短、更具扩展性。
具体来说,我们想克服以下一些缺点:
- 繁琐的全局 Dispatcher:过多的回调,过长的 switch 语句
- Action 和 Store 之间的一对多关系:当一个 Action 触发多个 Store 的更新时,可维护性会下降
- 在 Store 和 View 之间组合或转换不同类型数据的简单清晰的方法
概念上,这是一个简化的 Flux 库数据流图:
RxJS 提供了 observable/observer,可以优雅地替换全局 Dispatcher,其丰富的操作符有助于在 Store 和 View (#3) 之间以表达清晰的方式转换数据。虽然 #2 更多的是应用程序架构实践,而不是 Flux 的缺点,但我们发现限制响应相同 Action 的 Store 数量有助于保持应用程序逻辑更容易维护。
让我们深入了解 RxJS 如何帮助创建更好的 Flux。
概述
用 Rx 实现 Flux 的想法并非新鲜事,GitHub 上已有一些相关努力:
- Rx-Flux:没有中心 Dispatcher,Store 是一个 RxJS Observable,Action 是一个函数,也是一个 RxJS Observable,Store 订阅 Action 来更新 Store 数据。
- Reactive-Flux:React 组件可以订阅多个 Store,一个 Store 可以订阅多个 Action,每个都有自己的处理程序。
- Thundercats:除了用于 Store 和 Action 的 RxJS Observable 之外,它还通过使用 stampit!来构建 Store、Action 和 Cats(一个用于注册 Store 和 Action 工厂的容器),并且不使用 ES6 (2015) 类,因为“相关的危险”。
- Flurx:一个 Store 可以订阅多个 Action,每个都有一个处理程序;处理程序接收 Action 的调用参数并产生 Store 的新值。
- RR:一个非常小巧但纯粹的响应式(基于 RxJS)的 Flux 架构实现。支持 React-Native。
如果以上任何一个符合您的需求,请停止阅读并下载,它们各自都有其特殊性和优势。然而,以上任何一个都不完全符合我的需求,考虑到我真正寻找的是:
- 受控的 RxJS 依赖:RxJS 是一个庞大的库:完整版、主版、精简版、核心版等,我们希望将依赖项管理到最低限度。同时,如果应用程序使用更多的 Rx 功能,依赖项列表可能会增长。
- 避免 Action 中的 waitFor,同时使 Action 类型可扩展。
- 允许外部处理程序通过 Store 订阅 Action:这使得 Store 能够与遗留代码协同工作,例如在 Angular 控制器中的作用域函数。
- 默认情况下将 Action 与 Store 配对,同时仍允许 Store 订阅多个 Action。
- 消除全局 Dispatcher 和单例,保持基础实现实用且简单。
- 通过在 Store 中内置 redo/undo 功能来促进不可变数据结构,但并非强制要求,Store 的具体实例可以选择是否支持 undo/redo。
- 除了 Flux 之外,没有其他概念和构造,保持一切简单。
以上内容似乎涵盖了很多,但最终的代码库却非常简洁。以下是我们实现的一些通用技术考虑:
- Store 的数据对象是私有的,使用 React Immutable Helper 进行操作并保持其不可变性,undo/redo 可以随时选择启用或禁用。
- Action 的“类型”将是只读属性,并且只能在实例化时自定义。
- Store 和 Action 都由一个 BehaviorSubject 支持,它将使 Store 和 Action 都成为 observable 和 observer,并且确保 observer 无论何时订阅,都能始终获得初始或最后的数据。
- Subscribe 和 dispose 的调用可以是可重入的。
- 所有其他 Flux 架构方面都保持不变,包括 Store 可以订阅多个 Action,View 可以订阅多个 Store 等,尽管为了简洁起见,默认行为偏向于一对一的关系。
这是单向数据流的通用 Rx 实现。
让我们通过一些代码看看 Rx 如何实现 Flux。
Rx 库设置
作为应用我们极简设计原则的第一步,我们可以创建一个受控的 Rx 依赖列表,它只包含支持基本功能(rx.include.js)的库。
'use strict';
let Rx = require('rx/dist/rx');
require('rx/dist/rx.aggregates');
require('rx/dist/rx.async');
require('rx/dist/rx.binding');
require('rx/dist/rx.time');
// Use only native events even if jQuery
Rx.config.useNativeEvents = true;
module.exports = Rx;
上面的列表涵盖了最常见的 Rx 构造(observable、observer、subject 等)和操作符(map、filter、find、concat、merge、mergeAll、zip、combineLatest、debounce、delay、timeout 等)。如果项目需要其他操作符,只需在列表中添加相应的库,browserify 会负责导入和打包。这里的目的是提供一个受控列表以最大限度地减少依赖。
操作
基于原始的 Action 类型概念,每个分派的 Action 在其 payload 中都包含 'type' 和 'data'。'type' 字段是 Action 的标识符,也用于 observer(Store 或其他处理程序)订阅它时:只有指定的 Action 类型才会被“分派”给 observer。
'use strict';
import Rx from './rx.include';
//when instantiating, needs to pass in "action_types", see ../menus/reactive.menubar.action for example
function ActionBase(action_types) {
//back rx subject, initialized to have empty type and data, if dispatched, will show error
this._actionSubject = new Rx.BehaviorSubject({type:"", data:null});
//make the types map to be ready only properties
for (let name in action_types) {
if (action_types.hasOwnProperty(name)) {
Object.defineProperty(this, name, {
enumerable: true, configurable: true, get: () => action_types[name]
});
}
}
}
ActionBase.prototype = {
constructor: ActionBase,
dispatch(actType, actData) {
if (!actType || !actData) {
console.error("ActionBase: missing arguments", actType, actData);
}
else {
this._actionSubject.onNext({
type: actType,
data: actData
});
}
},
subscribe(actType, fn, context) {
if (!actType || !this.hasOwnProperty(actType)) {
console.error(`action type of ${actType} is not defined.`);
return null;
}
return this._actionSubject.filter( (payload) => payload.type === this[actType] )
.subscribe( (payload) => {
fn.apply(context, [payload]);
});
},
dispose(subscription) {
if (subscription) {
subscription.dispose();
}
else { //dispose ALL
this._actionSubject.dispose();
}
}
};
module.exports = ActionBase;
Rx.BehaviorSubject 是实际支持 Action 精髓的关键 Rx 构造,它在没有中央 Dispatcher 的情况下,“分派”带有必需 payload 的 Action。
Action 的构造函数接受一个字符串数组,表示支持的 Action 类型,这些类型只能在实例化 Action 实例时自定义。一旦创建,所有 Action 类型都是只读的。结合订阅时检查,它强制要求 Action 类型必须是支持的类型之一。
商店
Rx.BehaviorSubject 在 Store 中也起着至关重要的作用,它执行所有与响应式相关的操作。subscribe/dispose 模式与 Action 的模式相同。我们的 Store 实现中有两个主要特殊功能:它需要一个默认 Action 进行配对,并且内置了 undo/redo 支持。
'use strict';
import Rx from './rx.include';
function StoreBase(storeData, action) {
this._storeSubject = new Rx.BehaviorSubject(storeData);
this._storeState = storeData;
this._storeHistory = [];
this._historyIndex = -1;
//yeah, to keep the base class simple, one store only have one default action
//but, the actual instantiation of the store can have multiple actions
this.action = action;
}
StoreBase.prototype = {
constructor: StoreBase,
init() {
this.streamChange();
},
subscribe(fn, context) {
return this._storeSubject.subscribe( (payload) => {
fn.apply(context, [payload]);
});
},
dispose(subscription) {
if (subscription) {
subscription.dispose();
}
else { //dispose ALL
this._storeSubject.dispose();
}
},
streamChange() {
if (this.undoRedoSub) {
this._historyIndex++;
this._storeHistory.push(this._storeState);
}
this._storeSubject.onNext(this._storeState);
},
bindAction(actType, actFn, actContext) {
return this.action.subscribe(actType, actFn, actContext);
},
unBindAction(actSubs) {
if (!actSubs) { //prevent dispose all
console.log("StoreBase: missing subscription argument.");
}
else {
this.action.dispose(actSubs);
}
},
enableUndoRedo(toEnable) {
if (toEnable) {
if (!this.undoRedoSub) {
this.undoRedoSub = this.bindAction("UNDO_REDO", this.onUndoRedo, this);
}
}
else {
if (this.undoRedoSub) {
this.unBindAction(this.undoRedoSub);
this.undoRedoSub = null;
}
}
},
onUndoRedo(payload) {
if (payload.data === "--") {
if (this._historyIndex > 0) {
this._historyIndex--;
}
}
else if (payload.data === "++") {
if (this._historyIndex < this._storeHistory.length - 1) {
this._historyIndex++;
}
}
if (this._storeState != this._storeHistory[this._historyIndex]) {
this._storeState = this._storeHistory[this._historyIndex];
this._storeSubject.onNext(this._storeState);
}
}
};
module.exports = StoreBase;
在 Store 的构造函数中传入默认 Action 实例的原因是,大多数用例中 Store 需要一个 Action 才能完全正常工作,在基类中保留重复的样板代码更清晰。(在极少数不需要默认 Action 的情况下,构造函数将接受 undefined 或 null 参数,但调用 bindAction/unBindAction 时会抛出异常。)
bindAction/unBindAction 的目的是允许外部模块在分派特定类型的 Action 时注册一个函数。外部模块不需要与 Action 类型耦合,而是仅与 Store 实例进行交互。一个用例是:Angular 控制器可以实例化一个 Store 实例,以响应遗留 Angular 应用程序外壳中的 React 组件的特定 Action。
undo/redo 功能需要通过调用 enableUndoRedo(true) 来选择启用。假设客户端确实知道自己在做什么:默认配对的 Action 实例不能为 null,它必须支持至少一个名为 'UNDO_REDO' 的 Action 类型,最重要的是,`storeState` 需要是一个不可变数据结构。
无论是 immutable.js 还是 React immutable helpers 都可以帮助保持 `storeState` 的不可变性,每当 Store 响应 Action 更新 storeState 时,都会返回一个新的引用。不可变数据结构确实使 undo/redo 逻辑非常简单。底层,不可变数据算法使用 DAG(有向无环图)进行结构共享,尽管任何更新都会返回新数据,但内部数据结构共享会显著减少内存使用和 GC 碎片。
以上都是基于 Rx 的 Flux。接下来,我们将展示一些如何使用它们的代码示例,以及 React immutable helpers 如何使更新变得简单。
示例
来自《响应式自主状态》的代码示例是如何在 Dropdown 菜单内部管理其打开/折叠和高亮显示状态的。现在,让我们看看点击菜单项如何最终执行预定义的命令。
通过单向数据流,点击事件处理程序将分派一个“Action”,并且遗留的 Angular 控制器将一个函数绑定到执行该命令。这种方法使我们能够在引入新 React 组件的同时,还能利用现有的应用程序逻辑来实现分阶段的技术迁移。
首先,在遗留的 Angular 控制器中:
"use strict";
let menuBarMod = require('./_module.ds.menus');
let React = require('react');
let ReactDOM = require('react-dom');
let ReactMenuBar = require('./react.menubar');
let menuBarStore = require('./reactive.menubar.store');
menuBarMod.controller('MenuBar', function($scope, $translate, cmdExecutor) {
menuBarStore.init($translate.instant);
menuBarStore.bindAction("ACTION", cmdExecutor.onExecCommand, cmdExecutor);
ReactDOM.render(<ReactMenuBar />, document.getElementById("title-menu"));
});
其次,这是菜单项被点击时的事件处理程序(用于下拉菜单的 React 组件):
onMenuItemAction(idx, evt) {
evt && evt.preventDefault();
let itemData = this.props.menuData.items[idx];
if (!itemData.disabled) {
let actName = itemData.action;
menuBarAction.dispatch(menuBarAction.ACTION, actName);
}
},
所有这些都是粘合代码。然后,这是我们的 menubarAction 实例的创建方式:
'use strict';
import RxAction from '../util/rx.flux.action';
let MenuBarAction = new RxAction({
ACTION: "MENUBAR_ACTICON", UNDO_REDO: " UNDO_REDO"
});
module.exports = MenuBarAction;
至于 Store,这是如何使用默认 storeState 和 Action 来实例化新实例的:
'use strict';
import reactUpdate from 'react/lib/update';
import RxStore from '../util/rx.flux.store';
import menuBarAction from './reactive.menubar.action';
let MenuBarStore = new RxStore({
projectInfo: {
name: "",
version: "",
branch: ""
},
menus: [
{
label: "File",
items: [
{label: "Create New", icon: "icon-add", action: "modal-createNew"},
{role: "separator"},
{label: "Publish", icon: "icon-upload", action: "modal-publish"},
{label: "Close", icon: "icon-close", action: " close"}
]
},
{
label: "Source Control",
items: [
{label: "Commit Changes", icon: "icon-upload", action: "vcs-commit", disabled: true},
{label: "Switch Branch", icon: "icon-step-over", action: "vcs-switch-branch", disabled: true},
{role: "separator"},
{label: "Import from Source Control", icon: "icon-download", action: "vcs-import"},
{role: "separator"},
{label: "Create Branch", icon: "icon-ellipsis", action: "vcs-create-branch", disabled: true}
]
},
{
label: "Search",
items: [
{label: "Go To", icon: "icon-search", action: "modal-goto"},
{label: "Code Search", icon: "icon-cncn-advanced-search", action: "modal-search"}
]
}
]
}, menuBarAction);
当从 API 检索到新的 projectInfo 对象时,这是如何以不可变的方式更新 Store 的:
MenuBarStore.onProjectInfo = function(prjInfo) {
this._storeState = reactUpdate(this._storeState, {
projectInfo: {
$merge: prjInfo
}
});
this.streamChange();
};
另一个不可变操作示例:当项目的 VCS 被禁用时,我们需要删除“Source Control”下拉菜单。
MenuBarStore.onVCSInfo = function(vcsEnabled) {
if (!vcsEnabled) {
if (this._storeState.menus.length === 3) {
//remove 'source control' menu section when vcs is disabled, and only remove once
this._storeState = reactUpdate(this._storeState, {
menus: { $splice: [[1, 1]] }
});
this.streamChange();
}
};
module.exports = MenuBarStore;
总结
RxJS 提供了一种优化且富有表现力的方式来处理异步事件和数据,它自然地与数据驱动的 React 组件相匹配。通过 Rx 强大而简洁的构造和操作符,Flux 的实现变得更清晰、更简单。此外,Store 和 View 之间灵活的数据转换和组合也得到了便捷的实现。当与不可变数据结构结合时,Reactive Extensions 和 RxJS 实现的 Store 和 Action,可以在没有 Flux 的情况下实现更具扩展性、可组合性和灵活性的单向数据流。