使用 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 能够保留最后产生的对象,这样我们就可以随时访问模型里面的数据,而不需要使用 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 发生更新的时候,才会去重新改变组件,这个好处,不言而喻。

使用 RxJS 实现一个简易的仿 Elm 架构应用的更多相关文章

  1. .NET Core的文件系统[5]:扩展文件系统构建一个简易版“云盘”

    FileProvider构建了一个抽象文件系统,作为它的两个具体实现,PhysicalFileProvider和EmbeddedFileProvider则分别为我们构建了一个物理文件系统和程序集内嵌文 ...

  2. 自己来实现一个简易的OCR

    来做个简易的字符识别 ,既然是简易的 那么我们就不能用任何的第三方库 .啥谷歌的 tesseract-ocr, opencv 之类的 那些玩意是叼 至少图像处理 机器视觉这类课题对我这种高中没毕业的人 ...

  3. 基于 getter 和 setter 撸一个简易的MVVM

    Angular 和 Vue 在对Angular的学习中,了解到AngularJS 的两个主要缺点: 对于每一次界面时间,Ajax 或者 timeout,都会进行一个脏检查,而每一次脏检查又会在内部循环 ...

  4. 探秘Tomcat——一个简易的Servlet容器

    即便再简陋的服务器也是服务器,今天就来循着书本的第二章来看看如何实现一个servlet容器. 背景知识 既然说到servlet容器这个名词,我们首先要了解它到底是什么. servlet 相比你或多或少 ...

  5. 使用Windows Form 制作一个简易资源管理器

    自制一个简易资源管理器----TreeView控件 第一步.新建project,进行基本设置:(Set as StartUp Project:View/Toolbox/TreeView) 第二步.开始 ...

  6. [后端人员耍前端系列]AngularJs篇:使用AngularJs打造一个简易权限系统

    一.引言 上一篇博文已经向大家介绍了AngularJS核心的一些知识点,在这篇博文将介绍如何把AngularJs应用到实际项目中.本篇博文将使用AngularJS来打造一个简易的权限管理系统.下面不多 ...

  7. ENode 2.0 - 第一个真实案例剖析-一个简易论坛(Forum)

    前言 经过不断的坚持和努力,ENode 2.0的第一个真实案例终于出来了.这个案例是一个简易的论坛,开发这个论坛的初衷是为了验证用ENode框架来开发一个真实项目的可行性.目前这个论坛在UI上是使用了 ...

  8. 使用MVVM框架avalon.js实现一个简易日历

    最近在做公司内部的运营管理系统,因为与日历密切相关,同时无需触发条件直接显示在页面上,所以针对这样的功能场景,我就用avalon快速实现了一个简易日历,毕竟也是第一次造日历这种轮子,所以这里记录下我当 ...

  9. 做了一个简易的git 代码自动部署脚本

    做了一个简易的git 代码自动部署脚本 http://my.oschina.net/caomenglong/blog/472665 发表于2个月前(2015-06-30 21:08)   阅读(200 ...

随机推荐

  1. [国嵌笔记][027][ARM协处理器访问指令]

    协处理器作用 协处理器用于执行特定的处理任务,如数学协处理器可以执行控制数字处理,以减轻处理器的负担.ARM处理器最多可以支持16个协处理器,其中CP15是最重要的一个协处理器 CP15的作用 CP1 ...

  2. dblink实现不同用户之间的数据表访问

    1.dblink 1.创建dblink,如果在用户A下创建dblink,名称为TEST_DBLINK; 去操作GCFR_33用户下的表数据等等, 那么在查询表数据的sql就要加上dblink了.如下是 ...

  3. 安装Wamp后 Apache无法启动的解决方法

    安装Wamp后 Apache无法启动的解决方法,网上的解决方案可以说是五花八门,有些说了一大推,一点作用都起不到. 其实解决方法只需两步: 1.安装路径不能包含有中文,这个我不知道为什么,总之如果安装 ...

  4. syntax error, unexpected '['

    在用ThinkPHP框架做了个小的应用 我在本地搭建的服务器,进行测试好着的. 但是放到别的地方后,出现以下报错 syntax error, unexpected '[' 错误位置是在我自己写的一个A ...

  5. 教你搭建你自己的Git服务器

    http://lib.csdn.net/article/git/50086 导读 现在我们将要学习如何搭建 git 服务器,如何编写自定义的 Git 钩子来在特定的事件触发相应的动作(例如通知),或者 ...

  6. 本地如何使用phpstudy环境搭建多站点

    http://jingyan.baidu.com/article/e52e36154227ef40c70c5147.html 平时在开发项目的时候, 多个项目同时开发的时候会遇到都得放到根目录才能正常 ...

  7. 利用光场进行深度图估计(Depth Estimation)算法之一——聚焦算法

    前面几篇博客主要说了光场相机,光场相机由于能够记录相机内部整个光场,可以实现重聚焦(模糊线索)和不同视角的变换(视差线索),同时也可以利用这个特性进行深度估计(Depth Estimation). 先 ...

  8. 源码讲解 node+mongodb 建站攻略(一期)第二节

    源码讲解 node+mongodb 建站攻略(一期)第二节 上一节,我们完成了模拟数据,这次我们来玩儿真正的数据库,mongodb. 代码http://www.imlwj.com/download/n ...

  9. 理解Python中的装饰器//这篇文章将python的装饰器来龙去脉说的很清楚,故转过来存档

    转自:http://www.cnblogs.com/rollenholt/archive/2012/05/02/2479833.html 这篇文章将python的装饰器来龙去脉说的很清楚,故转过来存档 ...

  10. sqlserver datetime的bug

    sqlserver datetime 的毫秒的个位似乎存在bug,只有0.3.7这三个值,比如: 2018-01-20 23:59:59:999会变成2018-01-21 00:00:00.000 2 ...