现代化UI开发中,客户端(前端)一般会进行分层设计,实际用户可感知的 UI 作为顶层,称为视图(View),底层中独立于展示方式的数据结构称为模型(Model),而将两者进行关联的中间层部分,根据划分方式则有很多种定义,例如控制器(Controller)展示器(Presenter)以及视图模型(ViewModel)等。

程序设计中,UI相关的平台API一般以面向对象为基础,通过属性(Property)或者方法(Method)暴露相关状态及行为,供开发人员使用。UI 编程的本质是花式建立双向绑定,例如对于 <input> 元素,其 value 属性往往需要同时被程序读取和写入,同时需要满足读取的结果会作用于下次写入,并且写入的结果会作用于下次读取。如果不满足双向绑定的约束关系,要么会产生数据丢失、要么用户操作会被打断。几乎任何 DOM 属性都是双向绑定属性,甚至包括 innerHTML,不过由于其不会因用户交互发生预期的状态变化,因此往往不会进行监听和读取。

响应式概览

UI本质上是对应用状态的体现,因此一旦UI状态失去同步将很容易造成不预期的结果,因此UI响应式编程的重要性不言而喻。响应式编程(Reactive Programming),顾名思义,指通过声明式的设计指定数据依赖关系,并且仅以最小成本应对外界依赖的变化进行相应的数据处理。一个不是十分恰当的简单版本是,非响应式编程是基于拉取(Pull),而响应式编程是基于推送(Push)

对于视图状态的双向绑定,UI开发框架通常能够在很大程度上进行简化,自动或半自动地将视图状态与某个类实例的属性、某个 State 对象的属性、某个 Store 对象的属性进行同步,保持状态间的关联。但是,只有这层同步关系远远满足不了应用的需求,开发人员的最终目标可能是将状态同步到本地的持久存储空间,甚至跨越互联网同步到服务器。而由于成本限制和用户体验要求,实时同步往往并不可行,只能靠多级缓存的方式达到间接同步,从而让应用的状态管理复杂化。

Angular 概览

Angular是一个 UI 开发框架,采用准 MVVM 的层次划分方式,组件模板对应于视图,而组件类对应于视图模型。Angular 本身很出色地处理了 View 与 ViewModel 之间的绑定关系,只需要修改组件实例的属性,模版中的绑定结果就会自动应用于视图,一个简单组件为:

@Component

这里通过模板表达式建立了 DOM(View)与组件类(ViewModel)的绑定,随着 ViewModel 的变化,View 也会自动应用相应的更新。

虽然看上去如此美好,不过真正的开发成本并不在于 View 与 ViewModel 的同步,而是存在于 Model 与 ViewModel 之间同步,即如何根据一系列的外部状态确定 name 属性的值并将其更新。如果处理不妥,可能会导致代码量爆炸并且使得数据流向混乱,类似于:

class 

随着时间的推移和人员的变动,如果没有优秀的代码组织方案,项目代码往往都会向着混沌的方向发展,造成可读性与可维护性地持续恶化。

Angular 与 RxJS

Reactive Extensions 是微软提出的响应式设计方案,除了自身的 .Net 版本外,目前已有多个不同语言的社区实现。Rx 是观察者模式的演进,通过 Observable 类型承载数据流的定义,并能够通过运算符进行高效地组合与变换。

提供方返回 Observable 对象,消费者订阅该对象,即可实现对数据更新的持续可知状态。例如服务能够通过 Observable 暴露持续产生的数据:

@Injectable

为了获得一个 Observable 提供的数据(也可能是不包含数据纯事件通知),需要对其进行订阅,从而在回调中得到数据内容:

class 

而为了让数据流向更加清晰,能够将多个 Observable 进行组合,对消费者隔离实现细节,但仍然对静态分析友好,使用「Go to definition」即可确定完整的数据流向。类似于:

class 

其中 source1$、source2$ 与 source3$ 本身也可能是组合的结果,Observable 的可组合性一定程度上保证了分层设计中的隔离性,避免将数据聚合集中到最终消费者的位置。

不过,Observable 的使用本身仍然需要大量样板代码,对开发者而言是一个负担,例如 Angular 中可能出现的场景是:

@Component

对 Observable 的订阅导致了异步回调,从而导致过程逻辑的清晰度下降。不过,这才只是刚刚开始,由于存在订阅过程,我们需要在组件失效时取消订阅,否则该订阅会持续发生,导致内存泄漏:

class 

