如何看待 snabbdom 的作者开发的前端框架 Turbine 抛弃了虚拟 DOM?

snabbdom(Vue.js、Cycle.js 的 Virtual DOM 都用了 snabbdom)的作者自己写了个 FRP 的前端框架,该框架不…
关注者
1,404
被浏览
167,597

14 个回答

在一个月之前的北京QCon,我的演讲主题《单页应用的数据流方案探索》(zhuanlan.zhihu.com/p/26)中,有这么一段:

所以,我们发现如下事实:


  • 在触发reducer的时候,我们是精确知道要修改state的什么位置的
  • 合并完reducer之后,输出结果是个完整state对象,已经不知道state的什么位置被修改过了
  • 视图组件必须精确地拿到变更的部分,才能排除无效的渲染

整个过程,是经历了变更信息的拥有——丢失——重新拥有过程的。如果我们的数据流是按照业务模型去分别建立的,我们可以不需要去做这个全合并的操作,而是根据需要,选择合并其中一部分去进行运算。

这样的话,整个变更过程都是精确的,减少了不必要的diff和缓存。

作者的这篇文章说的就是这个问题,很高兴能碰到也这么想的人。

在React + Redux体系中,数据变更与视图变更之间的过程,就是经过了“精确——不精确——精确”这样的步骤。前一步是简单合并,而且是要改变数据引用的合并,后一步是diff。

问题的关键在于:为什么我已经很精确知道了某个数据产生了变更,还必须先假装不知道,整体提交上去,然后由另外一个环节再次通过“大家来找茬”,发现刚才变化的部分呢?

究其原因如下:


  • 因为Redux是只有一个全局状态的,所以,能描述整体V=f(M)的东西只有这个全局状态
  • 各视图组件需要分别setState,这些state是全局状态的部分,问题是,它们之间是有重叠的,很多时候还是有变换的,全局状态并不是简单等于这堆子状态之和,重点是这里。

所以我们就发现,因为它描述不出全局状态与子状态之间的关系,所以不得不用这种办法实现。那这种对于全局状态的diff算不算脏检查?而且,还必须强行修改引用,造成在使用Immutable数据的假象,话说,如果你愿意在Angular体系中每次操作都这样,生成新的引用,它脏检查的效率也会大大提高的呢。说好的鄙视Angular体系的脏检查的呢,AngularJS和Angular可都是沿用的脏检查策略。

所以,为什么React-Redux体系希望你每次修改了数据之后,把引用也改掉?为什么期望你尽量把全局状态的结构扁平化?因为这能提高脏检查效率啊。考虑一下,如果真的使用那种展示组件纯化的方案,必然就会导致全局状态对象极度膨胀,这个时候,是否需要担心这个diff的性能问题呢?

在AngularJS中,除了scope继承这个地方之外,其实可以采用跟React-Redux相同的开发规范。多简单啊,把所有数据全挂在$rootScope上,让它全局化,唯一化,然后其他的各类controller,只充当action dispatcher和reducer的职责,不就完事了吗,大家都一起快乐地脏检查……

那么,这个问题有没有解呢?我们还是回头看一看那个最关键的问题:全局状态跟组件状态之间的关系,到底有没有办法描述出来。

目前,各类主流前端框架的本质差异就是:做不做精确更新。

我们先考虑一下数据的变化过程,以这样一个东西为例:

某个表单对象myForm上,一个字段的值foo更新了。

我们应该抽象出怎样的信息来?

大粒度的更新策略就是:我告诉你,myForm变了,至于变了哪里,你自己看着办。

小粒度的更新策略就是:我告诉你,myForm下面的foo变了。

