使用 RxJS 实现一个简易的仿 Elm 架构应用

使用 RxJS 实现一个简易的仿 Elm 架构应用

标签(空格分隔): 前端前端


什么是 Elm 架构

Elm 架构是一种使用 Elm 语言编写 Web 前端应用的简单架构,在代码模块化、代码重用以及测试方面都有较好的优点。使用 Elm 架构,能够很是轻松的构建复杂的 Web 应用,不管是面对重构仍是添加新功能,它都能使项目保持良好的健康状态。git

Elm 架构的应用一般由三部分组成——模型更新视图。这三者之间使用 Message 来相互通讯。程序员

模型

模型一般是一个简单的 POJO 对象,包含了须要展现的数据或者是界面显示逻辑的状态信息,在 Elm 语言中,一般是自定义的“记录类型”,模型对象及其字段都是不可变的(immutable)。使用 TypeScript 的话,能够简单的用接口来描述模型:github

export interface IHabbitPresetsState {
    presets: IHabbitPreset[];
    isLoading: boolean;
    isOperating: boolean;
    isOperationSuccess: boolean;
    isEditing: boolean;
}

这时候,咱们就须要在心中谨记,永远不要去修改模型的字段!编程

Message

Message 用来定义应用程序在运行过程当中可能会触发的事件,例如,在一个秒表应用中,咱们会定义“开始计时”、“暂停计时”、“重置”这三种事件。在 Elm 中,可使用 Union Type 来定义 Message,若是使用 TypeScript 的话,能够定义多个消息类,而后再建立一个联合类型定义:redux

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 程序中,视图的渲染仅依赖模型中的数据,因此,模型的更新每每会致使视图的更新。app

视图

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 可以保留最后产生的对象,这样咱们就能够随时访问模型里面的数据,而不须要使用 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 的第一个参数的签名相符合,第一个参数是最后被建立出来的模型,第二个参数是接收到的消息。在这个函数中,咱们找到与接收到的消息相匹配的 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 的地方,在原先的模型流产生新的模型的地方后面再去触发新的消息流,若是返回结果中有须要触发的消息的话。

完整代码在此:https://gist.github.com/ZeekoZhu/c10b30815b711db909926c172789dfd2

使用样例

在上面的 gits 中提到了一个样例,可是不是很完整,以后会放出完整例子。

总结

看到这里,你可能已经发现了,本文实现的这个小工具看起来跟 redux 挺像的,确实,redux 也是 js 程序员对 Elm 架构的致敬。经过把 Web 应用的逻辑拆解成一个个状态间改变的逻辑,能够帮助咱们更好的理解所编写的东西,同时,也让 MV* 的思想获得进一步的展示,由于在编写 update 相关的代码的时候,能够在实现业务逻辑的同时而绝不碰触 UI 层面的东西,因此,正如本文开头提到的,视图能够是任何东西:React、Angular、jQuery,这都不要紧,只要可以对模型的 Observable 流的改变作出响应, DOM API 也是能够的,可能,这就是所谓的响应式编程吧。

对于普通的 Angular 应用来讲意味这什么?

在我本身将这个小工具结合 Angular 的使用体验来看,最大的改变就是代码变得更加有规律了,特别是处理异步并改变 UI 的场景,变得更容易套路化,更容易套路化就意味着更方便生成代码了。再一个,在 Angualr 中,若是组件依赖的全部输入都是 Observable 对象,那么能够将默认的变动检查策略改成:OnPush。这样,Angular 就不用对这个组件进行“脏检查”了,只有在 Observable 发生更新的时候,才会去从新改变组件,这个好处,不言而喻。

相关文章
相关标签/搜索