由于订阅状态的存在,需要提供额外的属性进行维护,进一步导致代码量增长。注意,Angular 中可能存在没有执行 ngOnInit 但执行了 ngOnDestroy 的情况,如果组件创建后没有经过任何一轮变化检测即被销毁,因此在 ngOnDestroy 中不能假定 ngOnInit 已经执行。如果熟悉 RxJS,可以使用 Subscription 的可组合性来同时管理多订阅信息,类似于:

class 

不过现实远没有这么美好,在 Angular 的设计理念中,Input 属性与 HTML Attribute 相对应,不仅可以被不断修改,也可能并非在初始化时提供,为此我们需要动态处理 clicks 输入:

class 

处理了输入动态变化之后,存在两处对外部数据源订阅过程,不仅分散了代码,也让各种执行时机变得不那么易读。同时,由于对 clicksSubscription 与 dataSubscription 取消订阅的时机不同,无法进行统一管理。

虽然 RxJS 具备强大的响应式能力,但是它的 API 设计对于组件(Component)的消费要求并不十分友好,需要浪费大量的重复成本来进行使用。为此,建立更高层次的抽象封装不可避免。

本小节在线示例:angular-grifve - StackBlitz

Angular 中的 AsyncPipe

管道(Pipe)是 Angular 模板中的通用数据变换机制,能够在不改变数据本身的情况下影响显示结果,例如上文中用到的 UpperCasePipe 能够将文字转换为大写形式。而 AsyncPipe 是一个更为特殊的管道,它的作用并不是简单地转换数据,而是将数据的异步容器 —— Observable 与 Promise —— 转换为内部的实际数据。使用了 AsyncPipe 后,我们将不再需要手动管理相关订阅状态:

class 

相比于手动订阅 Observable 而言,AsyncPipe 帮助节约了大量代码,但是仍然存在一些问题:

  1. 订阅过程仍然被分散,可能导致不必要的理解成本与重复代码;
  2. 初始状态难以在属性中确定,需要追踪 Observable 的定义;
  3. 副作用的执行过程较为隐蔽,不易发现;
  4. 数据仅存在内部订阅过程中,难以调试状态处理过程;
  5. 难以预览当前组件的整体状态;
  6. 难以复用最终数据内容。

这里简单对上面的描述进行一些补充说明,问题 1 的本质仍然是 ngOnChanges 与 ngOnInit 的竞态条件,如果我们需要在输入属性不存在时订阅另一个数据源,就会导致同一个属性的来源分散在多个不同位置,可能对后续维护者造成负担:

class 

问题 2 是我们无法在属性中定义初始(缺省)值,即便初始化为 of(1),由于属性整体在订阅前已经被替换为另一个 Observable,默认属性并不会生效,如果后续 Observable 未提供同步的初始值则会在第一个异步内容产生前导致 null 状态。

class 

问题 3 是副作用的处理过程发生在回调中,而不像单值数据能够在顶层显式处理:

class 

问题 4 是无法通过简单断点进行调试,为了能够增加断点必须引入额外的 tap 过程:

class 

问题 5 是即便增加了断点也无法在单个断点中同时获取多个 Observable 的状态,导致需要增加的断点数量以及人工记录成本大幅增加:

class 

问题 6 是 Observable 的订阅可能存在副作用,因此对同一 Observable 重复订阅并不安全,导致无法在模板中快速有效复用:

<!-- 

综上,AsyncPipe 的功能确实很强大,但是仍然没有达到组件响应式编程所需的目标高度。

本小节在线示例:angular-3otkus - StackBlitz

全新的 Reactive State 模式

为了解决 AsyncPipe 所存在的问题,我们的目标是:

  1. 所有状态更新仅存在于单个 Hook 方法;
  2. 能够利用属性自身的 Initializer 设置缺省值;
  3. 所有副作用在顶层位置同步执行;
  4. 组件属性仅承载实际数据,而非其容器对象;

那么,怎样的设计能够满足这些要求呢?

前文中我们提到:

不过真正的开发成本并不在于 View 与 ViewModel 的同步,而是存在于 Model 与 ViewModel 之间同步

那么既然 View 与 ViewModel 之间能够建立自动化的绑定关系,为什么不考虑在 Model 与 ViewModel 之间也建立自动化的绑定关系呢?换句话说,ViewModel 不仅作为 Binding Source,同时也作为 Binding Target,从而达到 Proxy 的效果。类似于:

class 