采用哪种策略,有几个方面的权衡。


  1. 变更批量更新的策略。 这什么意思呢?如果我点一个按钮,不只是改了myForm的foo属性,而是改了好几个,你让视图更新几次?如果是采用大粒度更新的框架,这里可以整体提交,比如说,你把一次外部交互所产生的变更一起提交,然后diff之后一次更新即可。但是在小粒度更新的框架里,内部一定会把这些拆解到一个队列中,在一次微任务中一起做掉,不然就会导致视图多次刷新,产生浪费。
  2. 数据的变更策略 采用哪种机制去更新视图,直接决定了业务代码应该怎么操作数据。 大粒度更新机制决定了:一切变更都应该尽可能地发起更大粒度的变更,直到全局,所以最后大家确实都直接在修改全局状态对象了。 小粒度更新机制决定了:一切变更都应该尽可能最小,直到原子化。 举例来说,如果你要修改a.b.c这个表达式里面c的值: 大粒度模式下,直接改c对你并没有什么好处,你会倾向于改b,甚至连a都改了,这样说不定还快些。 小粒度模式下,直接改c比改a或者b都要划算,因为你改了上层的东西,就得自动重建下级的这些索引关系,哪个数据的变更被另外哪些东西订阅了,都得补起来。
  3. 拿什么才能组合出全局状态来。 在大粒度更新策略的框架中,它们根本就不会去尝试做这件事,因为做不到。所以,大家都不约而同地偷懒,先合并出一个整体的,然后,上vdom这类强行diff的东西。本质上讲,不管是AngularJS,React,Cycle,都是一个思路。 这个时候再来看那些小粒度更新策略的框架,我们会发现一个问题,他们好像很大程度上回避了“描述全局状态”这件事。为什么呢,因为每个状态对象的每个属性,到底变化的时候影响到视图的什么地方,他们全部都知道。每个变动都生成了一个特定的函数去做视图变更的事情,甚至是做其他一些数据的变更,从而间接变更视图。所以,虽然他们是有机会描述全局状态的,但很大程度上并不需要做,通过精心设计的computed property,或者getter,可以把组件所需的数据来源定义得很清晰。

那么,“描述全局状态”这件事,到底有没有什么好处呢?

需要说明一下,这件事情是有好处的,尤其是调试的时候。设想有若干组件共享了某一个数据的不同形态,当这个数据产生了改变的时候,如果你只有组件调试器,很难对整个应用有一个全局视野。

但是,即使这些采用小粒度更新策略的框架们,他们在已有精确的依赖关系的情况下,想要完整表达出全局状态来,也不是一件容易的事。原因是:

  • 框架不知道每个细粒度数据的真实含义,数据类型过于简单,业务无法加入补充的描述信息
  • 框架虽然知道每个细粒度数据的完整变化过程,但很难描述时序这个事情

解决这两个问题的最终方向,还是要采用一种具有更强描述性的通用数据结构,那就是一种对普通数据类型的包装,一种Monad。

现在我们回头来看文章中提到的观点,它其实是要跟Cycle作对比的。同样做F & RP,两者的差异就在我刚才说的地方:要做大粒度更新策略,还是小粒度?要不要把所有细节变更汇总?

基于Observable/Stream这样的机制,在这类事情上其实有非常大的优势,究其本质:

  1. 一个Observable实际上就足以描述一个数据的完整变化过程。那么,如果我们把一个应用的整体状态视为一个大的波形图,它实际上就是可以用类似傅立叶变换这种视角去看待,被视为若干个小波形的叠加,而这些小波形是什么?就是描述整个应用全状态数据结构中,每一个小块的变化情况。最令我们心动的是:这个叠加关系,就是一个简单加法,数据之间互相不重叠。
  2. 一个Observable足以描述一个特定数据的完整生命周期。你拿到这些Observable之后,任意一个都能展开成刚才提到的一个小波形。
  3. Observable自身是可以描述时间的,时序关系理得出来。
  4. Observable的合并顺序是可交换,可组合的,意味着你可以从组成整个应用状态的Observable们里面,选择一些先合并起来,然后拿结果再跟其他的合并,最终结果不变。这让我们能够有机会应对不同视图对相同数据产生不同使用形态这件事,只要源头的一些东西足够原子化,这些都好办。

所以,这篇文章对Cycle产生的质疑是可以理解的:强迫合并成最大粒度更新这件事,别人那么做是因为他们做不到更好的,你是F & RP,明明能做到更好,为什么你还要合并?

虽然理论上是可以做,但实际开发的时候,应该还是有不少地方值得深思的,比如:


  • 粒度的控制。把每个最小粒度的更新都包装成Observable,是否划算,如果不划算,这个粒度如何把控?
  • 框架应当如何替业务开发人员把一些事情做起来,做哪些事情?
  • 如何处理“组件”这个概念?
  • 可视化调试工具要做成什么样子?有几个维度?

带着好奇心,期待它的发展。

======= 以下为5.20补充 ========

在相关的一些回复中,仍然看到一些同学没有理解合并和diff这两个东西所表达的意思,写了一段简单代码来说明:

OmabOO

上面这个链接是执行结果,可以看看我注释掉的那两段代码,它们究竟在干什么。

当然,这些框架并不是这么实现的,我只是用来打个比方。

