开发一个 MVVM 框架的过程中,需要面对的主要问题有哪些?

本文首发于我在知乎上的一个回答:开发vue(或类似的MVVM框架)的过程中,需要面对的主要问题有哪些?

列举几点我开发 Sugar 或阅读 Vue (1.0.26) 源码时遇到的难题和巧妙之处以及附上我个人极其主观的难度系数和巧妙系数评估:

难题一:如何控制指令的编译优先级?

如何扫描/提取指令我就不具体说了,就是写一个递归方法遍历所有节点,然后找出所有合法指令(以 v- 开头)放入编译队列,控制指令编译的优先级的场景大概是比如有两个节点:

<h1 v-if="show" v-text="text"></h1>

<ul>
<li v-for="item of items" v-bind:id="item.id"></li>
</ul>

因为 v-for 和 v-if 指令的编译优先级别是最高的,所以在编译以上 h1 和 li 节点的时候,需要判断如果有 v-if 或 v-for 必须先编译他们才能保证相同节点上其他指令的正常取值。到这里遇到的另一个问题就是假如进入到了 v-if/v-for 的判断里,如何阻止其他优先级较低的指令继续编译,如果不做处理继续编译的话上面的 v-bind:id="item.id" 就会无法正常取值。

难题二:如何解析和获取指令表达式的值?

假如指令表达式是更为复杂的带各种运算符的情况比如:

<h1 v-if="show || items.length > 0" v-text="'Hello, ' + text"></h1>

<ul>
    <li v-for="item of items" v-bind:id="item.id + text"></li>
</ul>

这种情况应当如何去获取表达式的值(或者可以将问题理解为如何执行并获取字符表达式的值)?我们很容易想到用 new Function() 可以实现,但是直接 new Function("return show || items.length > 0") 可不行,因为 new Function() 弄出来的函数只能访问全局变量,所以需要在表达式上做些改动。

在 MVVM 指令中每个表达式都有一个类似于 JS 中作用域的 scope,所有表达式的取值均在这个作用域上,所以不难得出这个 scope 其实就是数据模型对象或其加工后的对象,我们要得到的就是区分变量和常量并且给变量加上 scope 的表达式比如:

<h1
    v-if="scope.show || scope.items.length > 0"
    v-text="'Hello, ' + scope.text"
></h1>

<ul>
    <li
        v-for="item of scope.items"
        v-bind:id="scope.item.id + scope.text"
    ></li>
</ul>

当然如果你设计的框架要求使用者必须全部手动带上 scope.xxx 的话这个难题就不存在了。所以这个问题其实就转化成为了如何把字符串 "show || items.length > 0" 变成 "scope.show || scope.items.length > 0",这时候用 new Function("scope", "return scope.show || scope.items.length > 0") 就可以指定参数作用域进行取值了,由于是用 new Function() 来生成取值函数,所以其他边缘问题还要考虑限制恶意执行的表达式或 JS 关键字,这个问题对于正则表达式玩的溜的人来说绝对不算是难题,因为本人正则比较菜,所以难度系数定在了 8 ~

另外还有一个也不算是难题但是实现很巧妙的地方就是比如上面 v-for 循环下的 v-bind:id="scope.item.id + scope.text" 的取值问题,这里的 scope.item.id 是需要在 v-for 作用域上取值的,但是 scope.text 又是顶层数据模型 $data 上的取值!那这个 scope 到底是个什么样神奇的东西才能同时拥有“两个”取值作用域?其实很简单,就是利用原型!只要将 v-for 中的 vforScope 的原型指向 $data 即可:

var vforScope = Object.create(vm.$data);

然后在 vforScope 上定义 item 域(术语为 alias)为数组的选项即可:

vforScope.item = { id: 'xxx' };

此时这个 vforScope 既可以在自己的取值域(优先)也可以在 $data 上取值,所以当访问 scope.item.id 就会返回 'xxx' 此时就算是数据模型上有一个同名的 key 比如 $data.item = 123 也不会影响在 v-for 中的取值,因为先访问自己的再访问父级的(原型上)的值。

难题三:如何实现数据订阅模块?

