Mvvm 前端数据流框架精讲

5,615 阅读7分钟
原文链接: mp.weixin.qq.com
作者简介  

黄子毅,目前在阿里数据中台前端团队,负责数据产品相关业务。前端精读创办者、数据流框架 Dob 作者、可视化编辑器 gaea-editor 作者、react-native-image-viewer 作者、曾维护数套前端组件库。

本文来自黄子毅在“携程技术沙龙——新一代前端技术实践”上的分享。

本文将带大家了解什么是 mvvm,mvvm 的原理,以及近几年产生了哪些演变。同时借 mvvm 话题,拓展到对各类前端数据流方案的思考,形成对前端数据流整体认知,帮助大家在团队中更好地做技术选型。

一、Mvvm 的概念与发展

1、Mvvm & 单向数据流

Mvvm 是指双向数据流,即 View-Model 之间的双向通信,由 ViewModel 作桥接。如下图所示:

而单向数据流则去除了 View -> Model 这一步,需要由用户手动绑定。

2、生态 - 内置 & 解耦

许多前端框架都内置了 Mvvm 功能,比如 Knockout、Angular、Ember、Avalon、Vue、San 等等。

而就像 Redux 一样,Mvvm 框架中也出现了许多与框架解耦的库,比如 Mobx、Immer、Dob 等,这些库需要一个中间层与框架衔接,比如 mobx-react、redux-box、dob-react。解耦让框架更专注 View 层,实现了库与框架灵活搭配的能力。

解耦的数据流框架也诠释了更高抽象级别的 Mvvm 架构,即:View - 前端框架,Model - (mobx, dob),ViewModel - (mobx-react, dob-react)。

同时也实现了数据与框架分离,便于测试与维护。比如下面的例子,左边是框架无关的纯数据/数据操作定义,右边是 View + ViewModel:

3、运行效率 - 脏检测 & getter/setter 劫持

Angluar 早期的脏检测机制虽然开创了 mvvm 先河,但监听效率比较低,需要 N + 1 次确认数据是否有联动变化,就像下图所示:

现在几乎所有框架都改为 getter/setter 劫持实现监听,任何数据的变化都可以在一个事件循环周期内完成:

4、语法 - 特殊语法 & 原生语法

早期一些 Mvvm 框架需要手动触发视图刷新,现在这种做法几乎都被原生赋值语句取代。

5、数据变更方式 - Mutable & Immutable

下图的代码语法虽为 mutable,但产生的结果可能是 mutable,也可能是 immutable,取决于 mvvm 框架内置实现机制:

6、Connect 的两种写法

由于 mvvm 支持了 mutable 与 immutable 两种写法,所以对于 mutable 的底层,我们使用左图的 connect 语法,对于 immutable 的底层,需要使用右图的 conenct 语法:

对左图而言,由于 mutable 驱动,所有数据改动会自动调用视图刷新,因此不但更新可以一步到位,而且可以数据全量注入,因为没用到的变量不会导致额外渲染。

对右图,由于 immutable 驱动,本身并没有主动驱动视图刷新能力,所以当右下角节点变更时,会在整条链路产生新的对象,通过 view 更新机制一层层传导到要更新的视图。

二、从 TFRP 到 mvvm

讲到 mvvm 的原理,先从 TFRP 说起,详细可以参考《dob-框架实现》,该文以 dob 框架为例子,一步步介绍了如何实现 mvvm。本文简单做个介绍。

1、autorun & reaction

autorun 是 TFRP 的函数效果,即集成了依赖收集与监听,autorun 背后由 reaction 实现。

2、reaction 实现 autorun

如下图所示,autorun 是 subscription 套上 track 的 reaction,并且初始化时主动 dispatch,从入口(subscription)处激活循环,完成 subscription -> track -> 监听修改 -> subscription 完成闭环。

3、track 的实现

每个 track 在其执行期间会监听 callback 的 getter 事件,并将 target 与 properityKey 存储在二维 Map 中,当任何 getter 触发后,从这个二维表中查询依赖关系,即可找到对应的 callback 并执行。

4、View-Model 的实现

