数据的关联计算

@xufei 2016-09-10 06:32:55发表于 xufei/blog

数据的关联计算

在复杂的单页应用中,可能存在大量的关联计算。

什么是关联计算呢?比如说:

  • 定义变量a
  • 定义变量b,b始终等于a+1

这样,变量b就带来了一个需要重复的计算,我们需要借助不同的机制,当a变化的时候,去重新计算b的值。

对于这类东西,通常两种途径:

  • 在设置a的时候,通过一些机制去更新b
  • 在获取b的时候,重新根据a的值计算

通常,第一种方式会比较普遍使用。

在很多可编译到JS的语言中,都有setter和getter机制,ES的新版本也是有的,所以我们可以把这两种途径分别使用setter和getter去实现。

class A {
  private _a: number
  set a(val) {
    this._a = val
    this.b = val + 1
  }
}
class A {
  a: number
  get b() {
    return this.a + 1
  }
}

在一些视图层框架中,存在computed property的概念,本质上就是这么一个类似getter的定义,但是实现上可能会是用的getter,也可能会在内部被转换成setter来处理了。

因为如果你直接用setter,仍然存在一个问题:什么时候触发取值。我在一个click操作里,把a的值加一了,界面上的b怎么知道就要重算呢?

在一些基于脏检查的框架里,这个事情会自动去做,因为它是在任意可导致数据变化的事件之后,获取当前数据,跟历史数据来做个对比,这时候他就会调用到b的取值,所以getter是生效的。

我们必须认识到,如果不能精确追踪到依赖关系,getter就是低效的。比如说,如果你不知道当a变了之后,才需要更新b,就可能要频繁地去看b的当前值,其中绝大部分时候都是不需要重新算的。但另外一方面,如果你已经知道了只有当a变更的时候,才需要更新b,倒不如在a的setter里做这个事了。

手动在setter中更新关联数据,效率是可以保证的,但麻烦就在于写的时候麻烦,我给自己赋个值而已,还得去管你们后续要干什么,这个代码很不可读,也难维护。所以,有些框架允许你用getter的形式定义数据依赖,自动分析出变量依赖关系之后,再在内部转换成setter,这样就比较好了。

我们上面举的例子比较简单,单级的一对一依赖,如果复杂一些,可能会有几个方向:

  • 一对多依赖
  • 多级依赖
  • 异步依赖

什么是一对多依赖呢?

get a() {
  return this.b + this.c
}

这里面,a依赖于多个值。

什么是多级依赖呢?

get a() {
  return this.b + 1
}

get b() {
  return this.c + 1
}

这里,a的变化要一直追踪到c,如果控制得不好,还能写出依赖闭环,造成死循环。多级依赖的编写虽然不难,但比较罗嗦。

什么是异步依赖呢?

get a() {
  return this.b + 1
}

如果这里b的来源是异步的,就比较尴尬了,这样写肯定是不对的,所以,难道我们要写成这样吗?

set b(val) {
  this.a = val + 1
}

foo() {
  changeB().then(b => this.b = b)
}

这样本质上还是利用setter。

从刚才的描述中,我们得出的认识大致是这样:

  • setter比较高效,但是编写的时候比较麻烦
  • getter写起来很直观,但是执行效率可能不高,因为不容易做到精确调用,容易有无效执行
  • 异步依赖导致我们可能没法写getter

所以,在异步的情况下,我们真的就要承受setter的痛苦吗?

当然不,我们要找一种写起来类似getter,不会有无效执行,还能简洁处理异步依赖和多级依赖的情况的办法,问题是,这种办法真的存在吗?

我们考虑这么一个场景:

  • 用户a, b, c, d都是远程的,他们处于同一个聊天窗口中
  • d需要负责把a和b发过来的数字相加,然后把结果与c发来的数字相乘之后,发到聊天窗口中

问:站在d的角度,这代码怎么写?

实际的业务逻辑其实很简单,就这么一句:

d = (a + b) * c

最大的麻烦是,这些异步过程把业务逻辑打散了,导致这个代码特别难写,也不清晰。

如果使用RxJS,我们可以把每个数据的变更定义成流,然后定义出这些流的组合关系:

最终代码如下:

http://codepen.io/xufei/pen/PGPYLK

const A = new Rx.Subject()
const B = new Rx.Subject()
const C = new Rx.Subject()

const D = Rx.Observable
  .combineLatest(A, B, C)
  .map(data => {
    let [a, b, c] = data
    return (a + b) * c
  })

D.subscribe(result => console.log(result))

setTimeout(() => A.next(2), 3000)
setTimeout(() => B.next(3), 5000)
setTimeout(() => C.next(5), 2000)

setTimeout(() => C.next(11), 10000)

为了简单,我们用定时器来模拟异步消息。实际业务中,对每个Subject的赋值是可以跟AJAX或者WebSocket结合起来,而且对D的那段实现毫无影响。

我们可以看到,在整个这个过程中,最大的便利性在于,一旦定义完整个规则,变动整个表达式树上任意一个点,整个过程都会重跑一遍,以确保最终得到正确结果。无论中间环节上哪个东西变了,它只要更新自己就可以了,别人怎么用它的,不必关心。

而且,我们从D的角度看,他只关心自己的数据来源是如何组织的,这些来源最终形成了一棵树,从各叶子汇聚到树根,也就是我们的订阅者这里,树上每个节点变更,都会自动触发从它往下到树根的所有数据变动,这个过程是最精确的,不会触发无效的数据更新。

所以,借助RxJS,我们实现了:

  • 定义的时候,像getter一样清晰
  • 执行的时候,像setter一样高效
  • 每个环节是同步还是异步,都不影响代码的编写