我们可以注意到,如果你的框架是基于小粒度更新的理念构建的,那么,完全可以自动得到最初的那几个change表达式,不但如此,你还知道最后与之相关的那句订阅表达式:

const changeTitle$ = Rx.Observable
.fromEvent(document.getElementById('changeTitle'), 'click')
.map(e => `title${Math.random()}`)
changeTitle$.startWith(initTodo.title)
.subscribe(title => todoDiv.querySelector('.title').innerHTML = title)

所以,每一个变动事件到底影响了谁,是很精确知道的,我用Rx写的demo,像Vue内部不是这么做的,但它原理是一样的,它完全就知道什么变更会导致什么界面的更新,根本不需要virtual dom,就能够做到精确更新。之所以它还是引入了vd,是因为它要考虑服务端渲染,而且还适当放弃了一些精确性,根据 @尤雨溪 的描述,应该是推荐做组件粒度的整体数据设置,这个我认为是比较合理的取舍。

我注释掉的那段代码:

// 为什么不用这段,是因为这段会有不必要更新,比如说,content没变,但是title变了,它也得跟着更新视图
// todo$.subscribe(todo => {
//  todoDiv.querySelector('.title').innerHTML = todo.title
//  todoDiv.querySelector('.content').innerHTML = todo.content
//  todoDiv.querySelector('.completed').innerHTML = todo.completed
// })

理由在注释里说了,如果不做diff,每次就是全量更新,因为你不知道到底谁变了,谁没变。

但是下面这段:

const changeTitle1$ = todo$.pluck('title').distinct()
const changeContent$ = todo$.pluck('content').distinct()
const changeChange$ = todo$.pluck('change').distinct()

这个就相当于把变动的部分diff出来了,不变的就不会改变对应的视图。这么两段就分别约等于Redux和React-Redux所做的事情。

然而,这么一大片东西,实际上最后注释掉的那几句就能代替了,因为最初的精确变更的部分一直没有丢掉,所以是可以利用的。在复杂一些的场景下,那个地方的代码会复杂一些,但仍然会是精确高效的,这个不会变。

我从去年到现在,写了好几篇东西,但有些人就是不看,我也很为难啊,我反对什么,是会写出理由的,并不是什么信仰啊之类的,为什么我一直这么不认同Redux,繁琐的那部分我先不说了,引入这么繁琐的机制,抽象程度也较高,却还遗留着重大问题,仿elm形实兼失,很难让人信服。在底层一直保持着精确变更的数据管道的时候,非要我先合并到一个无类型的巨大对象中,再diff出来,很令人质疑啊。

大致的理解:在整体使用 FRP 的前提下,Cycle.js 是这样的:

Multiple state streams => merged into single state stream => vdom stream => diff

经过了一个从分散的状态流,合并成一个状态流,映射到 vdom 流,最后 diff 的过程。

这里的问题是在状态流合并的过程中,丢弃了各个状态流和 DOM 元素之间的关系。这个关系在 diff 的时候又被重新暴力计算了一遍,所以有浪费。Turbine 的策略是不合并状态流,直接映射到 DOM 副作用。

这和 Vue 1 的针对每个绑定单独观察的策略其实有些类似。需要顾虑的是在可能变动的 DOM 节点比较多的时候,大量对应的 watcher / observer 的常驻内存占用。另外这样的策略必须是基于 fine-grained + push-based 的响应机制,框架不是基于这样的前提去设计的话,还是得用 vdom,除非像 svelte 那样直接把副作用在编译的时候就编译好。

作者的那段话并不是在说『vdom 是错误的方向』,而是说『在 FRP 的前提下,vdom 不是性能最优的选择』。

我对这个策略的顾虑:

- 抛弃状态流的合并这一步,会不会导致 debug 方面的难度,因为这样就没有一个统一的入口去获得某一时间点的状态快照。

- vdom 的一个好处就是它把最终的副作用和应用的『视图状态』隔离了。这个策略等于从『数据状态』直接映射到『副作用』,完全丢掉了『视图状态』这个中间步骤。这样的话对于做非 DOM 的渲染可能会增加难度,但是如果把副作用层设计成抽象的接口,应该也可以做。

- 开发体验上,vdom 的渲染函数是一个同步的纯函数,Turbine 的 view 函数貌似还带各种 yield,感觉这个一般开发者还是挺难适应的...

总的来说这还是一个比较底层的实现/性能细节。与其关注 vdom,还不如关注 Turbine 和 Cycle 之间的异同。这个问题其实应该找 Andre Staltz 来答...