手撸MVVM框架——双向绑定核心

avatar
FE @字节跳动

序言

前两年在一家创业公司时,公司后来被收购,没啥业务压力的情况下自己动手写了一个类似 Vue 的 MVVM 框架玩,并同时在这个框架上实现了一个 Material Design 风格的组件库。麻雀虽小,但也算五脏俱全,因此也算是有一点心得体会,准备分享给大家。有兴趣的同学可以把玩下:yuhangge.gitee.io/jinge-mater…

先说下的是,笔者没有研读过 Vue 的源码。这个 MVVM 框架是基于“使用ES6 Proxy进行数据绑定”这一比较广为人知的方向,结合笔者过往业务经验,完全自主编写的一个玩具级的框架,还忘各路大佬轻拍。

本文涉及的双向绑定核心,对应的源码位于:github.com/jinge-desig…

API 设计

接下来开始正题。大家都知道 MVVM 框架的核心是“双向绑定”,而本文选择了“使用ES6 Proxy” 来实现这一核心。我们先来设计这一核心的 API。

vm

显然我们需要将一个目标 Object 使用 Proxy 代理。不论是编译器生成自动调用代码,还是用户业务代码里手动调用,都会用到这样一个转换函数。我们将其命名为 vm 函数。同时,我们将 Proxy 代理结果定义为 ViewModel(简称 VM) 类型。详见下述代码里的注释:

/**

 * @param obj 需要进行代理的目标 object,代理后可对该对象进行属性监听。

 * @returns 返回目标 object 的 Proxy

 */

function vm(obj: T extends object): VM<T>;

/**

 * VM 类型是一个 Proxy,具备所有目标对象 T 的类型,同时具备 MVVM 框架赋予的 InnerTypes

 */

type VM<T> = Proxy<T & InnerType>;

watch

不论是编译器自动生成监听代码,还是用户业务手动书写 ,都会需要一个挂载监听器 watch 函数,用于监听目标对象的属性赋值操作。详见下述代码里的注释:

/**

 * @param VM 已经通过前文的 vm 函数包装的代理对象。

 * @param props 要监听的属性。支持 "*" 和 "**" 通配符。

 * @param listener 目标对象发生变化(更准确讲发生赋值动作)时的监听回调。

 */

function watch(target: VM, propPath: PropertyPath, listener: Listener): void;

/**

 * 属性(路径)可以是数组,也可以是由点号.间隔的字符串。比如

 * "a.b.0.c" 等价于 ["a", "b", 0, "c"],都是监听 target.a.b[0].c,

 */

type PropertyPath = string | number | (string | number)[];

/**

 * 监听回调函数只向业务传递

 */

type Listener = (propPath: PropertyPath) => void;

需要注意的是,Listener 监听函数只能获取到当前赋值操作的属性路径,拿不到当前属性路径赋值前后的旧值和新值。这是这个框架的设计哲学,我们不做脏值检测。

unwatch

对应的也需要卸载监听器 unwatch 函数,详见下述代码里的注释:

/**

 * @param target 要取消监听的对象

 * @param props 要取消的监听的属性路径。可选参数,不提供则取消目标对象上的所有路径上的监听。

 * @param listener 要取消的监听的监听回调。可选参数,不提供则取消该路径上的所有监听回调。

 */

function unwatch(target: VM, propPath?: PropertyPath, listener?: Callback): void;

测试用例

我们考虑用测试驱动,先尝试总结出典型用例。读者可以尝试思考如何编写代码实现上述 API 的能力,足够支撑下述的全部用例,可以是一个非常不错的练习。

简单赋值

watch(target, 'a')

target.a = 10; // 触发监听函数

target.b = 20; // 不触发

watch(obj, 'a.0.c') // 等价于 watch(obj, ['a', 0, 'c']);

obj.a[0].c = 3; // 触发监听函数

需要注意的是,watch(target, 'a.b')watch(target.a, 'b') 是不同的:

watch(target, 'a.b') // #1

watch(target.a, 'b') // #2

target.a.b = 10; // 会同时触发 #1 和 #2 的监听

target.a = {};  // 只会出发 #1 的监听,不会触发 #2。

