网站优化
使用 RxJS 实现一个简易的仿 Elm 架构应用
什么是 Elm 架构
Elm 架构是一种使用 Elm 语言编写 Web 前端应用的简单架构,在代码模块化、代码重用以及测试方面都有较好的优势。使用 Elm 架构,可以非常轻松的构建复杂的 Web 应用,无论是面对重构还是添加新功能,它都能使项目保持良好的健康状态。
Elm 架构的应用通常由三部分组成——模型、更新、视图。这三者之间使用 Message 来相互通信。
模型
模型通常是一个简单的 POJO 对象,包含了需要展示的数据或者是界面显示逻辑的状态信息,在 Elm 语言中,通常是自定义的“记录类型”,模型对象及其字段都是不可变的(immutable)。使用 TypeScript 的话,可以简单的用接口来描述模型:
export interface IHabbitPresetsState { presets: IHabbitPreset[]; isLoading: boolean; isOperating: boolean; isOperationSuccess: boolean; isEditing: boolean; } |
这时候,我们就需要在心中谨记,永远不要去修改模型的字段!
Message
Message 用来定义应用程序在运行过程中可能会触发的事件,例如,在一个秒表应用中,我们会定义“开始计时”、“暂停计时”、“重置”这三种事件。在 Elm 中,可以使用 Union Type 来定义 Message,如果使用 TypeScript 的话,可以定义多个消息类,然后再创建一个联合类型定义:
export type HabbitPresetsMsg = Get | Receive | Add | AddResp | Delete | DeleteResp | Update | UpdateResp | BeginEdit | StopEdit; export class Get { } export class Receive { constructor(public payload: IHabbitPreset[]) { } } export class Add { constructor(public payload: IHabbitPreset) { } } export class AddResp { constructor(public payload: IHabbitPreset) { } } export class Delete { constructor(public payload: number) { } } export class DeleteResp { constructor(public payload: number) { } } export class Update { constructor(public payload: IHabbitPreset) { } } export class UpdateResp { constructor(public payload: IHabbitPreset) { } } export class BeginEdit { constructor(public payload: number) { } } export class StopEdit { } |
我们的应用程序一般从视图层来触发 Message,比如,在页面加载完毕后,就立即触发“加载数据”这个 Message,被触发的 Message 由更新模块来处理。
更新
更新,即模型的更新方式,通常是一个函数,用 TypeScript 来描述这个函数就是:
update(state: IHabbitPresetsState, msg: HabbitPresetsMsg): IHabbitPresetsState |
每当一个新的 Message 被触发的时候,Elm 架构便会将应用程序当前的模型跟接受到 Message 传入 update 函数,再把执行结果作为应用程序新的模型——这就是模型的更新。
在 Elm 程序中,视图的渲染仅依赖模型中的数据,所以,模型的更新往往会导致视图的更新。
视图
Elm 语言自带了一个前端的视图库,其特点是视图的更新仅依赖模型的更新,几乎所有的 Message 也都是由视图来触发。但在这篇文章里面,我将使用 Angular5 来演示效果,当然了,也可以使用 React 或者 jQuery 来实现视图,这取决于个人爱好。
小结
至此,我们大致的了解了一下 Elm 架构的几个要点:模型、更新、视图以及 Message。一个 Elm 架构的程序,通常是视图因为用户的动作触发特定 Message,然后由这个触发的 Message 跟当前应用的模型计算得出新的模型,新的模型的产生使得视图产生变化。
开始实现
首先让我们写出一个空的框架:
export class ElmArch<TState, TMsgType> { } |
TState 表示应用程序的模型类型,TMsgType 表示应用程序的消息联合类型。
由上一节可以知道,Message 是应用程序能够运行的关键,Message 在运行时要能够手动产生,并且,Message 的触发还要能被监听,所以,可以使用 RxJS/Subject 来构建一个 Message 流。
export class ElmArch<TState, TMsgType> { private readonly $msg = new Subject<TMsgType>(); send(msg: TMsgType) { this.$msg.next(msg); } } |
这里之所以定义一个 send 函数是为了更好的将代码封装起来,消息流对外只暴露一个触发消息的接口。
接下来,我们可以考虑一下模型流的实现。他跟消息流很类似,首先要能被监听,其次,还接收到消息后还要能手动产生,所以也可以使用 Subject 来实现。但是这里我用的是 BehaviorSubject ,因为 Behavior Subject 能够保留End产生的对象,这样我们就可以随时访问模型里面的数据,而不需要使用 Subscribe。
$res = new BehaviorSubject<TState>(initState); |
至此,1/3 的工作已经完成了,现在来按照我们的要求,使用 rxjs 让消息流能正确的触发模型流的更新。
this.$msg.scan(this.update, initState) .subscribe((s: TState) => { $res.next(s); }); |
scan 是 rxjs 的一个操作符,类似于 JS 中的 reduce,LINQ 中的 Aggregate。因为设置了一个初始模型(initState),所以在消息流每次产生新的消息的时候,update 函数就可以接收到上一次计算出来的模型,以及新的接收到的消息,然后返回新的模型。也就是说,scan 将消息流转化为了新的模型流。接着订阅这个模型流,并用之前定义的 BehaviorSubject 来广播新的模型。
这里就接近完成 1/2 的工作了,模型跟消息这两个的东西已经实现好了,接下来就继续实现更新。
Elm 是一门函数式语言,模式匹配的能力比 js 不知道高到哪里去了,既然要模仿 Elm 架构,那么这个地方也要仿出来。
type Pattern<TMsg, TState, TMsgType> = [new (...args: any[]) => TMsg, (acc: TState, msg: TMsg, $msg: Subject<TMsgType>) => TState]; /** * Pattern matching syntax * @template TMsg * @param {new (...args: any[]) => TMsg} type constructor of Msg * @param {(acc: TState, msg: TMsg, $msg: Subject<TMsgType>) => TState} reducer method to compute new state * @returns {Pattern<TMsg, TState, TMsgType>} * @memberof ElmArch */ caseOf<TMsg>( type: new (...args: any[]) => TMsg, reducer: (acc: TState, msg: TMsg, $msg: Subject<TMsgType>) => TState) : Pattern<TMsg, TState, TMsgType> { return [type, reducer]; } matchWith<TMsg>($msg: Subject<TMsgType>, patterns: Pattern<TMsg, TState, TMsgType>[]) { return (acc: TState, msg: TMsg) => { const state = acc; for (const it of patterns) { if (msg instanceof it[0]) { return it[1](state, msg, $msg); } } throw new Error('Invalid Message Type'); }; } |
首先我们定义了一个元组类型 Pattern 用来表示模式匹配的语法,在这里面,主要需要实现的是基于类型的匹配,所以元组的一个元素是消息类,第二个参数是当匹配成功时要执行的回调函数,用来计算新的模型,使用 caseOf 函数可以创建这种元组。matchWith 函数的返回值是一个函数,与 scan 的一个参数的签名相符合,一个参数是End被创建出来的模型,第二个参数是接收到的消息。在这个函数中,我们找到与接收到的消息相匹配的 pattern 元组,然后用这个元组的第二个元素计算出新的模型。
用上面的东西就可以比较好的模拟模式匹配的功能了,写出来的样子像这样:
const newStateAcc = matchWith(msg, [ caseOf(GetMonth, (s, m, $m) => { // blablabla }), caseOf(GetMonthRecv, (s, m) => { // blablabla }), caseOf(ChangeDate, (s, m) => { // blablabla }), caseOf(SaveRecord, (s, m, $m) => { // blablabla }), caseOf(SaveRecordRecv, (s, m) => { // blablabla }) ]) |
这样,之前用来构建模型流的地方就需要做一些改动:
this.$msg.scan(this.matchWith(this.$msg, patterns), initState) .subscribe((s: TState) => { $res.next(s); }); |
现在构建模型流需要依赖一个初始状态跟一个模式数组,那么就可以用一个函数封装起来,将这两个依赖项作为参数传入:
begin(initState: TState, patterns: Pattern<any, TState, TMsgType>[]) { const $res = new BehaviorSubject<TState>(initState); this.$msg.scan(this.matchWith(this.$msg, patterns), initState) .subscribe((s: TState) => { $res.next(s); }); return $res; } |
到目前为止,2/3 的工作就已经完成了,我们设计出了消息流、模型流以及处理消息的更新方法,做一个简单的计数器是完全没有问题的。点击查看样例
但是实际上,我们需要面对的问题远不止一个计数器这么简单,更多的情况是处理请求,有时候还需要处理消息的时候触发新的消息。对于异步的请求,需要在请求的响应中触发新的消息,可以直接调用 $msg.next() ,对于需要在更新的操作中触发新的消息,也可以主动调用 $msg.next() 这个函数就好了。
不过,事情往往没有这么简单,因为模型流并不是从消息流直接通过 rxjs 的操作符转换出来的,而更新函数中模式匹配部分执行时间长短不一,这可能导致消息与模型更新顺序不一致的问题。我想出的解决方法是:对于同步的操作需要触发新的消息,就必须要保证当前消息处理完成后,模型的更新被广播出去后才能触发新的消息。基于这一准则,我就又添加了一些代码:
type UpdateResult<TState, TMsgType> = TState | [TState, TMsgType[]]; /** * Generate a result of a new state with a sets of msgs, these msgs will be published after new state is published * @param {TState} newState * @param {...TMsgType[]} msgs * @returns {UpdateResult<TState, TMsgType>} * @memberof ElmArch */ nextWithCmds(newState: TState, ...msgs: TMsgType[]): UpdateResult<TState, TMsgType> { if (arguments.length === 1) { return newState; } else { return [newState, msgs]; } } |
在这里,我添加了新的类型—— UpdateResult<TState, TMsgType>,这个类型表示模型类型或模型类型与消息数组类型的元组类型。这么说起来确实有些绕口,这个类型存在的意义就是:Update 函数除了返回新的模型之外,还可以选择性的返回接下来要触发的消息。这样,单纯的模型流就变成了模型消息流,接着在 subscribe 的地方,在原先的模型流产生新的模型的地方后面再去触发新的消息流,如果返回结果中有需要触发的消息的话。
使用样例
在上面的 gits 中提到了一个样例,但是不是很完整,之后会放出完整例子。
总结
看到这里,你可能已经发现了,本文实现的这个小工具看起来跟 redux 挺像的,确实,redux 也是 js 程序员对 Elm 架构的致敬。通过把 Web 应用的逻辑拆解成一个个状态间改变的逻辑,可以帮助我们更好的理解所编写的东西,同时,也让 MV* 的思想得到进一步的展现,因为在编写 update 相关的代码的时候,可以在实现业务逻辑的同时而毫不碰触 UI 层面的东西,所以,正如本文开头提到的,视图可以是任何东西:React、Angular、jQuery,这都没关系,只要能够对模型的 Observable 流的改变做出响应, DOM API 也是可以的,可能,这就是所谓的响应式编程吧。
对于普通的 Angular 应用来说意味这什么?
在我自己将这个小工具结合 Angular 的使用体验来看,大的改变就是代码变得更加有规律了,特别是处理异步并改变 UI 的场景,变得更容易套路化,更容易套路化就意味着更方便生成代码了。再一个,在 Angualr 中,如果组件依赖的所有输入都是 Observable 对象,那么可以将默认的变更检查策略改为:OnPush。这样,Angular 就不用对这个组件进行“脏检查”了,只有在 Observable 发生更新的时候,才会去重新改变组件,这个好处,不言而喻。
此内容来源于网络,仅供参考不做松一设计业务使用,如涉及版权问题请与我们联系及时删除。
下一篇:Java实现单链表的快速排序和归并排序
上一篇:网站优化时需要注意哪些问题