作者:梅旭光 @Mayo
为提高小程序的开发效率,百度APP移动团队开发了Mars 框架。该框架支持使用 Vue 语法开发小程序,同时支持生成对应的 H5 页面。在 Mars 框架的0.3.x 版本中,我们极大简化了 Vue 的 render 过程,去掉了 VNode 构建,省略了 patch 过程,从而获得了性能提升。
Mars 框架原理简介,为什么要去除 VNode?
目前基于 Vue 的小程序开发框架原理差异不大。为方便大家理解,这里简单说一下 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 中起到了什么作用:
- 在 Vue 中,当数据发生变化时,会通知视图渲染依赖这一数据的所有实例,依次执行这些实例的 render 函数。render 函数执行过程中又会重新收集依赖,用于下一次数据发生变化时的依赖追踪。
- render 函数执行后会返回一个该实例对应的 VNode 树,render 过程中并不会创建子组件实例,仅仅是生成了一个占位符。这个 VNode 树随后会传递给 patch 过程。
- patch 过程会将当前 VNode 树与旧 VNode 树进行 diff,之后根据 diff 创建、销毁子组件实例,修改 DOM 完成渲染。
在小程序框架这个情境下,需要的是 数据依赖追踪 和 组件实例创建、销毁,其他部分的内容则可以进行删减。
可以精简哪些内容?
- render 函数部分,我们只需要进行必要的依赖追踪,不需要创建 VNode 节点。
- patch 部分,由于没有 VNode 了,我们也不需要进行耗时的 diff 操作了!
但是等一下,刚才我们说过子组件实例是在 diff VNode 树的过程中创建的,现在没有 VNode 树了,子组件实例如何创建呢?
解决方法是:在小程序子组件的生命周期中创建对应的 Vue 实例。也就是说,单个 Vue 实例只会创建它自己,不会再继续创建子组件实例。 之前的结构为小程序实例树和 Vue 实例树,组件实例间互相绑定。现在的结构变为只有小程序实例树,每个小程序实例节点单独对应一个 Vue 实例。
开始实践!
下面介绍去除 VNode 所做的具体工作。
createComponent 中创建 Vue 实例
由于把 patch 过程干掉了,因此需要手动创建子组件的 Vue 实例。同 Page 一样,我们在 Component 的生命周期函数中 new 一个 Vue 实例,并与当前小程序实例绑定:
this.$vue = new VueComponent(options);
this.$vue.$mp = {
scope: this
};
在小程序组件中创建 Vue 实例,缺少了 Vue 实例间的父子对应关系。维护这一关系需要解决两个问题:父元素绑定、properties 传递。
父元素绑定
在 patch 过程中,Vue 创建子组件时会传递以下三个参数:
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 时传入:
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 兼容了这个不一致。
render 函数精简
render 函数目前我们不能完全删除,因为还需要以下两个功能:依赖收集、复杂表达式和filter 计算。
依赖收集
Vue 在初始化时会对实例上的 data 进行响应式处理,设置 set 和 get 方法。组件执行 render 函数时,会读取变量触发 get 方法,从而在 get 方法中将当前实例收集为这个数据的依赖。下次数据更新时 Vue 会通知依赖进行更新。
为了收集依赖,我们需要在 render 函数中读取一遍数据。这里我们将 VNode 树编译为数组树的形式,只留下数据,剩下的内容都可以删除。
比如这样的一个 template:
<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 函数是这样的:
// 修改前的 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 函数是这样的:
// 修改后的 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:
<div :prop="someMethod(data)"></div>
或者执行一个 filter
<div :prop="someMethod | someFilter"></div>
Vue 中这部分的计算是在 render 中随着 VNode 的构建执行的,计算结果存储在了 VNode 节点中。现在我们没有 VNode 了,计算出的值怎么办呢?
- 计算复杂表达式和 filter 的过程还在 render 过程中保留。
- 计算出的值使用
_ff
方法包裹。每个计算值产生一个唯一的 id,_ff
方法将这些值按照 id 存储下来 setData 给小程序,小程序直接使用这些计算结果来进行渲染。
patch 过程
patch 过程已经完全不需要了,我们将这一过程完全删除。
顺带解决的一个坑
在之前的方案中,从 Page 开始创建的小程序组件实例树,与 Vue 组件实例树相互独立。为了让小程序组件实例与 Vue 组件实例之间能够对应(否则无法在组件层面 setData),我们需要对每个组件实例进行标记,通过标记来寻找对应关系。这在一些特殊情景下是会有问题的,例如组件快速生成又销毁等,可能出现实例间不匹配的问题。
修改后的方案 Vue 实例以组件级别创建,因此不再会出现实例无法匹配的情况。
结果和总结
去 VNode 优化后,使用线上业务验证,渲染时间减少了 16%。此外,由于精简了 Vue 的部分功能,框架整体的体积也减少了 11%。