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

数据的关联计算 #36

Open
xufei opened this Issue Sep 10, 2016 · 25 comments

Comments

Projects
None yet
9 participants
@xufei
Owner

xufei commented Sep 10, 2016

数据的关联计算

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

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

  • 定义变量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一样高效
  • 每个环节是同步还是异步,都不影响代码的编写
@xufei

This comment has been minimized.

Owner

xufei commented Sep 10, 2016

答复一下梨叔的这条微博:http://weibo.com/1655747731/E7CIj7HwS?ref=home&rid=2_0_1_2598662076944433695&type=comment

我们本质上不是说要用Observer这种方式去对付依赖态和执行序,而是由于这些东西已经被大量的异步化过程搞散了,没法清晰地整合起来。如果把每个过程都包装一下,然后这些过程之间可以进行一些组合,那就可以让整个程序的思路更清晰。

@Galen-Yip

This comment has been minimized.

Galen-Yip commented Sep 10, 2016

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

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

异步依赖是决定于a的获取时机 不是a的赋值时机呀 a的获取需要在b赋值之后
感觉叔叔这里说的没到点上

@xufei

This comment has been minimized.

Owner

xufei commented Sep 10, 2016

@Galen-Yip 不是啊,是因为我这里已经跳过了一步:

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

如果这里b是异步的,a的getter是无法确保取到正确结果的,所以你看到的那段,是我已经把getter转换为setter了,需要在b更新的时候手动去更新a,所以变成了b的setter

@Galen-Yip

This comment has been minimized.

Galen-Yip commented Sep 10, 2016

a对b的依赖,即使写了

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

获取a的时候 始终是要写在this.b = b赋值之后的,跟set b里面是否去写a的赋值没有关系的呀
异步的重点还是 a是什么时候去获取的。

@xufei

This comment has been minimized.

Owner

xufei commented Sep 10, 2016

@Galen-Yip 还是误解了,我意思是,如果写了b的setter,就不写a的getter了

@Galen-Yip

This comment has been minimized.

Galen-Yip commented Sep 10, 2016

一对一的异步依赖getter换setter还可以好好玩。

但到了一对多的情况,就没法好好玩耍了。
比如:

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

b和c都是异步过程

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

如果把get a写成b的setter和c的setter里面,这就没法玩了。

所以,我是觉得get a的写法其实没有问题的,b和c的异步问题是在什么时候去获取a

因此,像下面这样才是体现异步的过程,a、b、c都是不关心数据变动的,需要的话,就像这样写规则。

$.when(foo,bar)
  .then((b,c) => {
    console.log(a)
  })

其实也跟叔叔下面RxJS的实现是同一个道理的,都是规则的组合

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)
@jiyinyiyong

This comment has been minimized.

jiyinyiyong commented Sep 10, 2016

React 通过 Immutable Data �引入的方案解决的是同样的问题, 只是手段差别挺大的. 我觉得的 Rx 用来解决 Reactive Programming 当中单纯的数据展开和同步的问题大材小用了. 这样的数据同步应该可以找到更简单的抽象方式.

@xufei

This comment has been minimized.

Owner

xufei commented Sep 11, 2016

@jiyinyiyong 是的,我的目的是为了引出后续的一堆东西。用Rx来解决文中这个场景有些重了,但如果放在tb这么一个有规模有场景的实时方案中,还算适合

@Jinjiang

This comment has been minimized.

Jinjiang commented Sep 12, 2016

看到 Rx 我的第一反应是 KnockoutJS + lodash 😀

@leeluolee

This comment has been minimized.

leeluolee commented Sep 12, 2016

我们这边图表框架团队是基于RxJS开发的, 而且他们好像差不多13年就开始在用了,是不是有点超前...就是感觉他们后来的codebase有点大啊。

@xufei

This comment has been minimized.

Owner

xufei commented Sep 12, 2016

@leeluolee 图表框架是为什么用rx啊,我没想出场景,求分享

@Brooooooklyn

This comment has been minimized.

Brooooooklyn commented Sep 12, 2016

