前端数据管理:响应式(Reactive)数据间同步

响应式数据间同步的场景

技术总是由业务需求驱动的。酷家乐的核心业务之一是提供户型的 3D 渲染功能,并为此实现了一整套在线设计工具供用户编辑户型、建模、装修设计等。这一系列工具将各种户型、家具等 3D 模型渲染出来,并且在此之前都是由 Flash 技术实现。而随着浏览器技术的发展,Flash 工具已经接近生命周期的结束,于是一套新的基于 HTML5 实现的工具开始被提上日程,最终经过技术调研确立以类 React 方法的 Virtual DOM 技术驱动 canvas 中的 3D 渲染。于是有了如下的业务场景:

  • 3D 模型渲染为主业务
  • 以 React 的 Virtual DOM 为主视图框架驱动 3D 渲染

原始数据与视图框架之间的沟壑:中间数据结构的必要性

随着前端工程复杂度越来越高,数据管理也越来越容易变得混乱,框架应运而生,但不同框架对数据格式或多或少都有限制。业务特性对数据格式有要求,视图框架对数据格式也有要求,当两个要求冲突时,问题就来了。

比如 React 视图框架中,为了达到最佳渲染效率,需要数据是 immutable 形式;对应的 Redux 数据管理框架,需要数据是树状结构、可 JSON 化。但在 3D 渲染的业务场景中,为了便于计算,数据存储为互相引用的图状节点形式,不可 JSON 化,不可 immutable 化。格式冲突使得原始数据无法直接应用到视图框架中。

为此,需要在原始数据格式和选定的视图框架之间创建中间数据结构作为连接,使其适应视图框架对于数据格式的要求。

这个等同于视图数据的中间数据结构使得:

  • 格式不受限:原始数据格式完全不需要考虑框架限制,只考虑业务特性
  • 职责清晰:中间数据可存储视图交互数据,从而让原始业务数据和交互数据分离

新的问题:中间数据和原始数据间的同步 —— 响应式解决方案

引入中间数据解决了关键性问题,但是如何保持原始数据到中间数据的同步变成了另一个问题。

这个数据间同步的问题其实类似于数据-视图的同步问题,从一种结构持续同步到另一种结构。

简单的 MVC 做法(Backbone,Dojo,Ember 等):

  • 初始化:定义转换函数根据数据初始化视图
  • 更新:根据数据改动,人工更新视图

其中的问题在于更新流程完全人工维护,并且需要人工保证更新产出结果与初始化产出结果一致,容易出错。于是有了现在的响应式的视图概念,比如 React :

  • 定义数据-视图转换函数
  • 初始化:完整调用转换函数生成初始化视图
  • 更新:根据同一份转换函数的映射关系,自动触发局部视图更新

数据到视图的转换(同步)函数仅指定一次,初始化和更新都交由框架自行解决。在此基础上,数据和视图在任意时刻都是同步的,数据变更,则视图同步变更。开发者仅关系数据源,不再关系视图如何更新。

同样的,回到中间数据和原始数据的同步上,也应当是响应式,使得引入中间数据结构的同时,不会引入同步的维护成本。

如何实现响应式数据同步?

响应式数据同步的目标应当有:

  • 响应式:同一份转换函数,自动应用初始化和更新流程;保证任意时刻同样的原始数据,同步函数得出同样的结果数据
  • 最小局部更新:每次触发更新同步时,仅同步已更改的数据,不修改未更改的数据(以支持 React PureRender 特性)
  • 支持额外无关数据的混入:允许在转换函数中指定其他任意数据,并在同步过程中保留这些数据(以承载交互数据)
  • 弱化新概念的引入

实现可拆分为如下几个部分:

  • 依赖分析以及数据更新监听:用于收集结果数据所依赖的原始数据,以及监听原始数据改动以触发结果数据的更新
  • 转换函数:用于在初始化以及更新触发时根据原始数据生成新的结果数据
  • 语法定义

依赖分析以及数据更新监听

依赖分析和监听的关键在于监管数据的获取和修改,ES5 提供了 getter/setter 来隐式的监管属性的读写操作(弊端是定义 getter/setter 必须提前知晓属性列表,无法监管动态新增的属性,除非显式约束属性的读写方式)。ES2017 的 Proxy 方式走得更远(有兼容性问题),可以监管任意的已知或未知属性的读写操作(乃至方法调用等诸多行为,但这里我们只关心数据的读写)。

