New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

复杂单页应用的数据层设计 #42

Open
xufei opened this Issue Jan 2, 2017 · 7 comments

Comments

Projects
None yet
7 participants
@xufei
Owner

xufei commented Jan 2, 2017

复杂单页应用的数据层设计

很多人看到这个标题的时候,会产生一些怀疑:

什么是“数据层”?前端需要数据层吗?

可以说,绝大部分场景下,前端是不需要数据层的,如果业务场景出现了一些特殊的需求,尤其是为了无刷新,很可能会催生这方面的需要。

我们来看几个场景,再结合场景所产生的一些诉求,探讨可行的实现方式。

视图间的数据共享

所谓共享,指的是:

同一份数据被多处视图使用,并且要保持一定程度的同步。

如果一个业务场景中,不存在视图之间的数据复用,可以考虑使用端到端组件。

什么是端到端组件呢?

我们看一个示例,在很多地方都会碰到选择城市、地区的组件。这个组件对外的接口其实很简单,就是选中的项。但这时候我们会有一个问题:

这个组件需要的省市区域数据,是由这个组件自己去查询,还是使用这个组件的业务去查好了传给这个组件?

两者当然是各有利弊的,前一种,它把查询逻辑封装在自己内部,对使用者更加有利,调用方只需这么写:

<RegionSelector selected=“callback(region)”></RegionSelector>

外部只需实现一个响应取值事件的东西就可以了,用起来非常简便。这样的一个组件,就被称为端到端组件,因为它独自打通了从视图到后端的整个通道。

这么看来,端到端组件非常美好,因为它对使用者太便利了,我们简直应当拥抱它,放弃其他所有。

端到端组件示意图:

A | B | C
---------
 Server

可惜并非如此,选择哪种组件实现方式,是要看业务场景的。如果在一个高度集成的视图中,刚才这个组件同时出现了多次,就有些尴尬了。

尴尬的地方在哪里呢?首先是同样的查询请求被触发了多次,造成了冗余请求,因为这些组件互相不知道对方的存在,当然有几个就会查几份数据。这其实是个小事,但如果同时还存在修改这些数据的组件,就麻烦了。

比如说:在选择某个实体的时候,发现之前漏了配置,于是点击“立刻配置”,新增了一条,然后回来继续原流程。

例如,买东西填地址的时候,发现想要的地址不在列表中,于是点击弹出新增,在不打断原流程的情况下,插入了新数据,并且可以选择。

这个地方的麻烦之处在于:

组件A的多个实例都是纯查询的,查询的是ModelA这样的数据,而组件B对ModelA作修改,它当然可以把自己的那块界面更新到最新数据,但是这么多A的实例怎么办,它们里面都是老数据,谁来更新它们,怎么更新?

这个问题为什么很值得说呢,因为如果没有一个良好的数据层抽象,你要做这个事情,一个业务上的选择和会有两个技术上的选择:

  • 引导用户自己刷新界面
  • 在新增完成的地方,写死一段逻辑,往查询组件中加数据
  • 发一个自定义业务事件,让查询组件自己响应这个事件,更新数据

这三者都有缺点:

  • 引导用户刷新界面这个,在技术上是比较偷懒的,可能体验未必好。
  • 写死逻辑这个,倒置了依赖顺序,导致代码产生了反向耦合,以后再来几个要更新的地方,这里代码改得会很痛苦,而且,我一个配置的地方,为什么要管你后续增加的那些查询界面?
  • 自定义业务事件这个,耦合是减少了,却让查询组件自己的逻辑膨胀了不少,如果要监听多种消息,并且合并数据,可能这里更复杂,能否有一种比较简化的方式?

所以,从这个角度看,我们需要一层东西,垫在整个组件层下方,这一层需要能够把查询和更新做好抽象,并且让视图组件使用起来尽可能简单。

另外,如果多个视图组件之间的数据存在时序关系,不提取出来整体作控制的话,也很难去维护这样的代码。

添加了数据层之后的整体关系如图:

 A | B | C
------------
前端的数据层
------------
  Server

那么,视图访问数据层的接口会是什么样?

我们考虑耦合的问题。如果要减少耦合,很必然的就是这么一种形式:

  • 变更的数据产生某种消息
  • 使用者订阅这个消息,做一些后续处理

因此,数据层应当尽可能对外提供类似订阅方式的接口。

服务端推送

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

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

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

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

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

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

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

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

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

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

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

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

缓存的使用

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

比如说:

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

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

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

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

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

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

数据的聚合