@jiyinyiyong 其实这个 Rx 的例子,是叔叔抄 ben lesh 的 slide 里面的(ε=ε=ε=┏(゜ロ゜;)┛
我大量在业务里面用 Rx 组合计算数据,通过 ng 2 的 async pipe / vue 里面自己 shim 的高科技直接把流绑定到视图上,然后再用 Observable.scan 模仿 redux 开发组件,非常舒服。
用了 Rx 之后再也不用纠结流程控制这种事情了。。。所有的东西都变成了 stream,用操作符连起来 map reduce 一下就好了。

@jiyinyiyong

This comment has been minimized.

jiyinyiyong commented Sep 12, 2016

@Brooooooklyn 我用 React 也没觉得控制数据流很难啊. 不明白你说的那么好的效果, 有详细一点的代码吗?

@Brooooooklyn

This comment has been minimized.

Brooooooklyn commented Sep 12, 2016

用 vue 演示一下。。

<div>
  <icon :symbol="task.isDone ? 'checkbox-checked' : 'checkbox'" :class="{'not-allowed': !permission$.canEdit}"></icon>
</div>
<div>
{{content$}}
</div>

<div :class="dueDateColor$">
{{dueDate$}}
</div>

<div>
{{task$.note}}
</div>

<div>
{{involveMembers$}}
</div>
class TaskDetailData {
    task$ = this.service._TaskApi
        .get(this.taskId)
        .publishReplay(1)
        .refCount()

    project$ = this.service._ProjectApi
         .get(this.projectId)
         .publishReplay(1)
         .refCount()

    content$ = this.task$
        .map(r => r.content.trim())

    permission$ = this.task$
        .distinctUntilKeyChange('_executorId')
        .switchMap(t => {
           return this.project$
               .distinctUntilKeyChange('_defaultRoleId')
               .map(p => {
                    return {
                        canEdit: Utils.permission.canEdit(t, p)
                    }
                })
        })

    involveMembers$ = this.task$
        .switchMap(t => {
            return this.members$
                .takeLast()
                .flatMap(members => members)
                .filter(r => t.involveMembers.indexOf(r._id) !== -1)
                .map(r => r.name)
                .toArray()
                .map(r => r.join(', '))
        })

    dueDate$ = this.task$
        .map(r => Utils.dueDateColor(r.dueDate))

    constructor(
      private taskId: string,
      private projectId: string
    )
}

最后的效果是把

{
  _id: string,
  involveMembers: [ '_id' ],
  content: string,
  dueDate: ISOString
}

这样的数据显示到界面上,
最重要的是界面上的数据永远就是最新的,数据 update 的时候不需要做任何额外处理。

这个只是演示 RxJS 做数据处理的场景,用 scan 做类 redux 组件的场景有时间再写一下。

@jiyinyiyong

@jiyinyiyong

This comment has been minimized.

jiyinyiyong commented Sep 13, 2016

看上去是介绍了 Rx 强大的语法糖对数据进行拆拆装, 异步相关只有开头的两个 API 的用法的. 这里的代码是有处理了服务端推送吗? 自动更新看上去挺牛的.

那比如 c 依赖, ab, 只有 b 被更新, 这种场景下怎么处理?

@lyyourc

This comment has been minimized.

lyyourc commented Sep 13, 2016

Rx 在实际生产中适用的业务场景有哪些,可以举3个 具体的例子🙃
@xufei

@Brooooooklyn

This comment has been minimized.

Brooooooklyn commented Sep 13, 2016

@jiyinyiyong 叔叔举的例子里面就有你说的这种 c 依赖 a 和 b ,其中只有一个更新的情况啊

c = Observable.combineLatest(a, b)
  .map(([avalue, bvalue]) => {
    return avalue * bvalue
  })

这样写的话不管 a,b 哪个更新都会导致 c 的值变更。
如果需要 a b 都变更的时候 c 才变更可以写成

c = Observable.combineLatest(a, b)
  .distinctUntilChange((oldAandB, newAandB) => {
     return oldAandB[0] === newAandB[0] || oldAandB[1] === newAandB[1]
  })
  .map(([avalue, bvalue]) => {
    return avalue * b value
  })

其实我觉得最厉害的是由于 observable 是 lazy 的,所以在一个流上用着么多操作符,都只会在一个 iterator 里面完成这些操作,不会像数组的操作那样产生中间的对象和状态。

@Brooooooklyn

This comment has been minimized.

Brooooooklyn commented Sep 13, 2016

@jiyinyiyong 我们现在的思路却是是把异步的数据操作和异步的用户行为分离开来了。数据单独用 Rx 封装了一层,保证所有从这一层取到的数据都是自动更新的,更新的来源是网络请求和 socket。
用户行为这一层用 Observable.scan 做类似 redux 做的事情,用户的行为会导致 view 往流里面推一个 action,然后 reducer 计算出新的状态,再做一下 sideEffect (请求数据之类), 最后推回 view 层。

class Service {
  Action$ = new Subject<Action>()

  Store$: Observable<Action> = this.Action$
    .startWith({
      type: Actions.Reset,
      payload: null
    })
    .scan((current, action) => this.reducer(current, action))
    .switchMap(action => this.sideEffect(action))
}
// 这里直接绑定 vue 的模版
class View {
  created() {
    this._service = new TaskDetailService()

    this._service.Store$
      .do(action => this._actionHandler(action))
      .subscribe()

    this.$data = new TaskDetailData(...)
    ....
  }

  beforeDestroy() {
    this._service.Action$.unsubscribe()
  }

  updateExecutor() {
    this._service.Action$
      .next({
        type: Actions.OpenExecutorChooser,
        payload: {
          view: this,
          title: '任务执行者',
          members: this.members$,
          executor: this.executor$,
          taskId: this.task$._id
        }
      })
  }
}

结构差不多就这个样子,再结合之前写的那个 TaskDetailData, 处理用户行为只需要往 Action$ 里面推一个 Action 就好了。

@jiyinyiyong

This comment has been minimized.

jiyinyiyong commented Sep 13, 2016

看上去 Rx 对于业务需求的覆盖已经是相当好了. 虽然我还是不习惯它这么多操作符.

@xufei

This comment has been minimized.

Owner

xufei commented Sep 13, 2016

Rx本质上只是观察者的变种,编写方面方便很多,而传统的观察者,只要你不怕写得麻烦,本来也能够解决所有问题……

发自我的 iPhone

在 2016年9月13日,下午9:02,题叶 <notifications@github.commailto:notifications@github.com> 写道:

看上去 Rx 对于业务需求的覆盖已经是相当好了. 虽然我还是不习惯它这么多操作符.


You are receiving this because you were mentioned.
Reply to this email directly, view it on GitHubhttps://github.com//issues/36#issuecomment-246673612, or mute the threadhttps://github.com/notifications/unsubscribe-auth/ACmVJ5-V8N1AdVpvfWR3T8ELOA9yEKVTks5qpp7fgaJpZM4J5pqX.

@Galen-Yip

This comment has been minimized.

Galen-Yip commented Sep 13, 2016

Rx本质上只是观察者的变种,编写方面方便很多,而传统的观察者,只要你不怕写得麻烦,本来也能够解决所有问题

真理了

@xufei

This comment has been minimized.

Owner

xufei commented Sep 13, 2016

@DrakeLeung 一个月内给你举个比较完整的例子,能说明思路的

@ibufu

This comment has been minimized.

ibufu commented Sep 14, 2016

@DrakeLeung 比如说微博个人页,它展示你的所有微博,每条微博都有用户的头像,假设现在在这个页面里用户可以修改自己的头像。按传统的做法是修改完头像后,刷新整个页面。或者是手动替换每条微博的头像,这个不难。但是假如说这个页面还有私信组件,里面也有用户头像,或者还有其他很多组件里都包含用户头像。
这个时候就应该使用观察者模式了。所有需要用到头像的组件作为观察者,订阅一个被观察者。

@jiyinyiyong

This comment has been minimized.

jiyinyiyong commented Sep 14, 2016

想到个事情, 用 Stream 之后, HTML 和工具函数, 甚至数据流本身的代码热替换怎么实现?

@betarabbit

This comment has been minimized.

betarabbit commented Oct 9, 2016

Mobx看起来有点Rx的影子,民工叔能给解释解释Mobx和Rx的区别吗?

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