利用这些特性监管属性读写后,就可以进一步作依赖分析和更新通知。

  • 对于任意待执行的代码片段,在执行过程中所有被读取数据的对象,则作为该代码片段的数据依赖源
  • 更新通知则更简单,任意时刻调用了数据写入的对象,则触发该对象的更新

getter/setter 或 Proxy 可以提供细粒度的数据读写监管,但通常业务并不需要过细的读写操作粒度,可以根据原始数据的特征做优化调整,比如仅定义某一级的数据作为依赖源,仅监管该级数据读写;人为修饰数据获取和数据更新函数,人工指定依赖和更新源。借此缩小监听范围,提高效率。

数据转换函数

转换函数定义了原始数据到结果数据的绑定关系。

与 React 之类的 数据-视图 绑定一样,转换关系定义之后,实际执行分为相对简单的初始化过程以及较为复杂的局部更新过程。

  • 初始化过程除了完整了执行了整个转换函数,并记录下每个数据绑定节点对应的依赖源以及对应的局部转换函数
  • 当数据更新触发时,会遍历各个数据绑定节点,找到依赖于此次被更新的数据源,触发局部更新流程,更新该节点内的数据,并收集新的依赖源

局部更新过程的要求:

  • 最小数据集更新:最小数据更新不仅仅只是减少数据同步过程的消耗,更是在 React 的 immutable & PureRender 中发挥作用,避免不必要的视图更新。这是通过单独指定每个数据绑定节点,达到足够的更新粒度(即便父子节点也是独立更新,父节点更新不一定会影响子节点)
  • 避免无用和重复更新

以下方法都是为了优化局部更新,提升最终效率。

局部更新调优:父子节点独立更新

当原始数据[户型][家具]更新时,最终仅单独触发结果数据中的[户型][家具]节点自身数据的同步。[房间][墙面]的转换函数不会被调用。

这与 React 的 数据-视图 的同步过程有很大区别。React 采用的策略是从当前节点往下整颗树都会重新调用转换函数。

局部更新调优:更新排序

在同步过程中,父节点的同步结果可能会影响子节点,反过来则不会。假如父节点删除了子节点,那么子节点的同步操作就是不必要的。

同步过程中,按树从顶向下的顺序更新节点,可以避免无用更新。

局部更新调优:数组

数组在同步操作中是个特殊的对象,无论是 React 还是 Vue 都对数组元素的同步更新做了特殊的优化处理。

数组元素有些特性:

  • 可以增删改
  • 可以重新排序

根据数组元素的特性,更新时需要做到:

  • 数组节点的更新不能更新数组内单个元素:数组节点更新只处理增删,单个数组数组元素会由其依赖源触发更新,不需要在数组节点更新中处理(保持之前的父子节点独立更新原则)
  • 重用相同结果:相同元素排序后位置变更,但它的转换结果是相同的,需要重用已有结果,这个可以根据数组元素对应的原始数据作匹配查找

做到以上几点,数据同步的准确性和性能就可以得到保障。

数据同步绑定的语法定义

显然不可能仅仅为了两种数据间的同步,让开发者去使用 React 或 Vue 之类的视图框架所定义的 数据-视图 绑定语法。(实际上也是不能,假如能用 Virtual DOM 的方式描述原始数据到结果数据的绑定,并做到最小化的局部更新,显然已经可以用类似的方式直接将原始数据映射到视图)

最终的语法定义尽量保持写法不变,前后对比如下:

总结

引入中间数据之后,我们得到了:

  • 原始模型数据不再包含业务数据,只考虑模型相关业务,逻辑更清晰
  • 模型数据终于可以正确应用到 Virtual DOM 视图框架,因此才有了:

响应式是个不错的解决方式,引入中间数据的同时并不会带来过多的代价,因为大多数情况下使用者只需要关心原始数据更改(就好像在 React 视图框架中我们只关心如何修改数据,不关心视图如何更新)。

同时数据间同步的解决方案的适用面广,它只是做同步这一件事,完全可以适用于其他情况,做任意两层之间数据格式不匹配的桥梁。

关注我们

酷家乐质量效能团队热衷于技术的成长和分享,几乎每个月都会举办技术分享活动(海星日),每半年举办一次技术专题竞赛分享(火星日),并将优秀内容写成技术文章。

我们尽可能保障分享到社区的内容,是我们用心编写、精心挑选的优质文章。如果您想更全面地阅读我们的文章,请您关注我们的微信公众号"酷家乐技术质量"。