数据订阅是 MVVM 内部的最重要的模块部件之一,是实现数据驱动的核心。这个不太容易用具体的使用场景例子来说明遇到的问题。但是数据订阅模块的实现功能目的非常清晰和明确,就是实现对一个表达式中的任意依赖的变更能产生回调通知,比如:

new Watcher("'Hello, ' + name + ', ' + text", function (newValue) {
    // 这里需要监听的依赖是 name 和 text
});

数据订阅模块的内部处理需要结合指令表达式的解析结果和依赖追踪机制,是一个承上启下的重要环节,也是有很多的难题和巧妙点,这里不一一列举了。

难题四:如何追踪指令表达式的所有依赖?

好吧,当我看到 Vue 源码将 Observer, DependerWatcher 相结合来对任意复杂指令进行依赖追踪的实现后,我感觉这个实在实在是太太太太太巧妙了!!

我最初的构思和想法是类似利用一些比较著名的第三方对象变化监测库(哪怕造一个适合 MVVM 场景的)来实现对 $data 变化的监听(Object.observe 已狗带),利用取值的访问路径来实现依赖的追踪,这个方案的确可以实现一个监测依赖变更的 Watcher 并且利用这个思路去实现一个最简单的 MVVM(我的 Sugar 1.2 版本之前的就是这么做的), 但是基于访问路径的依赖追踪限制非常的多:不能更新依赖,不能分解依赖,不能实现计算属性等,其实也不是不能实现,只是实现起来蛋疼无比而且还不稳定代码量还超级多还难以维护。

所以要实现一个小巧玲珑并且功能强大稳定的 MVVM 用对象变化监测模块+访问路径来实现依赖追踪显然不合适。其实 Vue 的做法巧妙的地方更接近于“只拦截不监测”,或者换种说法是在拦截中用 depend 模块来代替监测(依赖驱动)。大致的三个步骤简要概括下:

  1. 依赖追踪的前期工作要在 MVVM 编译之前就应该开始了,做法就是拦截 $data 对象中每一个属性的 get 和 set(对象存取描述符 Object.defineProperty 目前分析这个的文章应该一搜几箩筐)。然后在每一次拦截前预先生成一个 depend 实例,这个实例需要做的只有两件事:一是在当前被拦截的属性被 get(访问到)的时候把自己交给数据订阅模块 Watcher 作记录;二是在当前被拦截的属性被 set(设置新值)的时候告诉跟自己有关系的所有 Watcher :“我的值变成 XXX 了,你们都马上更新下!”。
  2. 在指令表达式被解析的阶段比如 new Function("scope", "return scope.show || scope.items.length > 0") 时会去访问 scope (这里就是 $data) 的 show 和 items,此时 show 和 items 所在的拦截被触发,负责拦截的 depend 就会分别记住他们两个(参见步骤 1 中 depend 的职责),一旦发生变化立刻通知给 Watcher。
  3. Watcher 实例中关联了该表达式的所有的 depend 实例,只要任意一个依赖变化,表达式收到通知后重新求值并回调给用到该 Watcher 的具体指令实例,指令实例在收到变更通知的同时也能拿到新值和旧值,这样就实现了数据驱动视图(Model drive View)的模式。

Observer + Depender + Watcer 这个模式来实现依赖追踪机制加起来不过 600 行左右的代码就实现了 MVVM 中最最核心的功能。利用这个模式就可以轻而易举地实现 vm.$watch 这个 API;此外,在这个模式下只用了不超过 30 行 JS 代码就实现了强大的计算属性功能。

以上列举这些难题,或许对于不同的人来说这些不一定都是难题。其实实现一个 MVVM 还有很多有趣/巧妙的问题,比如数组的变异方法处理、v-for 循环列表的优化或复用、事件绑定函数和参数处理等等。只要解决了难题,难题就变成了巧妙之处,所以有兴趣就看源码吧,有动力就造轮子吧!

评论列表

除了广告和敏感话题言论之外,可以畅所欲言。
为自己起个简短易记的名字。
方便我可以联系到你,绝对不会被公开。
你的个人主页,链接会加在昵称上方便大家访问。