由于 autorun 与 view 的 render 函数很像,我们在 render 函数初始化执行时,使其包裹在 autorun 环境中,第 2 次 render 开始遍剥离外层的 autorun,保证只绑定一遍数据。

这样 view 层在原本 props 更新机制的基础上,增加了 autorun 的功能,实现修改任何数据自动更新对应 view 的效果。

三、Mvvm 的缺点与解法?

Mvvm 所有已知缺点几乎都有了解决方案。

1、无法监听新增属性

用过 Mobx 的同学都知道,给 store 添加一个不存在的属性,需要使用 extendObservable 这个方法。这个问题在 Dob 与 Mobx4.0 中都得到了解决,解决方法就是使用 proxy 替代 Object.defineProperty:

2、异步问题

由于 getter/setter 无法获得当前执行函数,只能通过全局变量方式解决,因此 autorun 的 callback 函数不支持异步:

3、嵌套问题

由于 reaction 特性,只支持同步 callback 函数,因此 autorun 发生嵌套时,很可能会打乱依赖绑定的顺序。解决方案是将嵌套的 autorun 放到执行队列尾部,如下图所示:

4、无数据快照

mutable 最被人诟病的一点就是无法做数据快照,不能像 redux 一样做时间回溯。有问题自然有人会解决,Mobx 作者的 Immer 库完美的解决了问题。

原理是通过 proxy 返回代理对象,在内部通过浅拷贝替代对对象的 mutable 更改。具体原理可以参考我之前的一篇文章《精读 Immer.js 源码》。

5、无副作用隔离

mvvm 函数的 Action 由于支持异步,许多人会在 Action 中发请求,同时修改 store,这样就无法将请求副作用隔离到 store 之外。同时对 store 的 mutable 修改,本身也是一种副作用。

虽然可以将请求函数拆分到另一个 Action 中,但人为因素无法完全避免。

自从有了 Immer.js 之后,至少从支持元编程的角度来看,mutable 并不一定会产生副作用,它可以是零副作用的:

typescript function inc(obj) { return produce(obj => obj.count++) }

上面这种看似 mutable 的写法其实是零副作用的纯函数,和下面写法等价:

typescript function inc(obj) { return { count: obj.count + 1, ...obj } }

而对副作用的隔离,也可以做出类似 dva 的封装:

四、Mvvm store 组织形式

Mvvm 在项目中 stores 代码结构也千变万化,这里列出 4 种常见形式。

1、对象形式,代表框架 – mobx

mobx 开创了最基本的 mvvm store 组织形式,基本也是各内置 mvvm 框架的 store 组织形式。

2、Class + 注入,代表框架 – dob

dob 在 store 组织形式下了不少功夫,通过依赖注入增强了 store 之间的关联,实现 stores -> action 多对一的网状结构。

3、数据结构化,代表框架 – mobx-state-tree

mobx-state-tree 是典型结构化 store 组织的代表,这种组织形式适合一体化 app 开发,比如很多页面之间细粒度数据需要联动。

4、约定与集成,代表框架 – 类 dva

类 dva 是一种集成模式,是针对 redux 复杂的样板代码,思考形成的简化方案,自然集成与约定是简化的方向。

另外这种方案更像一层数据 dsl,得益于此,同一套代码可以拥有不同的底层实现。

五、Mvvm vs Reactive programming

Mvvm 与 Reactive programming 都拥有 observable 特性,通过下面两张图可以轻松区分:

上面红线是 mvvm 的 observable 部分,这里指的是数据变化的 autorun 动作。

上面红线是 Reactive programming 的 observable 部分,指的是数据源派发流的过程。

Mvvm 与 Reactive programming 的结合

既然 redux 可以与 rxjs 结合(redux-observable),那么 mvvm 应该也可以如此。

下面是这种方案的构想:

rxjs 仅用来隔离副作用与数据处理,mvvm 拥有修改 store 的能力,并且精准更新使用的 View。

六、总结

根据业务场景指定数据流方案,数据流方案没有银弹,只有贴着场景走,才能找到最合适的方案。

了解到 mvvm 的发展与演进,让不同数据流方案组合,你会发现,数据流方案还有很多。

点击文末“阅读原文”,可看讲师现场分享视频。

【推荐阅读】