很多时候,视图上需要的数据与数据库存储的形态并不完全相同,在数据库中,我们总是倾向于储存更原子化的数据,并且建立一些关联,这样,从这种数据想要变成视图需要的格式,免不了需要一些聚合过程。

通常我们指的聚合有这么几种:

  • 在服务端先聚合数据,然后再把这些数据与视图模板聚合,形成HTML,整体输出,这个过程也称为服务端渲染
  • 在服务端只聚合数据,然后把这些数据返回到前端,再生成界面
  • 服务端只提供原子化的数据接口,前端根据自己的需要,请求若干个接口获得数据,聚合成视图需要的格式,再生成界面

大部分传统应用在服务端聚合数据,通过数据库的关联,直接查询出聚合数据,或者在Web服务接口的地方,聚合多个底层服务接口。

我们需要考虑自己应用的特点来决定前端数据层的设计方案。有的情况下,后端返回细粒度的接口会比聚合更合适,因为有的场景下,我们需要细粒度的数据更新,前端需要知道数据之间的变更联动关系。

所以,很多场景下,我们可以考虑在后端用GraphQL之类的方式来聚合数据,或者在前端用类似Linq的方式聚合数据。但是,注意到如果这种聚合关系要跟WebSocket推送产生关联,就会比较复杂。

我们拿一个场景来看,假设有一个界面,长得像新浪微博的Feed流。对于一条Feed而言,它可能来自几个实体:

Feed消息本身

class Feed {
  content: string
  creator: UserId
  tags: TagId[]
}

Feed被打的标签

class Tag {
  id: TagId
  content: string
}

人员

class User {
  id: UserId
  name: string
  avatar: string
}

如果我们的需求跟微博一样,肯定还是会选择第一种聚合方式,也就是服务端渲染。但是,如果我们的业务场景中,存在大量的细粒度更新,就比较有意思了。

比如说,如果我们修改一个标签的名称,就要把关联的Feed上的标签也刷新,如果之前我们把数据聚合成了这样:

class ComposedFeed {
  content: string
  creator: User
  tags: Tag[]
}

就会导致无法反向查找聚合后的结果,从中筛选出需要更新的东西。如果我们能够保存这个变更路径,就比较方便了。所以,在存在大量细粒度更新的情况下,服务端API零散化,前端负责聚合数据就比较合适了。

当然这样会带来一个问题,那就是请求数量增加很多。对此,我们可以变通一下:

做物理聚合,不做逻辑聚合。

这段话怎么理解呢?

我们仍然可以在一个接口中一次获取所需的各种数据,只是这种数据格式可能是:

{
  feed: Feed
  tags: Tags[]
  user: User
}

不做深度聚合,只是简单地包装一下。

在这个场景中,我们对数据层的诉求是:建立数据之间的关联关系。

综合场景

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

Teambition任务面板

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

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

比如说:

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

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

甚至:

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

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

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

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

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

技术诉求

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

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

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

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

主流框架对数据层的考虑

一直以来,前端框架的侧重点都是视图部分,因为这块是普适性很强的,但在数据层方面,一般都没有很深入的探索。

  • React, Vue 两者主要侧重数据和视图的同步,生态体系中有一些库会在数据逻辑部分做一些事情
  • Angular,看似有Service这类可以封装数据逻辑的东西,实际上远远不够,有形无实,在Service内部必须自行做一些事情
  • Backbone,做了一些业务模型实体和关联关系的抽象,更早的ExtJS也做了一些事情

综合以上,我们可以发现,几乎所有现存方案都是不完整的,要么只做实体和关系的抽象,要么只做数据变化的封装,而我们需要的是实体的关系定义和数据变更链路的封装,所以需要自行作一些定制。

那么,我们有怎样的技术选型呢?

RxJS

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

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

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

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

function getDataO(): Observable<T> {
  if (cache) {
    return Observable.of(cache)
  }
  else {
    return Observable.fromPromise(fetch(url))
  }
}

getDataO().subscribe(data => {
  // 处理数据
})

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

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

我们再看另外一段代码:

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$数据流才是“始终反映某用户当前状态”的数据流,我们也就因此可以用它与其它流组合,参与后续运算。

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

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

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

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

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

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

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

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

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

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

e = (a + b ) * c - d

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

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

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

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

permission$ := task$ + user$

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

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

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

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

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与其它方案的对比

1. 与watch机制的对比

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

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

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

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

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

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

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

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

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

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

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

2. 跟Redux的对比

