什么是RxJS?【美高梅59599】

什么是RxJS?【美高梅59599】

3. 跨端复用代码。

以前我们经常会考虑做响应式布局,目的是能够减少开发的工作量,尽量让一份代码在PC端和移动端复用。但是现在,越来越少的人这么做,原因是这样并不一定降低开发的难度,而且对交互体验的设计是一个巨大考验。那么,我们能不能退而求其次,复用尽量多的数据和业务逻辑,而开发两套视图层?

在这里,可能我们需要做一些取舍。

回忆一下MVVM这个词,很多人对它的理解流于形式,最关键的点在于,M和VM的差异是什么?即使是多数MVVM库比如Vue的用户,也未必能说得出。

在很多场景下,这两者并无明显分界,服务端返回的数据直接就适于在视图上用,很少需要加工。但是在我们这个方案中,还是比较明显的:

> —— Fetch ————-> | | View <– VM <– M <–
RESTful ^ | <– WebSocket

1
2
3
4
5
> —— Fetch ————->
|                           |
View  <–  VM  <–  M  <–  RESTful
                    ^
                    |  <–  WebSocket

这个简图大致描述了数据的流转关系。其中,M指代的是对原始数据的封装,而VM则侧重于面向视图的数据组合,把来自M的数据流进行组合。

我们需要根据业务场景考虑:是要连VM一起跨端复用呢,还是只复用M?考虑清楚了这个问题之后,我们才能确定数据层的边界所在。

除了在PC和移动版之间复用代码,我们还可以考虑拿这块代码去做服务端渲染,甚至构建到一些Native方案中,毕竟这块主要的代码也是纯逻辑。

单返回值 多返回值
Pull/Synchronous/Interactive Object Iterables (Array / Set / Map / Object)
Push/Asynchronous/Reactive Promise Observable

缓存的使用

如果说我们的业务里,有一些数据是通过WebSocket把更新都同步过来,这些数据在前端就始终是可信的,在后续使用的时候,可以作一些复用。

比如说:

在一个项目中,项目所有成员都已经查询过,数据全在本地,而且变更有WebSocket推送来保证。这时候如果要新建一条任务,想要从项目成员中指派任务的执行人员,可以不必再发起查询,而是直接用之前的数据,这样选择界面就可以更流畅地出现。

这时候,从视图角度看,它需要解决一个问题:

  • 如果要获取的数据未有缓存,它需要产生一个请求,这个调用过程就是异步的
  • 如果要获取的数据已有缓存,它可以直接从缓存中返回,这个调用过程就是同步的

如果我们有一个数据层,我们至少期望它能够把同步和异步的差异屏蔽掉,否则要使用两种代码来调用。通常,我们是使用Promise来做这种差异封装的:

JavaScript

function getDataP() : Promise<T> { if (data) { return
Promise.resolve(data) } else { return fetch(url) } }

1
2
3
4
5
6
7
function getDataP() : Promise<T> {
  if (data) {
    return Promise.resolve(data)
  } else {
    return fetch(url)
  }
}

这样,使用者可以用相同的编程方式去获取数据,无需关心内部的差异。

RxJS字面意思就是:JavaScript的响应式扩展(Reactive Extensions for
JavaScript)。

1. 视图的极度轻量化。

我们可以看到,如果视图所消费的数据都是来源于从核心模型延伸并组合而成的各种数据流,那视图层的职责就非常单一,无非就是根据订阅的数据渲染界面,所以这就使得整个视图层非常薄。而且,视图之间是不太需要打交道的,组件之间的通信很少,大家都会去跟数据层交互,这意味着几件事:

  • 视图的变更难度大幅降低了
  • 视图的框架迁移难度大幅降低了
  • 甚至同一个项目中,在必要的情况下,还可以混用若干种视图层方案(比如刚好需要某个组件)

我们采用了一种相对中立的底层方案,以抵抗整个应用架构在前端领域日新月异的情况下的变更趋势。

使用RxJS,你可以用Observer 对象来表示多个异步数据流
(那些来自多个数据源的,比如,股票报价,微博,计算机事件,
网络服务请求,等等。),还可以用Observer
对象订阅事件流。无论事件何时触发,Observable 对象都会通知订阅它的
Observer对象。

服务端推送

如果要引入服务端推送,怎么调整?

考虑一个典型场景,WebIM,如果要在浏览器中实现这么一个东西,通常会引入WebSocket作更新的推送。

对于一个聊天窗口而言,它的数据有几个来源:

  • 初始查询
  • 本机发起的更新(发送一条聊天数据)
  • 其他人发起的更新,由WebSocket推送过来