这样就几乎满足了上面的全部要求,称为真正维护友好的响应式组件。由于 JavaScript 语言自身的限制,这个模式并不便于直接实现,因此引入一些公用代码工具,最终变成:

class 

这里用到一个外部依赖,ng-reactive(名称暂定,完整示例参考 README),提供了 bind、state 等函数与 Reactive 基类,其核心理念是包括:

  • 模板永远只接受实际数据,任何容器都应该在组件类中转换为实际数据;
  • 设定 Reactive State 属性,能够作为 Observable 绑定的目标;
  • 同一个 Reactive State 只允许绑定一个数据源,重新绑定时会自动取消原有订阅;
  • 输入属性以及主动定义的 State 都属于组件状态,能够被响应;
  • 任何状态变化都会触发中心化的状态处理方法;

相比于 AsyncPipe,在各种意义上有效提高了可维护性。其中一个巨大的意义就是单元测试,因为对 Observable 的断言过于复杂,并且重复订阅存在未知的副作用,因此之前的测试中往往只能对 DOM(View)进行断言,而现在能够对组件类(ViewModel)进行断言。

Angular 中所有组件基于类实现,而状态存在于类属性,因此使用 if 并不会因为引入词法作用域影响状态传递,同时能够有效保证分支覆盖率并且规避内存垃圾的制造。同时避免引入学习成本,无需参考外部文档来理解传入回调的具体执行时机。

一个例外是基于视图相关的操作,由于视图还未更新完成,需要额外的等待才能进行视图操作,这时不可避免地需要引入回调函数:

if 

而其它类方法可能进一步分工,除了 Public API 之外的方法主要用于数据源的准备(产生或变换 Observable)与副作用的执行(调用视图或其他外部操作)。

本小节在线示例:angular-pf13c9 - StackBlitz

为什么要中心化处理?

与中心 Hook 相针对的一个模式是独立 Setter,分别处理每个状态自身的变化。不过后者实际上会遇到很多不便。

问题 1,多个状态可能是相关联的,共同作用于某一结果,例如以下场景:

@Component

这是一个很糟糕的设计,实践中该组件应该接收一个 Book id 而非多个独立输入,不过这里仅用于展示输入属性间的关联性。由于需要根据 name 和 author 来决定 title,因此其中任何一个属性的变化都会导致重新计算 title。然而实际上这两个属性几乎永远同时变化,由于无法确定触发顺序(由消费者模板书写顺序决定),因此需要重复两次计算过程,导致无意义的重复消耗。

问题 2,单独处理可能导致进入错误的中间状态。还是基于上面的例子,假设 name 的变化先于 author 的变化被触发,那么 name 的 setter 执行后 author 的 setter 执行前实际处于一个不正确的状态。而如果一旦发生错误,这个错误状态可能得到保留并对用户可见:

class 

问题 3,无法浏览组件状态。由于触发的顺序不定,某个属性 setter 的执行过程,其他属性可能已经被更新也可能尚未被更新,这时在调试器中看到的组件状态是一个部分更新的状态,容易妨碍开发人员作出合理判断。

为什么要基于类?

既然只有一个中心化的 Hook,为什么还要使用类(Class),而非函数(Function)呢?

事实上,类的绝对优势并不在于状态处理,而是在于通信机制。采用类定义组件保证了组件的实例存在,即可以通过实例引用获取其属性或触发其方法,这样避免了定义无意义的属性传递,提供了可读性。哪怕允许函数组件的存在,也会使得类实现与函数实现变为实现细节,实例引用无法再作为 API 载体,导致所有非立即生效的状态都会被做成输入,使得视图定义臃肿不堪。

一个最为直接的例子就是开关方法,在类定义中,可以直接提供相应的方法供消费者在合适的时机主动执行:

// Provider

而失去实例方法之后,组件只能提供一个开关状态作为外部输入,该状态与存在竞态关系的内部开关状态共同作用,决定最终的开启与关闭,如果需要得到最终状态还得设置额外回调属性或事件监听:

// Provider

即便不考虑额外属性增加的 Diff 成本,竞态关系的存在也极大提升了理解和维护成本。

写在最后

任何组件都应该能够随时响应任何合法的输入变化(业务逻辑可成立的情况下),有效的组件响应式设计是项目可维护性的重要保证之一,同时,中心化状态更新机制能够可靠地降低响应式设计成本。

RxJS 是一个优秀的响应式工具,但绝不是优秀的 ViewModel。