Rx和Redux其实没有什么关系。在表达数据变更的时候,从逻辑上讲,这两种技术是等价的,一种方式能表达出的东西,另外一种也都能够。

比如说,同样是表达数据a到b这么一个转换,两者所关注的点可能是不一样的:

  • Redux:定义一个action叫做AtoB,在其实现中,把a转换成b
  • Rx:定义两个数据流A和B,B是从A经过一次map转换得到的,map的表达式是把a转成b

由于Redux更多地是一种理念,它的库功能并不复杂,而Rx是一种强大的库,所以两者直接对比并不合适,比如说,可以用Rx依照Redux的理念作实现,但反之不行。

在数据变更的链路较长时,Rx是具有很大优势的,它可以很简便地做多级状态变更的连接,也可以做数据变更链路的复用(比如存在a -> b -> c,又存在a -> b -> d,可以把a -> b这个过程拿出来复用),还天生能处理好包括竞态在内的各种异步的情况,Redux可能要借助saga等理念才能更好地组织代码。

我们之前有些demo代码也提到了,比如说:

用户信息数据流 := 用户信息的查询 + 用户信息的更新

这段东西就是按照reducer的理念去写的,跟Redux类似,我们把变更操作放到一个数据流中,然后用它去累积在初始状态上,就能得到始终反映某个实体当前状态的数据流

在Redux方案中,中间件是一种比较好的东西,能够对业务产生一定的约束,如果我们用RxJS实现,可以把变更过程中间接入一个统一的数据流来完成同样的事情。

具体方案

以上我们谈了以RxJS为代表的数据流库的这么多好处,彷佛有了它,就像有了民主,人民就自动吃饱穿暖,物质文化生活就自动丰富了,其实不然。任何一个框架和库,它都不是来直接解决我们的业务问题的,而是来增强某方面的能力的,它刚好可以为我们所用,作为整套解决方案的一部分。

至此,我们的数据层方案还缺失什么东西吗?

考虑如下场景:

某个任务的一条子任务产生了变更,我们会让哪条数据流产生变更推送?

分析子任务的数据流,可以大致得出它的来源:

subtask$ = subtaskQuery$ + subtaskUpdate$

看这句伪代码,加上我们之前的解释(这是一个reduce操作),我们得到的结论是,这条任务对应的subtask$数据流会产生变更推送,让视图作后续更新。

仅仅这样就可以了吗?并没有这么简单。

从视图角度看,我们还存在这样的对子任务的使用:那就是任务的详情界面。但这个界面订阅的是这条子任务的所属任务数据流,在其中任务数据包含的子任务列表中,含有这条子任务。所以,它订阅的并不是subtask$,而是task$。这么一来,我们必须使task$也产生更新,以此推动任务详情界面的刷新。

那么,怎么做到在subtask的数据流变更的时候,也推动所属task的数据流变更呢?这个事情并非RxJS本身能做的,也不是它应该做的。我们之前用RxJS来封装的部分,都只是数据的变更链条,记得之前我们是怎么描述数据层解决方案的吗?

实体的关系定义和数据变更链路的封装

我们前面关注的都是后面一半,前面这一半,还完全没做呢!

实体的变更关系如何做呢,办法其实很多,可以用类似Backbone的Model和Collection那样做,也可以用更加专业的方案,引入一个ORM机制来做。这里面的实现就不细说了,那是个相对成熟的领域,而且说起来篇幅太大,有疑问的可以自行了解。

需要注意的是,我们在这个里面需要考虑好与缓存的结合,前端的缓存很简单,基本就是一种精简的k-v数据库,在做它的存储的时候,需要做到两件事:

  • 以集合形式获取的数据,需要拆分放入缓存,比如Task[],应当以每个Task的TaskId为索引,分别单独存储
  • 有时候后端返回的数据可能是不完整的,或者格式有差异,需要在储存之间作正规化(normalize)

总结以上,我们的思路是:

  • 缓存 => 基于内存的微型k-v数据库
  • 关联变更 => 使用ORM的方式抽象业务实体和变更关系
  • 细粒度推送 => 某个实体的查询与变更先合并为数据流
  • 从实体的变更关系,引出数据流,并且所属实体的流
  • 业务上层使用这些原始数据流以组装后续变更

更深入的探索

如果说我们针对这样的复杂场景,实现了这么一套复杂的数据层方案,还可以有什么有意思的事情做呢?

这里我开几个脑洞:

  • 用Worker隔离计算逻辑
  • 用ServiceWorker实现本地共享
  • 与本地持久缓存结合
  • 前后端状态共享
  • 可视化配置

