# 模板编译与优化
Vue.js 模板编译器的目标代码其实就是渲染函数。Vue.js 模板编译器会首先对模板进行词法分析和语法分析,得到模板 AST。接着,将模板 AST 转换(transform)成 JavaScript AST。最后,根据 JavaScriptAST 生成 JavaScript 代码,即渲染函数代码。
# 编译器工作流程
- parser: 用来将模板字符串解析为模板 AST 的解析器。
- transformer: 用来将模板 AST 转换为 JavaScript AST 的转换器。
- generator: 用来根据 JavaScript AST 生成渲染函数代码的生成器。
# parser
模板解析:将其解析为模板 AST。
- 词法分析(Lexical Analysis)
- 目的:将模板字符串分解成一系列的 Token。
- 实现:使用一个基于有限状态机(Finite State Machine, FSM)的词法分析器。状态机根据当前的状态和输入字符决定下一个状态,并在状态迁移的过程中生成 Token。例如,当状态机处于文本状态时,遇到
<
字符可能会切换到标签状态。词法分析的过程就是状态机在不同状态之间迁移的过程。在此过程中,状态机会产生一个个Token,形成一个 Token 列表。我们将使用该 Token 列表来构造用于描述模板的 AST。 - 输出:一个 Token 列表,其中包含了模板中的所有元素、属性、指令、文本等信息。
- 语法分析(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))
});
}
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 操作。