# 模板编译与优化

Vue.js 模板编译器的目标代码其实就是渲染函数。Vue.js 模板编译器会首先对模板进行词法分析和语法分析,得到模板 AST。接着,将模板 AST 转换(transform)成 JavaScript AST。最后,根据 JavaScriptAST 生成 JavaScript 代码,即渲染函数代码。

vue编译器

# 编译器工作流程

  • parser: 用来将模板字符串解析为模板 AST 的解析器。
  • transformer: 用来将模板 AST 转换为 JavaScript AST 的转换器。
  • generator: 用来根据 JavaScript AST 生成渲染函数代码的生成器。

# parser

模板解析:将其解析为模板 AST。

  1. 词法分析(Lexical Analysis)
    • 目的:将模板字符串分解成一系列的 Token。
    • 实现:使用一个基于有限状态机(Finite State Machine, FSM)的词法分析器。状态机根据当前的状态和输入字符决定下一个状态,并在状态迁移的过程中生成 Token。例如,当状态机处于文本状态时,遇到 < 字符可能会切换到标签状态。词法分析的过程就是状态机在不同状态之间迁移的过程。在此过程中,状态机会产生一个个Token,形成一个 Token 列表。我们将使用该 Token 列表来构造用于描述模板的 AST。
    • 输出:一个 Token 列表,其中包含了模板中的所有元素、属性、指令、文本等信息。
  2. 语法分析(Parsing)
    • 目的:将 Token 列表解析成抽象语法树(Abstract Syntax Tree, AST)。
    • 实现:通过递归下降的方式构建 AST。在解析过程中,维护一个标签栈来追踪当前的上下文。每当遇到一个开始标签时,解析器创建一个新的节点并将其压入栈顶;遇到结束标签时,则从栈中弹出相应的节点。栈顶的节点始终作为下一个 Token 的父节点。扫描 Token 列表并维护一个开始标签栈。每当扫描到一个开始标签节点,就将其压入栈顶。栈顶的节点始终作为下一个扫描的节点的父节点。这样,当所有 Token 扫描完毕后,即可构建出一棵树型 AST
    • 输出:一棵描述了模板结构的 AST。

# transformer

AST 转换:将模板 AST 转换为用于描述渲染函数的 JavaScript AST

  • 目的:对 AST 进行优化或转换,以提高渲染性能。
  • 实现:
    • 使用深度优先遍历(Depth-First Search, DFS)算法来遍历 AST。
    • 采用插件化的设计模式,允许开发者通过 context.nodeTransforms 注册自定义的转换函数。这些函数可以在遍历 AST 时对节点进行修改、删除或增加。
    • 转换函数分为“进入阶段”(enter)和“退出阶段”(exit),确保子节点先于父节点被处理,这有助于实现复杂的转换逻辑。
    • 上下文对象(context)中包含了当前访问的节点、父节点、节点的位置索引等信息,便于在转换过程中引用。
  • 输出:优化后的 AST,更适合生成高效的渲染函数。

# generator

代码生成(Code Generation):据 JavaScript AST 生成渲染函数代码

  • 目的:将 AST 转换为渲染函数的 JavaScript 代码。
  • 实现:
    • 首先将模板 AST 转换为 JavaScript AST,这个过程涉及到了对节点类型的映射。
    • 对于不同的 AST 节点类型,定义了对应的代码生成函数。这些函数负责生成特定的 JavaScript 代码片段。
    • 为了增强生成代码的可读性和可维护性,在代码生成过程中加入了适当的缩进和换行处理。这些处理逻辑被封装为工具函数,并集成到了代码生成的上下文对象中。
  • 输出:最终的渲染函数代码,该代码可以直接被浏览器解析执行,用于组件的渲染。

进一步可参考 Vue 模板编译与渲染

# 编译优化

实际上,模板的结构非常稳定。通过编译手段,我们可以分析出很多关键信息,例如哪些节点是静态的,哪些节点是动态的。结合这些关键信息,编译器可以直接生成原生 DOM 操作的代码,这样甚至能够抛掉虚拟 DOM,从而避免虚拟 DOM带来的性能开销。但是,考虑到渲染函数的灵活性,以及Vue.js 2 的兼容问题,Vue.js 3 最终还是选择了保留虚拟DOM。这样一来,就必然要面临它所带来的额外性能开销。

那么,为什么虚拟 DOM 会产生额外的性能开销呢?根本原因在于,渲染器在运行时得不到足够的信息。传统 Diff 算法无法利用编译时提取到的任何关键信息,这导致渲染器在运行时不可能去做相关的优化。而 Vue.js 3 的编译器会将编译时得到 关键信息“附着”在它生成的虚拟 DOM 上,这些信息会通过虚拟 DOM 传递给渲染器。最终,渲染器会根据这些关键信息执行“快捷路径”​,从而提升运行时的性能。

# 补丁标记

