Vuejs设计与实现 —— 编译层面的优化

3,093 阅读6分钟

编译优化

是什么?

编译优化 指的是编译器将 模板(template) 编译为 渲染函数(render) 的过程中,尽可能的 提取关键信息,以达到 生成最优代码 的过程。

为什么需要?

传统的 Diff 算法会存在很多无意义的对比操作

在对比 新旧 两颗 虚拟 DOM 时,总是要按照 虚拟 DOM层级结构 "一层一层" 进行遍历,然后其中某些内容的遍历对比是完全没必要的,例如:

<div id="foo">
    <p class="bar">{{ text }}</p>
</div>

其中唯一可能变化的就是 <p> 标签中的 text 值,当响应式数据 text 发生修改时,最高效的更新方式就是直接更新 <p> 标签对应的文本内容,然而对于 传统 Diff 算法 而言,会先根据 render 函数生成 新的虚拟 DOM,然后再对比 新旧虚拟 DOM 的方式:

  • 对比 div 节点,以及该节点的属性和子节点
  • 对比 p 节点,以及该节点的属性和子节点
  • 对比 p 节点的文本节点,发现文本节点发生变化,于是更新文本节点

编译思路

Vue.js3 编译优化的思路来源就是,跳过这些无意义的对操作,进一步的提升 VueDiff 算法的对比性能:

  • 模板的结构相对稳定,在编译阶段尽可能提取关键信息(如:标记静态节点、动态节点)
  • 基于关键信息,通过编译器直接生成对应的原生 DOM 操作代码,减少生成 虚拟 DOM 的性能消耗,有利于提升初始化渲染的速度

实验性的新编译策略

从理论上来看,某些情况下确实并不需要 虚拟 DOM,(即 No Virtual DOM),但在 Vue.js3 中仍然选择保留虚拟 DOM,并承受其带来的性能开销,主要是考虑到 渲染函数的灵活性Vue.js2 的兼容性 问题.

感兴趣可以去了解下,未来 Vue 会提供的一些新特性,不过这并不是本文的核心内容,State of Vue 2022-尤雨溪

Vue3 中的编译优化的方式

标记动态节点

标记动态节点之后,在后续渲染器更新阶段旧可以直接基于动态节点集合,实现对动态节点的 靶向更新定向更新.

patchFlag 属性

在编译器进行编译时,如果判断当前节点是属于 动态节点,就会为这个 vnode 节点打上 patchFlag 标记,也就是添加一个 patchFlag 属性,并且 patchFlag 属性 对应的 数值 代表了当前这个 动态节点的类型,如:

  • 数字 1:代表该节点是 动态textContent
  • 数字 2:代表该节点是 动态calss 绑定
  • 数字 3:代表该节点是 动态style 绑定
  • ...

dynamicChildren 属性

dynamicChildren 属性 值对应的是一个数组,其中保存的就是带有 patchFlag 属性vnode 节点,并且带有 dynamicChildren 属性vnode 节点成称为 块,即 Block.

Block 节点

一个 Block 本质上也是一个 虚拟 DOM 节点,只不过它比普通的虚拟节点多了一个用于 存储动态子节点dynamicChildren 属性.

一个 Block 不仅能够收集它的 直接动态子节点,也能收集所有 动态的子代节点,而后续渲染器的更新操作将以 Block 作为更新维度去处理.

什么样的节点会变成 Block 节点?

  • 所有模板的 根节点
  • 带有 v-if 指令的节点
  • 带有 v-for 指令的节点
  • 模板中 Frament 节点所包裹的 多根节点

其中 v-ifv-for 指令会导致 更新前后模板结构不稳定,不过由于 v-for 指令渲染的是一组子节点,为了更好的表示这一组子节点,就需要使用 Fragment 节点来表达 v-for 指令的渲染结果,并将其作为 Block 节点.

静态提升

静态提升的目的是尽可能减少更新时创建 虚拟 DOM 带来的 性能开销内存占用.

没有静态提升时带来的问题

通常,在响应式数据发生变化时,渲染函数就会重新执行,并产生新的虚拟 DOM 节点,显然纯静态的虚拟节点完全没有必要重新创建,这会带来一定的性能开销.

解决方案

在编译阶段可以 将纯静态节点提升到渲染函数外部,在渲染函数内部保持对静态节点的引用即可,当响应式数据变化引起渲染函数重新执行时,并不会重新创建静态的虚拟节点,这样旧可以避免重复创建静态节点的虚拟 DOM 带来的性能开销.

值得注意的是,静态提升是以树为单位的,毕竟不可能会为每一个小的静态节点进行静态提升,这会导致渲染函数外部对应存储静态节点的变量增多,这也会 占用一定的内存.

预字符串化

基于 静态提升 可以继续采用 预字符串化 的优化手段,即直接将原本需要以树为单位进行静态提升的内容,直接转换为对应基于 DOM 操作的 字符串形式.

预字符串化的优势如下:

  • 大块的静态内容可以直接通过 innerHTML 进行设置,在 初始化渲染 时具有一定的性能优势
  • 减少创建虚拟节点产生开销的性能
  • 减少内存占用

缓存内联事件处理函数

不缓存内联事件函数带来的问题

在模板事件处理函数中,为了一些简单的更新操作,通常会在模板中编写 内联的事件处理函数,例如:

<Comp @change="c = a + b">  ===>  function render(ctx){
                                     return h(Com, {
                                        // 内联事件处理函数
                                        onChange: () => ctx.c = ctx.a + ctx.b
                                     })
                                  }

显然,当 render 函数被重新执行时,都为会 Comp 组件创建一个全新的 props 对象,并且其中的 onChange 事件也是一个全新的函数,这会导致渲染器对 Comp 组件进行更新,造成额外的性能开销。

解决方案

通过为 render 渲染函数传递第二个参数 cache 数组,且这个 cache 数组是来自于组件实例的,因此可以将内联事件处理函数添加到 cache 数组中缓存起来.

当渲染函数重新执行时并创建虚拟 DOM 时,优先从缓存中读取对应的事件处理函数,避免事件处理函数被重新创建,导致 Comp 组件进行无用更新.

v-once 缓存虚拟 DOM

Vue.js2Vue.js3 中都支持 v-once 指令,当前编译器遇到 v-once 指令时,会利用上面提到的 cache 数组来缓存渲染函数的全部或部分执行结果.

v-once 的优势

  • 避免组件更新时重新创建虚拟 DOM 带来的性能开销,因为虚拟 DOM 被缓存了,因此更新时无需重新创建
  • 避免无用的 Diff 开销,这是因为被 v-once 标记的虚拟 DOM 树会被父级 Block 节点收集

完结