// 第 4 行代码执行后,#2 就再也不会触发了。旧的 target.a 这个对象失去引用,将被回收。

父级对象变更

watch(obj, 'a.b.c', listener)

obj.a = {}; // 可触发对 a.b.c 的监听

obj.a.b = {}; // 触发

obj.a.b.c = "hello"; // 触发

数组

设置 length

target = vm(['a', 'b', 'c', 'd'])

watch(target, 'length') // #1

watch(target, [1]) // #2

target.length = 3; // 不会触发监听函数 #2

target.length = 1; // 触发监听函数 #1 和 #2

watch(target, [6]); // #3

target.length = 10; // 会触发监听函数 #1 和 #3

各种函数

数组的各种原生函数都需要支持:

target = vm([1,1,2,3,5])

watch(target, 'length') // #1

watch(target, [1]) // #2

target.push(8)  // 触发 #1

target.unshift(0); // 触发 #1 , #2

// splice, pop, shift, slice, ...

通配符

框架内部(模板及模板编译器)不需要通配的能力,但业务代码中会有需要深度监听的能力,因此需要支持通配符。

单层通配符 *

可以监听目标对象上的所有一级路径:

watch(target, 'a.b.*.c');

target.a.b[0] = {}; // 触发监听函数

target.a.b[1].c = 34; // 触发

target.a.e.o.c = 34; // 不会触发

深层通配符 **

相当于 deep watch 的概念:

target = [new Todo(), new Todo()];

// 任何数据变化时都需要将数据存到 localStorage

watch(target, '**', () => saveToLocalStorage(target));

// 以下三种 case 都会触发监听函数

target.push(new Todo());

target.splice(1, 1);

target[0].title = 'new title';

上面的用例来源于实际的 Todo Demo,及其源码。值得一提的是这个 demo 是的 TodoMVC 的实现之一。

组合和转移

父子对象之间可以组合关联,也可以转移或移除:

objA = vm({ a: { b: {} } }) // 父对象

objB = vm({}) // 子对象

watch(objB, 'x') // #1

watch(objA, 'a.b.x') // #2

objB.x = 10; // 只会触发  #1 的监听

objA.a.b = objB; // 只会触发 #2

objA.a.b.x = 20; // 会同时触发 #1 和 #2 的监听

objB.x = 30; // 会同时触发 #1 和 #2

objA.a.b = null; // 移除子对象,只触发 #2

objB.x = 40; // 只触发 #1,不再触发 #2

objC = objA.a; // 从父对象中转移

watch(objC, 'b') // #3,等价于 watch(objA.a, 'b')

objC.b = "oo" // 会同时触发 #2 和 #3

objA.a = null; // 只会触发 #2

objC.b = "ss"; // 只会触发 #3,不再触发 #2

动态复合监听

在 vue 等框架模板中,经常会有如下的写法:

<span>你好:{{obj.a[obj.b.c][index].name}}</span>

这种情况下,渲染函数要监听的是 watch(root, ["obj", "a", XX, YY, "name"])这样一个路径,且其中的 XX 和 YY 是动态的。并且,XX 本身也是需要进一步监听的 watch(root, "obj.b.c")。也就是,我们需要能够支持这种动态且复合的监听能力。

实际上,这个动态复合监听的能力,本身不属于双向绑定核心直接支持的能力,而是模板编译器如何使用核心能力来支撑这个需求的话题。也就是本质上这个不属于双向绑定核心的测试用例。但在此处列出来,是想让喜欢挑战的同学,提前思考下可以如何解决这种动态且复合的监听需求。后续的文章中,我们会在模板编译的相关章节介绍方案。

设计实现

Proxy

我们首先需要关注如何对目标对象进行代理。大家都知道,对于只有一层的对象,比如 { a: 1, b: 'b' },通过 Proxy 可以简单地捕获在其上的属性赋值。

但对于多层对象,比如 target = { a: { b: [{ c: 0 }, { d: 1 } ] }} ,简单思考后便会发现,如果只对 target 进行 Proxy,那么当执行 target.a.b[0].c = 1 这样的赋值操作时,如何感知到 a.b.0.c 这样一个 path 发生了赋值变更,会成为问题。