只要运行时能够区分动态内容和静态内容,即可实现极致的优化策略

  • patchFlag:给虚拟节点加一个额外的属性,即 patchFlag,它的值是一个数字。只要虚拟节点存在该属性,我们就认为它是一个动态节点。这里的 patchFlag 属性就是所谓的补丁标志

  • dynamicChildren:在虚拟节点的创建阶段,把它的动态子节点提取出来,并将其存储到该虚拟节点的 dynamicChildren 数组。把带有该属性的虚拟节点称为“块”​,即 Block。

有了 Block 这个概念之后,渲染器的更新操作将会以 Block 为维度。也就是说,当渲染器在更新一个 Block 时,会忽略虚拟节点的 children 数组,而是直接找到该虚拟节点的dynamicChildren 数组,并只更新该数组中的动态节点。这样,在更新时就实现了跳过静态内容,只更新动态内容。同时,由于动态节点中存在对应的补丁标志,所以在更新动态节点的时候,也能够做到靶向更新。

# 静态提升

它能够减少更新时创建虚拟 DOM 带来的性能开销和内存占用,即把纯静态的节点提升到渲染函数之外,避免不必要的重新创建。

当把纯静态的节点提升到渲染函数之外后,在渲染函数内只会持有对静态节点的引用。当响应式数据变化,并使得渲染函数重新执行时,并不会重新创建静态的虚拟节点,从而避免了额外的性能开销。需要强调的是,静态提升是以树为单位的。

# 预字符串化

基于静态提升,我们还可以进一步采用预字符串化的优化手段。预字符串化是基于静态提升的一种优化策略。静态提升的虚拟节点或虚拟节点树本身是静态的,那么,能否将其预字符串化呢?当采用了静态提升优化策略时,预字符串化能够将这些静态节点序列化为字符串,并生成一个Static 类型的 VNod。

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

# 缓存内联事件 与 v-once

缓存内联事件处理函数可以避免不必要的更新。

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

// 优化后,缓存内联函数
function render(ctx, cache) {
  return h(Comp, {
    // 将内联事件处理函数缓存到 cache 数组中
    onChange: cache[0] || (cache[0] = ($event) => (ctx.a + ctx.b))
  });
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

优化前,每次重新渲染时(即 render 函数重新执行时)​,都会为 Comp 组件创建一个全新的 props 对象。同时,props 对象中 onChange 属性的值也会是全新的函数。这会导致渲染器对 Comp 组件进行更新,造成额外的性能开销。

优化后,渲染函数的第二个参数是一个数组 cache,该数组来自组件实例,我们可以把内联事件处理函数添加到 cache 数组中。这样,当渲染函数重新执行并创建新的虚拟 DOM 树时,会优先读取缓存中的事件处理函数。这样,无论执行多少次渲染函数,props 对象中 onChange 属性的值始终不变,于是就不会触发 Comp 组件更新了。

Vue.js 3 不仅会缓存内联事件处理函数,配合 v-once 还可实现对虚拟 DOM 的缓存。Vue.js 2 也支持 v-once 指令,当编译器遇到 v-once 指令时,会利用 cache 数组来缓存渲染函数的全部或者部分执行结果。实际上,v-once 指令能够从两个方面提升性能。

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

# 总结

Vue.js 3 的编译器会充分分析模板,提取关键信息并将其附着到对应的虚拟节点上。在运行时阶段,渲染器通过这些关键信息执行“快捷路径”​,从而提升性能。编译优化的核心在于,区分动态节点与静态节点。Vue.js 3 会为动态节点打上补丁标志,即 patchFlag。

同时,Vue.js 3 还提出了 Block 的概念,一个 Block 本质上也是一个虚拟节点,但与普通虚拟节点相比,会多出一个 dynamicChildren 数组。该数组用来收集所有动态子代节点,这利用了 createVNode 函数和 createBlock 函数的层层嵌套调用的特点,即以“由内向外”的方式执行。再配合一个用来临时存储动态节点的节点栈,即可完成动态子代节点的收集。由于 Block 会收集所有动态子代节点,所以对动态节点的比对操作是忽略 DOM 层级结构的。这会带来额外的问题,即 v-if、v-for 等结构化指令会影响 DOM 层级结构,使之不稳定。这会间接导致基于 Block 树的比对算法失效。而解决方式很简单,只需要让带有 v-if、v-for 等指令的节点也作为 Block 角色即可。

除了 Block 树以及补丁标志之外,Vue.js 3 在编译优化方面还做了其他努力:

  • 静态提升:能够减少更新时创建虚拟 DOM 带来的性能开销和内存占用。
  • 预字符串化:在静态提升的基础上,对静态节点进行字符串化。这样做能够减少创建虚拟节点产生的性能开销以及内存占用。- 缓存内联事件处理函数:避免造成不必要的组件更新。
  • v-once 指令:缓存全部或部分虚拟节点,能够避免组件更新时重新创建虚拟 DOM 带来的性能开销,也可以避免无用的Diff 操作。