我们一个一个看,好玩的地方在哪里。

第一个,之前提到,整个方案的核心是一种类似ORM的机制,外加各种数据流,这里面必然涉及数据的组合、计算之类,那么我们能否把它们隔离到渲染线程之外,让整个视图变得更流畅?

第二个,很可能我们会碰到同时开多个浏览器选项卡的客户,但是每个选项卡展现的界面状态可能不同。正常情况下,我们的整个数据层会在每个选项卡中各存在一份,并且独立运行,但其实这是没有必要的,因为我们有订阅机制来保证可以扩散到每个视图。那么,是否可以用过ServiceWorker之类的东西,实现跨选项卡的数据层共享?这样就可以减少很多计算的负担。

对这两条来说,让数据流跨越线程,可能会存在一些障碍待解决。

第三个,我们之前提到的缓存,全部是在内存中,属于易失性缓存,只要用户关掉浏览器,就全部丢了,可能有的情况下,我们需要做持久缓存,比如把不太变动的东西,比如企业通讯录的人员名单存起来,这时候可以考虑在数据层中加一些异步的与本地存储通信的机制,不但可以存localStorage之类的key-value存储,还可以考虑存本地的关系型数据库。

第四个,在业务和交互体验复杂到一定程度的时候,服务端未必还是无状态的,想要在两者之间做好状态共享,有一定的挑战。基于这么一套机制,可以考虑在前后端之间打通一个类似meteor的通道,实现状态共享。

第五个,这个话题其实跟本文的业务场景无关,只是从第四个话题引发。很多时候我们期望能做到可视化配置业务系统,但一般最多也就做到配置视图,所以,要么做到的是一个配置运营页面的东西,要么是能生成一个脚手架,供后续开发使用,但是一旦开始写代码,就没法合并回来。究其原因,是因为配不出组件的数据源和业务逻辑,找不到合理的抽象机制。如果有第四条那么一种铺垫,也许是可以做得比较好的,用数据流作数据源,还是挺合适的,更何况,数据流的组合关系能够可视化描述啊。

独立数据层的优势

回顾我们整个数据层方案,它的特点是很独立,从头到尾,做掉了很长的数据变更链路,也因此带来几个优势:

1. 视图的极度轻量化。

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

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

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

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

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

3. 跨端复用代码。

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

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

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

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

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

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

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

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

4. 可拆解的WebSocket补丁

这个标题需要结合上面那个图来理解。我们怎么理解WebSocket在整个方案中的意义呢?其实可以整体视为整个通用数据层的补丁包,因此,我们就可以用这个理念来实现它,把所有对WebSocket的处理部分,都独立出去,如果需要,就异步加载到主应用来,如果在某些场景下,想把这块拿掉,只需不引用它就行了,一行配置解决它的有无问题。

但是在具体实现的时候,需要注意:拆掉WebSocket之后的数据层,对应的缓存是不可信的,需要做相应考虑。

对技术选型的思考

到目前为止,各种视图方案是逐渐趋同的,它们最核心的两个能力都是:

  • 组件化
  • MDV(模型驱动视图)

缺少这两个特性的方案都很容易出局。

我们会看到,不管哪种方案,都出现了针对视图之外部分的一些补充,整体称为某种“全家桶”。

全家桶方案的出现是必然的,因为为了解决业务需要,必然会出现一些默认搭配,省去技术选型的烦恼。

但是我们必须认识到,各种全家桶方案都是面向通用问题的,它能解决的都是很常见的问题,如果你的业务场景很与众不同,还坚持用默认的全家桶,就比较危险了。

通常,这些全家桶方案的数据层部分都还比较薄弱,而有些特殊场景,其数据层复杂度远非这些方案所能解决,必须作一定程度的自主设计和修正,我工作十余年来,长期从事的都是复杂的toB场景,见过很多厚重的、集成度很高的产品,在这些产品中,前端数据和业务逻辑的占比较高,有的非常复杂,但视图部分也无非是组件化,一层套一层。

所以,真正会产生大的差异的地方,往往不是在视图层,而是在水的下面。

愿读者在处理这类复杂场景的时候,慎重考虑。有个简单的判断标准是:视图复用数据是否较多,整个产品是否很重视无刷新的交互体验。如果这两点都回答否,那放心用各种全家桶,基本不会有问题,否则就要三思了。

必须注意到,本文所提及的技术方案,是针对特定业务场景的,所以未必具有普适性。有时候,很多问题也可以通过产品角度的权衡去避免,不过本文主要探讨的还是技术问题,期望能够在产品需求不让步的情况下,也能找到比较优雅、和谐的解决方案,在业务场景面前能攻能守,不至于进退失据。