angular input_可视化的 Angular 响应式编程相关推荐

  1. 【响应式编程的思维艺术】 (5)Angular中Rxjs的应用示例

    [摘要] Rxjs在angular中的基本应用 本文是[Rxjs 响应式编程-第四章 构建完整的Web应用程序]这篇文章的学习笔记. 示例代码托管在:http://www.github.com/das ...

  2. 【Angular 4】响应式编程

    Rxjs? 响应式编程主要是通过观察者模式实现,在Angular中的Rxjs即是它的具体实现.它们的关系如下? 这个图只是简单的数据流,实际上,所有事物都可以用流处理,比如页面上的按钮点击事件: im ...

  3. 【响应式编程的思维艺术】 (1)Rxjs专题学习计划

    [摘要] 请暂时忘掉你的对象,感受一切皆流的世界. 一. 响应式编程 响应式编程,也称为流式编程,对于非前端工程师来说,可能并不是一个陌生的名词,它是函数式编程在软件开发中应用的延伸,如果你对函数式编 ...

  4. 理解响应式编程(RxJS)

    2019独角兽企业重金招聘Python工程师标准>>> 概念 学习angular2以上版本,或多或少会接触到Observable.subscribe等东西,本来打着用会Rx的API就 ...

  5. 通过RxJS理解响应式编程

    什么时候响应式编程 一句话概括就是用异步数据流来编程 从某种程度上讲,一个点击事件就是一个异步事件流,我们可以注册监听然后再做一些其他的事情.正是这样我们就应该有一个工具包来创建,组合,过滤这些流.一 ...

  6. Rxjs 响应式编程-第四章 构建完整的Web应用程序

    Rxjs 响应式编程-第一章:响应式 Rxjs 响应式编程-第二章:序列的深入研究 Rxjs 响应式编程-第三章: 构建并发程序 Rxjs 响应式编程-第四章 构建完整的Web应用程序 Rxjs 响应 ...

  7. 响应式编程笔记(二):代码编写

    2019独角兽企业重金招聘Python工程师标准>>> 响应式编程笔记(二):代码编写 博客分类: 架构 原文:Notes on Reactive Programming Part ...

  8. 走进JavaScript响应式编程(Reactive Programming)

    或许"响应式布局"这个名单大家都听过或者都自己实现过,那么"响应式编程"是什么呢?下面我们来具体聊一聊. 我的理解 从字面意思上我们可以大致理解为:所有的事件存 ...

  9. 赠书:响应式编程到底是什么?

    点击上方蓝色"程序猿DD",选择"设为星标" 回复"资源"获取独家整理的学习资料! 最近几年,随着Go.Node 等新语言.新技术的出现,J ...

最新文章

  1. MyBatis if标签的用法
  2. Task05:青少年软件编程(Python)等级考试模拟卷(一级)
  3. 专访SIGDIAL2020最佳论文一作高信龙一:成功都是一步步走出来的
  4. fatfree-f3小型php框架(二)
  5. 阿里云产品搭建web应用梳理
  6. 20165214 预备作业3 Linux安装及学习
  7. 记录一个自动创建分区的脚本
  8. Storm集群的安装及简单使用
  9. java从键盘上录入任何整数,输出该整数的阶乘
  10. @程序员,快收下这份比特币“勒索病毒”应对须知!
  11. Golang map 三板斧第二式:注意事项
  12. Blender:超详细的甜甜圈制作教程(一)【原教程 油管:Blender Guru】——建模篇
  13. 搭建eova开发环境
  14. Windows 7硬盘安装工具 NT6 HDD Installer v3.0(含图文教程)
  15. 信号的同调性(Coherence)分析及MATLAB实例
  16. 经营计划与经营利润分析动态报表的实现--业务需求
  17. 为什么说“管理是一门技术、更是一门艺术”
  18. 大神崛起必备的10大练手的Python项目 墙裂建议收藏!
  19. 和小公主一起学习Branch and Bound
  20. java环境变量设置 java_home

热门文章

  1. python 关闭udp端口_UDP聊天器
  2. mac的终端通过ssh远程连接Linux服务器
  3. redis源码剖析(2):基础数据结构ADLIST
  4. python实现人脸检测及识别(1)---- 采集人脸数据
  5. Android之——AsyncTask和Handler对照
  6. 很久的东西-也有价值
  7. CCF201703-3 Markdown(100分)【文本处理】
  8. NUC1157 To the Max【最大子段和+DP】
  9. NUC1474 Ants【水题】
  10. 数学类网站、代码(Matlab Python R)、编程站点