image.png 我们采取的方案是,对目标对象进行深度遍历的递归代理。也就是像上图所示,把简单地对 target 进行代理,变成对 target 及其所有子对象都进行代理。在 src/vm/proxy.ts 中,createViewModelwrapProp 两个函数互相调用形成递归来实现深度代理。

ViewModelCore

当使用 watch 函数来挂载对目标对象的监听时,需要有一个地方可以将监听函数存下来。好比最普通的 EventEmitter,使用 emitter.on 函数时,会将 listener 存下来;使用 emitter.emit 时会通知(触发)对应 listener 执行。我们将这样一个 EventEmitter 概念的核心,设计为 class ViewModelCore 的类。这个类的内部,包含了 listener 的存储器和更多用于支撑功能逻辑的成员属性,以及 watch,unwatch 等函数。

image.png

在对目标对象进行 vm 代理时,对每一层的 object 对象,会新建一个 ViewModelCore 的类实例,然后将其挂到 target 上的 $$ 属性,即 target.$$ = new ViewModelCore()

实际的代码中,$$ 我们采用了 Symbol,即 const $$ = Symbol('InnerViewModelCore');同时采用 Object.defineProperty(target, $$ 的方式挂载到目标对象。以此保证这个 ViewModelCore 不被业务层直接感知和使用。

挂载好后,调用 watch,unwatch 函数时,会实际在内部转成调用 target[$$] 上的对应函数。

Path-Tree

使用 watch 函数挂载监听时,除了 listener 外,监听的路径也是需要保存的。

image.png

上图所示的是当执行 watch(target, 'a.b', callback_b)时的 Path-Tree 。我们将监听路径转成一棵树型结构,将 listener 函数也就是 callback_b 实际挂在树节点b上。

而 Path-Tree 是动态构建的,也就是只有实际挂载监听函数时,才按需生成树节点。因此上图中,Path-Tree 里面,没有 c 节点。但当执行 watch(target, 'c', callback_c) 之后,则会有新的 c 节点在 Path-Tree 中生成,并将 callback_c 函数实际挂在节点 c 上。

值得一提的是,如果接下来再执行 watch(target.a, 'b', callback_b2),则并不是在 target[$$] 的 Path-Tree 的 b 节点挂载监听函数。而是如下图所示,会在 target.a 对象的 ViewModelCore 的 Path-Tree 上,挂载在 b 节点上。大家可以仔细体会其中的区别。

image.png

Notify

通过 vm 函数,我们可以构建出一棵基于目标对象的代理树 Proxy-Tree(树的每一个节点是原始数据的 Proxy 代理)。通过 watch 函数,我们构建了代理对象的监听路径树 Path-Tree,树的每一个节点会挂上实际的监听函数。

接下来,就是当执行比如 target.a.b = 20 这样的赋值操作时,我们如何将 Proxy-Tree 和 Path-Tree 进行联动从而实现完整的监听。

这个过程很简单。在 Proxy-Tree 上,显然是 b 节点最先响应到赋值操作(通过 ES6 Proxy 的能力),然后会把这个消息,在 Proxy-Tree 中向上传递。每传递到一个节点,则会看当前节点的 ViewModelCore 上是否有 Path-Tree 的存在(即是否有通过 watch 函数挂载的监听)。如果有的话,则在 Path-Tree 中向下传递消息,并对每一个节点的实际挂载的 listeners 进行调用(通知)。

因此,当执行 target.a.b = 20 这样的赋值操作后,callback_b2 和 callback_b 都会被触发,但 callback_c 不会被触发。

总结

我们在本文通过测试驱动的方式,带着大家设计了一个双向绑定核心。但实际编码实际这个设计,仍然有诸多细节需要考虑和处理。但在这里我们不准备深入细节,因此也就没有粘贴代码。如果想了解一个 MVVM 框架代码级的细节,就推荐你直接读 Vue 或 React 的源码。但笔者更推荐的是,在读完本文后,能让你有所启发,自己去尝试实现一个双向绑定的核心,或者尝试琢磨一个比本文的方案更优雅的设计方案。

后续当时间许可,笔者会进一步分享在这个双向绑定核心的基础上,如何一步步搞出完整的 MVVM 框架,敬请期待。