序言
前两年在一家创业公司时,公司后来被收购,没啥业务压力的情况下自己动手写了一个类似 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 发生了赋值变更,会成为问题。
我们采取的方案是,对目标对象进行深度遍历的递归代理。也就是像上图所示,把简单地对 target 进行代理,变成对 target 及其所有子对象都进行代理。在 src/vm/proxy.ts 中,createViewModel 和 wrapProp 两个函数互相调用形成递归来实现深度代理。
ViewModelCore
当使用 watch
函数来挂载对目标对象的监听时,需要有一个地方可以将监听函数存下来。好比最普通的 EventEmitter
,使用 emitter.on
函数时,会将 listener 存下来;使用 emitter.emit
时会通知(触发)对应 listener 执行。我们将这样一个 EventEmitter 概念的核心,设计为 class ViewModelCore
的类。这个类的内部,包含了 listener 的存储器和更多用于支撑功能逻辑的成员属性,以及 watch
,unwatch
等函数。
在对目标对象进行 vm
代理时,对每一层的 object 对象,会新建一个 ViewModelCore 的类实例,然后将其挂到 target 上的 $$
属性,即 target.$$ = new ViewModelCore()
。
实际的代码中,$$
我们采用了 Symbol,即 const $$ = Symbol('InnerViewModelCore')
;同时采用 Object.defineProperty(target, $$
的方式挂载到目标对象。以此保证这个 ViewModelCore 不被业务层直接感知和使用。
挂载好后,调用 watch
,unwatch
函数时,会实际在内部转成调用 target[$$]
上的对应函数。
Path-Tree
使用 watch
函数挂载监听时,除了 listener 外,监听的路径也是需要保存的。
上图所示的是当执行 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 节点上。大家可以仔细体会其中的区别。
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 框架,敬请期待。