视图展示的数据 := 初始查询的数据 + 本机发起的更新 + 推送的更新

<table>
<colgroup>
<col style="width: 50%" />
<col style="width: 50%" />
</colgroup>
<tbody>
<tr class="odd">
<td><div class="crayon-nums-content" style="font-size: 13px !important; line-height: 15px !important;">
<div class="crayon-num" data-line="crayon-5b8f4b62cb7b7061328078-1">
1
</div>
</div></td>
<td><div class="crayon-pre" style="font-size: 13px !important; line-height: 15px !important; -moz-tab-size:4; -o-tab-size:4; -webkit-tab-size:4; tab-size:4;">
<div id="crayon-5b8f4b62cb7b7061328078-1" class="crayon-line">
视图展示的数据 := 初始查询的数据 + 本机发起的更新 + 推送的更新
</div>
</div></td>
</tr>
</tbody>
</table>

这里,至少有两种编程方式。

查询数据的时候,我们使用类似Promise的方式:

JavaScript

getListData().then(data => { // 处理数据 })

1
2
3
getListData().then(data => {
  // 处理数据
})

而响应WebSocket的时候,用类似事件响应的方式:

JavaScript

ws.on(‘data’, data => { // 处理数据 })

1
2
3
ws.on(‘data’, data => {
  // 处理数据
})

这意味着,如果没有比较好的统一,视图组件里至少需要通过这两种方式来处理数据,添加到列表中。

如果这个场景再跟上一节提到的多视图共享结合起来,就更复杂了,可能很多视图里都要同时写这两种处理。

所以,从这个角度看,我们需要有一层东西,能够把拉取和推送统一封装起来,屏蔽它们的差异。

无论你在用
Node.js编写一个web端应用还是服务端应用,你都必须经常处理异步和基于事件的编程。Web应用程序和Node.js应用程序都会碰到I
/
O操作和计算耗时的任务,这些任务可能需要很长时间才能完成,并可能会阻塞主线程。而且,处理异常,取消和同步也很麻烦,并且容易出错。

综合场景

以上,我们述及四种典型的对前端数据层有诉求的场景,如果存在更复杂的情况,兼有这些情况,又当如何?

Teambition的场景正是这么一种情况,它的产品特点如下:

  • 大部分交互都以对话框的形式展现,在视图的不同位置,存在大量的共享数据,以任务信息为例,一条任务数据对应渲染的视图可能会有20个这样的数量级。
  • 全业务都存在WebSocket推送,把相关用户(比如处于同一项目中)的一切变更都发送到前端,并实时展示
  • 很强调无刷新,提供一种类似桌面软件的交互体验

比如说:

当一条任务变更的时候,无论你处于视图的什么状态,需要把这20种可能的地方去做同步。

当任务的标签变更的时候,需要把标签信息也查找出来,进行实时变更。

甚至:

  • 如果某个用户更改了自己的头像,而他的头像被到处使用了?
  • 如果当前用户被移除了与所操作对象的关联关系,导致权限变更,按钮禁用状态改变了?
  • 如果别人修改了当前用户的身份,在管理员和普通成员之间作了变化,视图怎么自动变化?

当然这些问题都是可以从产品角度权衡的,但是本文主要考虑的还是如果产品角度不放弃对某些极致体验的追求,从技术角度如何更容易地去做。

我们来分析一下整个业务场景:

  • 存在全业务的细粒度变更推送 => 需要在前端聚合数据
  • 前端聚合 => 数据的组合链路长
  • 视图大量共享数据 => 数据变更的分发路径多

这就是我们得到的一个大致认识。

推送模式 vs 拉取模式

在交互式编程中,应用程序为了获取更多信息会主动遍历一个数据源,通过检索一个代表数据源的序列。这种行为就像是JavaScript数组,对象,集合,映射等的迭代器模式。在交互式编程中,必须通过数组中的索引或通过ES6
iterators
来获取下一项。

在拉取模式中,应用程序在数据检索过程中处于活动状态:
它通过自己主动调用next来决定检索的速度。
此枚举模式是同步的,这意味着在轮询数据源时可能会阻止您的应用程序的主线程。
这种拉取模式好比是你在图书馆翻阅一本书。
你阅读完成这本书后,你才能去读另一本。

另一方面在响应式编程中,应用程序通过订阅数据流获得更多的信息
(在RxJS中称为可观测序列),数据源的任何更新都传递给可观测序列。这种模式下应用是被动接收数据:除了订阅可观察的来源,并不会主动查询来源,而只是对推送给它的数据作出反应。事件完成后,信息来源将向用户发送通知。这样,您的应用程序将不会被等待源更新阻止。

这是RxJS采用的推送模式。
这好比是加入一个图书俱乐部,在这个图书俱乐部中你注册了某个特定类型的兴趣组,而符合你兴趣的书籍在发布时会自动发送给你。
而不需要排队去搜索获取你想要的书籍。
在重UI应用中,使用推送数据模式尤其有用,在程序等待某些事件时,UI线程不会被阻塞,这使得在具有异步要求的JavaScript运行环境中非常重要。
总之,利用RxJS,可使应用程序更具响应性。

Observable / Observer的可观察模式就是Rx实现的推送模型。
Observable对象会自动通知所有观察者状态变化。
请使用Observablesubscribe方法来订阅,subscribe方法需要Observer对象并返回Disposable对象。
这使您能够跟踪您的订阅,并能够处理订阅。
您可以将可观察序列(如一系列的鼠标悬停事件)视为普通的集合。
RxJS对可观察序列的内置实现的查询,允许开发人员在基于推送序列(如事件,回调,Promise,HTML5地理定位API等等)上组合复杂的事件处理。有关这两个接口的更多信息,请参阅探索
RxJS的主要概念

RxJS

遍观流行的辅助库,我们会发现,基于数据流的一些方案会对我们有较大帮助,比如RxJS,xstream等,它们的特点刚好满足了我们的需求。

以下是这类库的特点,刚好是迎合我们之前的诉求。

  • Observable,基于订阅模式
  • 类似Promise对同步和异步的统一
  • 查询和推送可统一为数据管道
  • 容易组合的数据管道
  • 形拉实推,兼顾编写的便利性和执行的高效性
  • 懒执行,不被订阅的数据流不执行

这些基于数据流理念的库,提供了较高层次的抽象,比如下面这段代码:

JavaScript

function getDataO(): Observable<T> { if (cache) { return
Observable.of(cache) } else { return Observable.fromPromise(fetch(url))
} } getDataO().subscribe(data => { // 处理数据 })

1
2
3
4
5
6
7
8
9
10
11
12
function getDataO(): Observable<T> {
  if (cache) {
    return Observable.of(cache)
  }
  else {
    return Observable.fromPromise(fetch(url))
  }
}
 
getDataO().subscribe(data => {
  // 处理数据
})

这段代码实际上抽象程度很高,它至少包含了这么一些含义:

  • 统一了同步与异步,兼容有无缓存的情况
  • 统一了首次查询与后续推送的响应,可以把getDataO方法内部这个Observable也缓存起来,然后把推送信息合并进去

我们再看另外一段代码:

JavaScript

const permission$: Observable<boolean> = Observable
.combineLatest(task$, user$) .map(data => { let [task, user] = data
return user.isAdmin || task.creatorId === user.id })

1
2
3
4
5
6
const permission$: Observable<boolean> = Observable
  .combineLatest(task$, user$)
  .map(data => {
    let [task, user] = data
    return user.isAdmin || task.creatorId === user.id
  })

这段代码的意思是,根据当前的任务和用户,计算是否拥有这条任务的操作权限,这段代码其实也包含了很多含义:

首先,它把两个数据流task$和user$合并,并且计算得出了另外一个表示当前权限状态的数据流permission$。像RxJS这类数据流库,提供了非常多的操作符,可用于非常简便地按照需求把不同的数据流合并起来。

我们这里展示的是把两个对等的数据流合并,实际上,还可以进一步细化,比如说,这里的user$,我们如果再追踪它的来源,可以这么看待:

某用户的数据流user$ := 对该用户的查询 +
后续对该用户的变更(包括从本机发起的,还有其他地方更改的推送)

如果说,这其中每个因子都是一个数据流,它们的叠加关系就不是对等的,而是这么一种东西:

  • 每当有主动查询,就会重置整个user$流,恢复一次初始状态
  • user$等于初始状态叠加后续变更,注意这是一个reduce操作,也就是把后续的变更往初始状态上合并,然后得到下一个状态

这样,这个user$数据流才是“始终反映某用户当前状态”的数据流,我们也就因此可以用它与其它流组合,参与后续运算。

这么一段代码,其实就足以覆盖如下需求:

  • 任务本身变化了(执行者、参与者改变,导致当前用户权限不同)
  • 当前用户自身的权限改变了

这两者导致后续操作权限的变化,都能实时根据需要计算出来。

其次,这是一个形拉实推的关系。这是什么意思呢,通俗地说,如果存在如下关系:

JavaScript

c = a + b //
不管a还是b发生更新,c都不动,等到c被使用的时候,才去重新根据a和b的当前值计算

1
c = a + b     // 不管a还是b发生更新,c都不动,等到c被使用的时候,才去重新根据a和b的当前值计算

如果我们站在对c消费的角度,写出这么一个表达式,这就是一个拉取关系,每次获取c的时候,我们重新根据a和b当前的值来计算结果。

而如果站在a和b的角度,我们会写出这两个表达式:

JavaScript

c = a1 + b // a1是当a变更之后的新值 c = a + b1 // b1是当b变更之后的新值

1
2
c = a1 + b     // a1是当a变更之后的新值
c = a + b1    // b1是当b变更之后的新值

这是一个推送关系,每当有a或者b的变更时,主动重算并设置c的新值。

如果我们是c的消费者,显然拉取的表达式写起来更简洁,尤其是当表达式更复杂时,比如:

JavaScript

e = (a + b ) * c – d

1
e = (a + b ) * c – d

如果用推的方式写,要写4个表达式。

所以,我们写订阅表达式的时候,显然是从使用者的角度去编写,采用拉取的方式更直观,但通常这种方式的执行效率都较低,每次拉取,无论结果是否变更,都要重算整个表达式,而推送的方式是比较高效精确的。

但是刚才RxJS的这种表达式,让我们写出了形似拉取,实际以推送执行的表达式,达到了编写直观、执行高效的结果。

看刚才这个表达式,大致可以看出:

permission$ := task$ + user$

这么一个关系,而其中每个东西的变更,都是通过订阅机制精确发送的。

有些视图库中,也会在这方面作一些优化,比如说,一个计算属性(computed
property),是用拉的思路写代码,但可能会被框架分析依赖关系,在内部反转为推的模式,从而优化执行效率。

此外,这种数据流还有其它魔力,那就是懒执行。

什么是懒执行呢?考虑如下代码:

JavaScript

const a$: Subject<number> = new Subject<number>() const b$:
Subject<number> = new Subject<number>() const c$:
Observable<number> = Observable.combineLatest(a$, b$) .map(arr
=> { let [a, b] = arr return a + b }) const d$:
Observable<number> = c$.map(num => { console.log(‘here’) return
num + 1 }) c$.subscribe(data => console.log(`c: ${data}`))
a$.next(2) b$.next(3) setTimeout(() => { a$.next(4) }, 1000)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const a$: Subject<number> = new Subject<number>()
const b$: Subject<number> = new Subject<number>()
 
const c$: Observable<number> = Observable.combineLatest(a$, b$)
  .map(arr => {
    let [a, b] = arr
    return a + b
  })
 
const d$: Observable<number> = c$.map(num => {
  console.log(‘here’)
  return num + 1
})
 
c$.subscribe(data => console.log(`c: ${data}`))
 
a$.next(2)
b$.next(3)
 
setTimeout(() => {
  a$.next(4)
}, 1000)

注意这里的d$,如果a$或者b$中产生变更,它里面那个here会被打印出来吗?大家可以运行一下这段代码,并没有。为什么呢?

因为在RxJS中,只有被订阅的数据流才会执行。

主题所限,本文不深究内部细节,只想探讨一下这个特点对我们业务场景的意义。

想象一下最初我们想要解决的问题,是同一份数据被若干个视图使用,而视图侧的变化是我们不可预期的,可能在某个时刻,只有这些订阅者的一个子集存在,其它推送分支如果也执行,就是一种浪费,RxJS的这个特性刚好能让我们只精确执行向确实存在的视图的数据流推送。

RxJS可与诸如数组,集合和映射之类的同步数据流以及诸如Promises之类的单值异步计算进行互补和顺畅的互操作,如下图所示:

RxJS与其它方案的对比

因为可观察序列是数据流,你可以用Observable的扩展方法实现的标准查询运算符来查询它们。从而,你可以使用这些标准查询运算符轻松筛选,投影(project),聚合,撰写和执行基于时间轴(time-based)的多个事件的操作。此外,还有一些其他反应流特定的操作符允许强大的查询写入。
通过使用Rx提供的扩展方法,还可以正常处理取消,异常和同步。

1. 与watch机制的对比

不少视图层方案,比如Angular和Vue中,存在watch这么一种机制。在很多场景下,watch是一种很便捷的操作,比如说,想要在某个对象属性变更的时候,执行某些操作,就可以使用它,大致代码如下:

JavaScript

watch(‘a.b’, newVal => { // 处理新数据 })

1
2
3
watch(‘a.b’, newVal => {
  // 处理新数据
})

这类监控机制,其内部实现无非几种,比如自定义了setter,拦截数据的赋值,或者通过对比新旧数据的脏检查方式,或者通过类似Proxy的机制代理了数据的变化过程。

从这些机制,我们可以得到一些推论,比如说,它在对大数组或者复杂对象作监控的时候,监控效率都会降低。

有时候,我们也会有监控多个数据,以合成另外一个的需求,比如:

一条用于展示的任务数据 := 这条任务的原始数据 + 任务上的标签信息 +
任务的执行者信息

如果不以数据流的方式编写,这地方就需要为每个变量单独编写表达式或者批量监控多个变量,前者面临的问题是代码冗余,跟前面我们提到的推数据的方式类似;后者面临的问题就比较有意思了。

监控的方式会比计算属性强一些,原因在于计算属性处理不了异步的数据变更,而监控可以。但如果监控条件进一步复杂化,比如说,要监控的数据之间存在竞争关系等等,都不是容易表达出来的。

另外一个问题是,watch不适合做长链路的变更,比如:

JavaScript

c := a + b d := c + 1 e := a * c f := d * e

1
2
3
4
c := a + b
d := c + 1
e := a * c
f := d * e

这种类型,如果要用监控表达式写,会非常啰嗦。

RxJS是一个利用可观察(observable)序列和LINQ查询操作符来处理异步以及基于事件程序的一个库。通过RxJS,
开发人员用Observables表示
异步数据流,用LINQ运算符查询
异步数据流,并使用Schedulers参数化
异步数据流中的并发。简而言之,Rx = Observables + LINQ + Schedulers。

技术诉求

以上,我们介绍了业务场景,分析了技术特点。假设我们要为这么一种复杂场景设计数据层,它要提供怎样的接口,才能让视图使用起来简便呢?

从视图角度出发,我们有这样的诉求:

  • 类似订阅的使用方式(只被上层依赖,无反向链路)。这个来源于多视图对同一业务数据的共享,如果不是类似订阅的方式,职责就反转了,对维护不利
  • 查询和推送的统一。这个来源于WebSocket的使用。
  • 同步与异步的统一。这个来源于缓存的使用。
  • 灵活的可组合性。这个来源于细粒度数据的前端聚合。

根据这些,我们可用的技术选型是什么呢?

后记

不久前,我写过一篇总结,内容跟本文有不少重合之处,但为什么还要写这篇呢?

上一篇,讲问题的视角是从解决方案本身出发,阐述解决了哪些问题,但是对这些问题的来龙去脉讲得并不清晰。很多读者看完之后,仍然没有得到深刻认识。

这一篇,我希望从场景出发,逐步展示整个方案的推导过程,每一步是怎样的,要如何去解决,整体又该怎么做,什么方案能解决什么问题,不能解决什么问题。

上次我那篇讲述在Teambition工作经历的回答中,也有不少人产生了一些误解,并且有反复推荐某些全家桶方案,认为能够包打天下的。平心而论,我对方案和技术选型的认识还是比较慎重的,这类事情,事关技术方案的严谨性,关系到自身综合水准的鉴定,不得不一辩到底。当时关注八卦,看热闹的人太多,对于探讨技术本身倒没有展现足够的热情,个人认为比较可惜,还是希望大家能够多关注这样一种有特色的技术场景。因此,此文非写不可。

如果有关注我比较久的,可能会发现之前写过不少关于视图层方案技术细节,或者组件化相关的主题,但从15年年中开始,个人的关注点逐步过渡到了数据层,主要是因为上层的东西,现在研究的人已经多起来了,不劳我多说,而各种复杂方案的数据层场景,还需要作更艰难的探索。可预见的几年内,我可能还会在这个领域作更多探索,前路漫漫,其修远兮。

(整个这篇写起来还是比较顺利的,因为之前思路都是完整的。上周在北京闲逛一周,本来是比较随意交流的,鉴于有些公司的朋友发了比较正式的分享邮件,花了些时间写了幻灯片,在百度、去哪儿网、58到家等公司作了比较正式的分享,回来之后,花了一整天时间整理出了本文,与大家分享一下,欢迎探讨。)

2 赞 4 收藏
评论

美高梅59599 1

2. 增强了整个应用的可测试性。

因为数据层的占比较高,并且相对集中,所以可以更容易对数据层做测试。此外,由于视图非常薄,甚至可以脱离视图打造这个应用的命令行版本,并且把这个版本与e2e测试合为一体,进行覆盖全业务的自动化测试。

admin

网站地图xml地图