即使我们面对的业务场景没有这么复杂,使用类似RxJS的库,依照数据流的理念对业务模型做适度抽象,也是会有一些意义的,因为它可以用一条规则统一很多东西,比如同步和异步、过去和未来,并且提供了很多方便的时序操作。

后记

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

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

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

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

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

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

@JimLiu

This comment has been minimized.

JimLiu commented Jan 13, 2017

我最近也在思考类似复杂单页应用(论坛+聊天室)的架构和对数据层抽象的问题。

初步的设计如下,正在实施中:
对于Server端来说,用GraphQL提供数据结构,好处是客户端可以方便的自由组合按需请求数据,有兴趣可以看看GitHub GraphQL API,真的是超级强大和方便;缺点是服务端要做很多查询优化,以避免性能消耗。另外GraphQL也提供了基于WebSocket的订阅方案,参考:How We Built GraphQL Subscriptions with Apollo

对于Client来说,基于React+Redux的架构已经可以满足几乎所有需求,对我来说完全不需要再引入RxJS进一步去抽象。关键难点是对于states的拆分要科学合理,尽可能地遵循范式,避免嵌套数据结构。如果出现了嵌套的对象,那么尽量通过 ID 来引用。例如对于一个简单的论坛来说,基本的states数据有:

{
  topics: {}, // key是id,value是topic对象,topic会有authorId去关联user,postId去关联post
  posts: {},
  users: {},
}

对于复杂的数据关系,例如一个主题下面的帖子列表,如果回帖很多,只会显示一部分帖子,往上或往下滚动的时候去加载更多,参考这个显示效果:https://discuss.flarum.org/d/187-word-association-game/1245 。那么这个主题和下面帖子集合的对应关系,需要专门的集合去存储,参考数据结构如下:

{
  postStream: {
   [topicId]: {
      postIds: [], // 所有帖子id集合,按照回帖顺序排序好
      currentIndex, // 当前浏览的帖子在集合中的索引
      visibleStart, // 当前视图显示的帖子开始位置
      visibleEnd // 当前视图显示的结束位置
   }
 }
}

在上下滚动的时候,动态计算和更新visibleStart、visibleEnd,这样再动态加载和显示不同的帖子内容。如果有些帖子还没有加载,那么需要先本地显示一个loading状态的帖子,然后合并所有未请求的帖子id,去服务端请求所需要的posts和users,请求成功后再去更新上面的相关集合。

在Component层面,需要借助Selector去组合需要的数据,这个过程有点类似于Server端去各个数据库select和join数据。
举例来说,看一个主题的时候,根据当前URL里面的topicId参数,要去topics里面去获取topic信息,去postStream里面找出来当前应该显示的帖子集合,对于每个帖子,要去合并作者信息。这个Selector出来的结果,如果数据没有更新,会缓存,如果这时候某个用户换头像了,那么这个selector会重新运算,那么所有该用户的帖子下面的头像都会改变。

@HaveF

This comment has been minimized.

HaveF commented Jan 13, 2017

@JimLiu 麻烦问一下,apollo的WebSocket你用起来了吗?似乎不是很好用啊

@JimLiu

This comment has been minimized.

JimLiu commented Jan 13, 2017

@HaveF 我用的是SocketCluster
这有个很好的基于SocketCluster和GraphQL实现查询订阅的例子Meatier

@yozman

This comment has been minimized.

yozman commented May 6, 2017

啃了三遍,
今天终于完全读懂了
@xufei 谢谢分享,
希望有时间的时候能把 实体的关系定义 也科普下 😄

@chinafootballyu

This comment has been minimized.

chinafootballyu commented Jun 22, 2017

前端数据层的设计参考mongodb,提供相似的api。这样可行不,会不会搞复杂了!

@michael-lu-cn

This comment has been minimized.

michael-lu-cn commented Jun 27, 2017

粗读一下,一点见解:
像流程中修改配置的例子,本来就是极端情况,修改配置本来就是很重的操作。 这个时候去刷新一下,我觉着一般人不会觉着突兀,而因此去设计一套复杂的机制去兼容这种状况,迫使其他轻量的操作也必须应用这种重模式,会不会不够经济?

@silentzone

This comment has been minimized.

silentzone commented Sep 10, 2017

想知道没变化的前端数据是缓存在浏览器内存还是缓存在 localstroage里面

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment