基于 Vue 的小程序开发框架性能优化实践---去除 VNode

为了提高小程序的开发效率,我们团队开发了Mars 框架,可以使用 Vue 语法开发小程序,同时支持编译到 H5。近期我们进行了 Mars 框架的性能升级(0.3.x 版本),极大简化了 Vue 的 render 过程,去掉了 VNode 构建,省略了 patch 过程,从而获得了性能提升。

Mars 框架原理简介,为什么要去除 VNode?

为了方便大家理解,这里简单说一下 Mars 框架的原理,目前基于 Vue 的小程序开发框架原理差异不大。

详细的原理大家可以看这篇文章:Mars - 又双叒叕一个多端开发框架?这次是 Vue 驱动,能完美适配 H5

Mars 的原理如下图所示:

Mars 原理图

上图中,左半部分表示小程序的执行部分。粉红色区域代表小程序视图,蓝色部分代表小程序的逻辑执行部分,视图与逻辑之间交换的是数据和事件。右边绿色部分是我们在小程序逻辑之外,单独创建的 Vue 实例。小程序逻辑(蓝色部分)与 Vue 实例(绿色部分)是以如下方式工作的:

  • 在小程序的 Page 创建时,我们会同步 new 一个 Vue 实例。
  • 在 Vue 实例的 .$mp.scope 变量中绑定小程序实例,小程序实例中也会使用 .$vue 变量来绑定 Vue 实例,用于后续的数据传递。
  • 使用 handleProxy 方法代理小程序中的事件,当小程序事件发生时,对应执行 Vue 实例中相应的 Method。
  • 页面中的逻辑执行在 Vue 部分,每当 Vue 的视图更新时,在 Updated 阶段将数据的变化使用 setData 方法同步给小程序实例,触发小程序视图的刷新。

可以看到优化前我们基本保留了 Vue 的所有渲染过程,只是删除了 Vue 中的 DOM 操作部分。由于 Vue 实例与小程序之间交换的只有数据,因此 Vue 中的视图层其实是没有用到的。 我们需要的只是执行 Vue 中的逻辑,判断数据修改是否会造成视图更新,视图更新时把变化的数据同步给小程序。而 Vue 视图层相关的内容,VNode、render、patch 这些很多是没有必要的,我们的想法是通过精简不必要的操作来提升性能。

优化前 render 和 patch 过程所起的作用

想要精简 render 和 patch,我们就需要先搞清楚 render 和 patch 在 Vue 中起到了什么作用:

  1. 在 Vue 中,当数据发生变化时,会通知视图渲染依赖这一数据的所有实例,依次执行这些实例的 render 函数,这次 render 函数执行过程中又会重新收集依赖,用于下一次数据发生变化时的依赖追踪。
  2. render 函数执行后会返回一个该实例对应的 VNode 树,render 过程中并不会创建子组件实例,仅仅是生成了一个占位符。这个 VNode 树随后会传递给 patch 过程。
  3. patch 过程会将当前 VNode 树与旧 VNode 树进行 diff,之后根据 diff 创建、销毁子组件实例,修改 DOM 完成渲染。

在小程序框架这个情境下,我们需要的是 数据依赖追踪组件实例创建、销毁,其他部分的内容则可以进行删减。

我们可以精简哪些内容?

  • render 函数部分,我们只需要进行必要的依赖追踪,不需要创建 VNode 节点。
  • patch 部分,由于没有 VNode 了,我们也不需要进行耗时的 diff 操作了!

但是等一下,没有了 VNode 树,如何创建组件实例呢?我们将子组件的 Vue 实例创建改到了小程序子组件的生命周期中,也就是说单个 Vue 实例只会创建它自己,不会在继续创建子组件实例。 之前的结构为小程序实例树和 Vue 实例树,组件实例间互相绑定。现在的结构变为只有小程序实例树,每个小程序实例节点单独对应一个 Vue 实例。

开始实践!

下面介绍一下我们具体做了哪些内容。

createComponent 中创建 Vue 实例

由于把 patch 过程干掉了,因此我们需要手动创建子组件的 Vue 实例,同 Page 一样,我们在 Component 的生命周期函数中 new 一个 Vue 实例,并与当前小程序实例绑定:

1
2
3
4
this.$vue = new VueComponent(options);
this.$vue.$mp = {
scope: this
};

在组件中创建 Vue 实例时,之前 Vue 中的父子关系没有了,维护这一关系需要解决以下问题:父元素绑定properties 传递

父元素绑定

在 patch 过程中,Vue 创建子组件时会传递以下三个参数:

1
2
3
4
5
const options: InternalComponentOptions = {
_isComponent: true,
_parentVnode: vnode,
parent
}
  • _isComponent 用于优化 options 的合并,我们可以直接设置成 true。
  • _parentVnode 用于在 render 过程中获取父元素信息,例如 scope-slot 等,由于我们已经把 VNode 删掉了,因此不再需要了。
  • parent 用于获取根元素、绑定 $children 等操作,Vue 就是通过这个参数来维护实例间的父子关系的。

我们需要找到当前 Vue 实例的父实例,作为 parent 参数,从而完成父元素绑定过程。 小程序当前没有机制来直接获取父元素,需要我们自己想办法来查找。在之前开发 Mars 过程中,为了进行小程序组件实例和 Vue 组件实例间的匹配,对小程序实例树和 Vue 实例树中的组件节点都进行了标记,现在不需要进行实例间匹配查找了,但是我们可以通过这个标记来查找父元素。

  • 由于 Page 元素可能在同一时间不唯一(由于页面切换),因此每创建一个 Page 实例,都需要绑定一个唯一的 rootUID,我们将其存储在了getApp().__pages__中。rootUID 会逐层传给每个小程序自定义组件实例。
  • 每次有小程序自定义组件实例创建,我们都将该实例以标记的 id 为 key 存储在 getApp().__pages__[rootUID].__vms__中。
  • 根据 rootUID 找到根元素,进而找到 page 中的 __vms__
  • 根据 compId 算出父实例的 compId。
  • 根据父实例的 compid从__vms__中找到父元素,作为 parent。

