背景引入
Vue3 正式发布已经有一段时间了,有些项目已经开始使用vue3,vue3在模板编译方面与vue2有了很大的改进,那么到底都做了哪些优化呢?这些优化到底提升了什么?这些优化怎么做的?带着这些疑问开始这篇文章的探索。
编译叙述
目录介绍
Vue 的编译模块包含 4 个目录:
compiler-core
compiler-dom // 浏览器
compiler-sfc // 单文件组件
compiler-ssr // 服务端渲染
其中 compiler-core 模块是 Vue 编译的核心模块,并且是平台无关的。而剩下的三个都是在 compiler-core 的基础上针对不同的平台作了适配处理
编译过程
针对下面相同的一段模板代码,我们对比下vue2与vue3编译后到底有什么不同
<div name="test">
<!-- 这是注释 -->
<p>{{ test }}</p>一个文本节点
<div>good job</div>
</div>
vue2编译后
import {
createCommentVNode as _createCommentVNode,
toDisplayString as _toDisplayString,
createElementVNode as _createElementVNode,
createTextVNode as _createTextVNode,
openBlock as _openBlock,
createElementBlock as _createElementBlock
} from "vue"
export function render(_ctx, _cache, $props, $setup, $data, $options) {
return (_openBlock(), _createElementBlock("div", {
name: "test"
}, [_createCommentVNode(" 这是注释 "), _createElementVNode("p", null, _toDisplayString(_ctx.test), 1 /* TEXT */ ),
_createTextVNode(" 一个文本节点 "), _createElementVNode("div", null, "good job")]))
} // Check the console for the AST
vue3编译后:
import {
createCommentVNode as _createCommentVNode,
toDisplayString as _toDisplayString,
createElementVNode as _createElementVNode,
createTextVNode as _createTextVNode,
openBlock as _openBlock,
createElementBlock as _createElementBlock
} from "vue"
const _hoisted_1 = {
name: "test"
}
const _hoisted_2 = /*#__PURE__*/ _createTextVNode(" 一个文本节点 ") const _hoisted_3 = /*#__PURE__*/ _createElementVNode(
"div", null, "good job", -1 /* HOISTED */ ) export function render(_ctx, _cache, $props, $setup, $data,
$options) {
return (_openBlock(), _createElementBlock("div", _hoisted_1, [_createCommentVNode(" 这是注释 "),
_createElementVNode("p", null, _toDisplayString(_ctx.test), 1 /* TEXT */ ), _hoisted_2,
_hoisted_3]))
} // Check the console for the AST
从编译后的内容上看两个有了一些区别,但整个编译过程还可以理解为分为三个阶段:Parsing(解析)、 Transformation(转换)、Code Generation(代码生成)。
编译入口
Vue3 的同学肯定知道 Vue3 引入了新的组合 Api,在组件 mount 阶段会调用 setup 方法,之后会判断 render 方法是否存在,如果不存在会调用 compile 方法将 template 转化为 render。
// packages/runtime-core/src/renderer.ts
const mountComponent = (initialVNode, container) => {
const instance = (
initialVNode.component = createComponentInstance(
// ...params
)
)
// 调用 setup
setupComponent(instance)
}
// packages/runtime-core/src/component.ts
let compile
export function registerRuntimeCompiler(_compile) {
compile = _compile
}
export function setupComponent(instance) {
const Component = instance.type
const {
setup
} = Component
if (setup) {
// ...调用 setup
}
if (compile && Component.template && !Component.render) {
// 如果没有 render 方法
// 调用 compile 将 template 转为 render 方法
Component.render = compile(Component.template, {
...
})
}
}
这部分都是 runtime-core 中的代码,之前的文章有讲过 Vue 分为完整版和 runtime 版本。如果使用 vue-loader 处理 .vue 文件,一般都会将 .vue 文件中的 template 直接处理成 render 方法。
// 需要编译器
Vue.createApp({
template: ' {
{
hi
}
}
'
})
// 不需要
Vue.createApp({
render() {
return Vue.h('div', {}, this.hi)
}
})
完整版与 runtime 版的差异就是,完整版会引入 compile 方法,如果是 vue-cli 生成的项目就会抹去这部分代码,将 compile 过程都放到打包的阶段,以此优化性能。runtime-dom 中提供了 registerRuntimeCompiler 方法用于注入 compile 方法。
compiler三个阶段
compiler-parse
Vue 在解析模板字符串时,可分为两种情况:以 < 开头的字符串和不以 < 开头的字符串。
不以 < 开头的字符串有两种情况:它是文本节点或 {{ exp }} 插值表达式。
而以 < 开头的字符串又分为以下几种情况:
- 元素开始标签
- 元素结束标签
- 注释节点
- 文档声明
用伪代码表示,大概过程如下:
e(s.length) {
if (startsWith(s, '{{')) {
// 如果以 '{{' 开头
node = parseInterpolation(context, mode)
} else if (s[0] === '<') {
// 以 < 标签开头
if (s[1] === '!') {
if (startsWith(s, '<!--')) {
// 注释
node = parseComment(context)
} else if (startsWith(s, '<!DOCTYPE')) {
// 文档声明,当成注释处理
node = parseBogusComment(context)
}
} else if (s[1] === '/') {
// 结束标签
parseTag(context, TagType.End, parent)
} else if (/[a-z]/i.test(s[1])) {
// 开始标签
node = parseElement(context, ancestors)
}
} else {
// 普通文本节点
node = parseText(context, mode)
}
}
在源码中对应的几个函数分别是:
parseChildren(),主入口。parseInterpolation(),解析双花插值表达式。parseComment(),解析注释。parseBogusComment(),解析文档声明。parseTag(),解析标签。parseElement(),解析元素节点,它会在内部执行parseTag()。parseText(),解析普通文本。parseAttribute(),解析属性。
每解析完一个标签、文本、注释等节点时,Vue 就会生成对应的 AST 节点,并且 会把已经解析完的字符串给截断 。
对字符串进行截断使用的是 advanceBy(context, numberOfCharacters) 函数,context 是字符串的上下文对象,numberOfCharacters 是要截断的字符数。
我们用一个简单的例子来模拟一下截断操作:
<div name="test">
<p></p>
</div>
1、 首先解析 <div ,然后执行 advanceBy(context, 4) 进行截断操作(内部执行的是 s = s.slice(4) ),变成:
name="test"> <p></p></div>
再解析属性,并截断,变成:
<p></p></div>
></p></div>
模板字符串假设为 s,第一个字符 s[0] 是 < 开头,那说明它只能是刚才所说的四种情况之一。 这时需要再看一下 s[1] 的字符是什么:
- 如果是
!,则调用字符串原生方法startsWith()看看是以'<!--'开头还是以'<!DOCTYPE'开头。虽然这两者对应的处理函数不一样,但它们最终都是解析为注释节点。 - 如果是
/,则按结束标签处理。 - 如果不是
/,则按开始标签处理。
从我们的示例来看,这是一个 <div> 开始标签。
这里还有一点要提一下,Vue 会用一个栈 stack 来保存解析到的元素标签。当它遇到开始标签时,会将这个标签推入栈,遇到结束标签时,将刚才的标签弹出栈。它的作用是保存当前已经解析了,但还没解析完的元素标签。这个栈还有另一个作用,在解析到某个字节点时,通过 stack[stack.length - 1] 可以获取它的父元素。
主要流程方法有:
parseChildren() // 主入口
parseInterpolation() //解析插值表达式
parseComment() //解析注释
parseBogusComment() //解析文档声明
parseTag() //解析标签
parseText() //解析普通文本
parseAttribute() //解析属性
compiler-transform
转换前:
转换后:
vdom存在的问题
更新最小粒度为组件,虽然 Vue 能够保证触发更新的组件最小化,但在单个组件内部依然需要遍历该组件的整个 vdom 树。
问题是:传统 vdom 的性能跟模版大小正相关,跟动态节点的数量无关。在一些组件整个模版内只有少量动态节点的情况下,静态无关的节点仍要对比,浪费性能
理想情况:
vue3中patchFlag使用
transform 在对 AST 节点进行转换时,会打上 patchflag 参数,这个参数主要用于 diff 比较过程。当 DOM 节点有这个标志并且大于 0,就代表要更新,没有就跳过。
patchflag 的取值范围:
enum PatchFlags {
// 动态文本节点
TEXT = 1,
// 动态 class
CLASS = 1 << 1, // 2
// 动态 style
STYLE = 1 << 2, // 4
// 动态属性,但不包含类名和样式
// 如果是组件,则可以包含类名和样式
PROPS = 1 << 3, // 8
// 具有动态 key 属性,当 key 改变时,需要进行完整的 diff 比较。
FULL_PROPS = 1 << 4, // 16
// 带有监听事件的节点
HYDRATE_EVENTS = 1 << 5, // 32
// 一个不会改变子节点顺序的 fragment
STABLE_FRAGMENT = 1 << 6, // 64
// 带有 key 属性的 fragment 或部分子字节有 key
KEYED_FRAGMENT = 1 << 7, // 128
// 子节点没有 key 的 fragment
UNKEYED_FRAGMENT = 1 << 8, // 256
// 一个节点只会进行非 props 比较
NEED_PATCH = 1 << 9, // 512
// 动态 slot
DYNAMIC_SLOTS = 1 << 10, // 1024
// 静态节点
HOISTED = -1,
// 指示在 diff 过程应该要退出优化模式
BAIL = -2
}
从上述代码可以看出 patchflag 使用一个 11 位的位图来表示不同的值,每个值都有不同的含义。Vue 在 diff 过程会根据不同的 patchflag 使用不同的 patch 方法。
transform 后的 AST:
codegenNode、helpers 和 hoists 已经被填充上了相应的值。codegenNode 是生成代码要用到的数据,hoists 存储的是静态节点,helpers 存储的是创建 VNode 的函数名称(其实是 Symbol)
靶向更新:
靶向更新 — 哪些节点是动态节点,以及为什么它是动态的(是绑定了动态的class?还是绑定了动态的style?亦或是其它动态的属性?)
hoists(静态提升)
hoistStatic 是一个标识符,表示要不要开启静态节点提升。如果值为 true,静态节点将被提升到 render() 函数外面生成,并被命名为 _hoisted_x 变量。
例如 一个文本节点 生成的代码为 const _hoisted_2 = /*#__PURE__*/_createTextVNode(" 一个文本节点 ") 。
- 静态提升以节点树为单位提升
- 动态子节点的静态属性可提升
- 源码:hoistStatic (transform.ts—transform())
相关方法:
- transformElement() //负责节点转换
- transformExpression() //节点中表达式的转化
- transformText() //负责节点文本转换
- buildProps() //解析属性设置patchFlag
- hoistStatic() // 静态节点提升
compiler—generate
经过前面对AST进行了优化之后,需要将整个AST变成一个可执行的代码块,也就是render函数。于是模板编译器使用了generate对AST进行了代码生成
generate函数很简单,首先传入options实例化一个CodegenState用于接下来的代码生成,然后调用genElement生成代码块。
export function generate(
ast: ASTElement | void,
options: CompilerOptions
): CodegenResult {
const state = new CodegenState(options);
const code = ast ? genElement(ast, state) : '_c("div")';
return {
render: `with(this){return ${code}}`,
staticRenderFns: state.staticRenderFns,
};
}
generate() //代码生成入口
genModule / genFunction (基于不同mode)
genHoists() //生成静态节点提升代码
genNode() //具体节点代码生成逻辑
相关流程
generate主要就是将AST模型转成render表达式,其中采用了大量的递归调用,主要的流程如下:
优化策略
1:静态节点提升
静态不变的节点,直接把它创建了,放在渲染函数的外部,直接使用,不需要频繁创建了;
2补丁标记和动态属性记录
添加动态补丁
hello world
对title做动态变化的标记,等之后做diff对比的时候,只做当前的动态值做比对,减少递归遍历的过程。3缓存事件处理程序
对事件进行缓存,尝试从缓存中获取,render函数执行的时候,不会再重新创建;
4块block
5、 v-once指令: 缓存全部或部分虚拟节点,能够避免组件更新时重新创建虚拟DOM带来的性能开销, 也可以避免无用的Diff操作
补充说明
vue3知识图谱: www.processon.com/view/link/5…
vue3快了吗: github.com/shengxinjin…