properties 传递

除了需要设置的初始化属性外,我们还需要传递子组件的 properties,否则父元素的数据没办法传递给子组件。

  • 数据初始化:可以在 Vue 创建时传入 propsData 来作为 props 的初始数据。 由于小程序自定义组件的参数和 Vue 子组件实例的参数是相同的,因此我们可以直接将程序自定义组件的参数作为propsData在 new Vue 时传入:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    const options = {
    mpType: 'component',
    mpInstance: this,
    propsData: properties,
    parent
    };

    // 初始化 vue 实例
    this.$vue = new VueComponent(options);
  • 数据更新:仿照 Vue 给子组件传参数的机制,每次 render 时,将 props 重新给子组件赋值一遍。

只需要更新第一层,因为 properties 如果是对象,那么它在父元素中已经做过变化追踪了。

事件传递

对于 template 上绑定的事件,由于我们本身已经使用了 handleProxy 来处理,因此不会受到影响。

需要处理的是 .$emit.$on 方法。

  • 对于 .$emit,我们利用小程序机制,使用 triggerEvent 在小程序层面给父元素传递事件。
  • 对于 .$on,使用 Vue 现成的机制就好,不需要做额外工作,不过这也造成 Vue 的事件机制不能删除。

这里有个小坑:triggerEvent 方法传递的参数,需要从 event.detail 中获取,Mars 兼容了这个 diff。

render 函数精简

render 函数目前我们不能完全删除,因为需要以下两个功能:依赖收集复杂表达式和filter 计算

依赖收集

Vue 在初始化时会对实例上的 data 进行响应式处理,设置 set 和 get 方法。组件执行 render 函数时,会读取变量触发 get 方法,从而在 get 方法中将当前实例收集为这个数据的依赖。下次数据更新时 Vue 会通知依赖进行更新。

为了收集依赖,我们需要在 render 函数中读取一遍数据。这里我们将 VNode 树编译为数组树的形式,只留下数据,剩下的内容都可以删除。

比如这样的一个 template:

1
2
3
4
5
6
7
8
9
10
11
<template>
<view class="hello">
<view @tap="tapHandler">
<text>https://github.com/max-team/Mars</text>
</view>
<view>{{ aaa }}</view>
<view>{{ ccc }}</view>
<name :name="nameOutter"></name>
<view>{{ aaaComp }}</view>
</view>
</template>

Vue 产出的 render 函数是这样的:

1
2
// 修改前的 render 函数
_c('view',{staticClass:"hello"},[_c('view',{on:{"tap":_vm.tapHandler}},[_c('text',[_vm._v("https://github.com/max-team/Mars")])]),_c('view',[_vm._v(_vm._s(_vm.aaa))]),_c('view',[_vm._v(_vm._s(_vm.ccc))]),_c('name',{attrs:{"name":_vm.nameOutter,"compId":(_vm.compId ? _vm.compId : '$root') + ',0'}}),_c('view',[_vm._v(_vm._s(_vm.aaaComp))])],1)

精简后我们得到的 render 函数是这样的:

1
2
// 修改后的 render 函数
[,[,,[(_vm.aaa)],,[(_vm.ccc)],,[[_vm.nameOutter,(_vm.compId ? _vm.compId : '$root') + ',0']],,[(_vm.aaaComp)]]]

可以看到 Vue 中的大量 render helper 掉用,例如 _c_v_s 等都可以省略了。

有些 render helper 还是不能去掉,例如 v-for 循环,我们还是保留了 _l 函数,因为 v-for 循环的对象可能为数组、字符串、数字等多种情况。

复杂表达式和filter 计算。

在 Vue 的 template 中,是可以像 js 一样执行很多计算的,比如可以执行定义好的 method:

1
<div :prop="someMethod(data)"></div>

或者执行一个 filter

1
<div :prop="someMethod | someFilter"></div>

这部分的计算之前是在 render 中随着 VNode 构建执行的,计算结果存储在了 VNode 节点中。现在我们没有 VNode 了,计算出的值怎么办呢?

  • 计算复杂表达式和 filter 的过程还在 render 过程中保留。
  • 计算出的值使用 _ff 方法包裹。每个计算值产生一个唯一的 id,_ff 方法将这些值按照 id 存储下来 setData 给小程序,小程序直接使用这些计算结果来进行渲染。

patch 过程

patch 过程已经完全不需要了,我们将这一过程完全删除。

顺带解决的一个坑

在之前的方案中,从 Page 开始创建的小程序组件实例树,与 Vue 组件实例树是相互独立的。为了让小程序组件实例与 Vue 组件实例之间能够对应上(否则无法在组件级别 setData),我们需要对每个组件实例进行标记,通过标记来寻找对应关系。这在一些特殊情景下是会有问题的,例如组件快速生成又销毁等,造成实例间不匹配。

修改后的方案由于 Vue 实例是以组件级别创建的了,因此不再会出现实例无法匹配的情况。

结果和总结

我们使用了线上业务进行验证,渲染时间 -16%。此外,由于我们精简了 Vue 的功能,删除了这部分功能的代码,框架整体的体积也减少了 11%。