携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第1天,点击查看活动详情
前言
在之前的文章中,分析了 Vue 2.x 版本的编译三部曲:parse
、optimize
、generate
我们再整体来回顾一下 Vue 2.x 版本的编译三部曲:
三部曲第一步parse
: 将开发者写的 template
模板字符串转换成抽象语法树 AST
,AST 就这里来说就是一个树状结构的 JavaScript 对象,描述了这个模板,这个对象包含了每一个元素的上下文关系。
三部曲第二步optimize
: 深度遍历parse
流程生成的 AST 树,去检测它的每一颗子树是不是静态节点,如果是静态节点表示生成的 DOM 永远不需要改变,这对运行时对模板的更新起到极大的优化作用,提升了运行效率。
三部曲第三步generate
: 把优化后的 AST 树转换成可执行的代码,即生成 render code
。 为后续 vnode
生成提供基础。
虽然 Vue 2.x 版本已经足够优秀,但是还存在一些瑕疵,所以在 Vue 3.x 版本进行了一些相关的优化。
Vue 3 与 Vue 2 相比,在 bundle 包大小方面(tree-shaking 减少了 41% 的体积),初始渲染速度方面(快了 55%),更新速度方面(快了 133%)以及内存占用方面(减少了 54%)都有着显著的性能提升。
这一系列的提升,在于 Vue 3.x 在三个大的方向进行了优化:
- 源码体积优化
- 数据劫持优化
- 编译优化
那我们今天就来重点聊聊 Vue 3.x 的编译优化。
编译优化
我们知道,在 Vue 中通过通过数据劫持和依赖收集来进行重渲染,Vue.js 2.x 的数据更新并触发重新渲染的粒度是组件级的:
这里举一个例子说明:
<template>
<div id="content">
<p class="text">1</p>
<p class="text">2</p>
<p class="text">3</p>
<p class="text">{{message}}</p>
<p class="text">4</p>
<p class="text">5</p>
<p class="text">6</p>
</div>
</template>
大家看上面这段模板,虽然只有一个动态节点
,message
。但在如果 message
值发生改变,对于单个组件来说,内部需要遍历该组件的整个 vnode 树
,你没有听错,Vue 2.x 内部需要遍历整个 vnode
树。
但是有很多节点其实本身并不需要进行 diff 和遍历,这间接导致了导致性能跟模版大小正相关
,跟动态节点的数量无关,当一些组件的整个模版内只有少量动态节点时,这些遍历都是性能的浪费。
如果你还在用 Vue 2.x ,这就告诉我们尽量把模板组件化,从而来降低更新粒度的大小提升性能。
在 Vue.js 3.x ,通过编译阶段对静态模板的分析
,编译生成了 Block tree
。Block tree
是一个将模版基于动态节点指令切割
的嵌套区块,每个区块内部的节点结构是固定的,而且每个区块只需要以一个 Array 来追踪自身包含的动态节点。
借助 Block tree
,Vue.js 将 vnode 更新性能由与模版整体大小相关提升为与动态内容的数量相关,这是一个非常大的性能突破。
除此之外,Vue.js 3.0 在编译阶段还包含了对 Slot 的编译优化、事件侦听函数的缓存优化,并且在运行时重写了 diff 算法。
那在 Vue 3.x 是如果在编译节点对静态模板进行分析
并且生成 Block tree
的?
又是如何将模版基于动态节点指令进行切割
?
又是如何只靠一个 Array 就能追踪自身包含的动态节点?
带着这些问题,正式开启今天的探索。
编译之前
我们知道,不管是 Vue 3.x 还是 Vue 2.x 其实本身都是存在服务器编译
和 web 编译
,但是本文我只会分享关于 web 编译
,并不会分析服务器编译
请注意。
先看看编译入口,Vue 3.x 的编译入口在 compile$1
中,函数接受两个参数:
- template:被编译模板字符串
- options:编译配置
// TODO: 编译入口
function compile$1(template, options = {}) {
return baseCompile(template, extend({}, parserOptions, options, {...}));
}
compile$1
函数内部调用baseCompile
函数,通过baseCompile
函数来完成编译工作。
function baseCompile(template, options = {}) {
...
// TODO: 解析 template 生成 AST
const ast = isString(template) ? baseParse(template, options) : template;
...
// TODO: AST 转换
transform(ast, extend({}, options, {
prefixIdentifiers,
nodeTransforms: [
...nodeTransforms,
...(options.nodeTransforms || []) // user transforms
],
directiveTransforms: extend({}, directiveTransforms, options.directiveTransforms || {} // user transforms
)
}));
// TODO: 生成代码
return generate(ast, extend({}, options, {
prefixIdentifiers
}));
}
baseCompile
函数做三件事:
- 模板字符串 template 的解析,将 template 解析成 AST
- AST 转换
- 代码生成
baseCompile
是整个底层核心编译系统的入口,虽然内部逻辑相对比较复杂,但是功能很明确,就是将输入的模板编译成运行时产物render code
。在通过执行render code
生成vnode
。
这里其实大家有没有一个疑惑,为什么 Vue 不直接将 template 转换为 vnode?而是先生成 render code 函数
在通过 render code 函数
来生成 vnode
。
其实原因很简单,原因在于 Vue 中当状态发生改变之后,需要重渲染视图,而 vnode 是无法获取到最新的状态。所以需要一个运行时的执行器,来保证重渲染视图时,vnode 每次能拿到最新的状态。而 render code 函数
本质上是一个可以执行的函数,能满足动态性,获取到最新的状态。
这里用一段模板来举个例子:
<div>
<!-- 这是一段注释 -->
{{msg}}
<div>hello, {{msg}}.</div>
this is text.
</div>
在 Vue 2.x 版本会生成这样一段 render code。
with (this) {
return _c('div', [
_e(' 这是一段注释 '),
_v(' \n ' + _s(msg) + '\n '),
_c('div', [_v('hello, ' + _s(msg) + '.')]),
_v(' \n this is text.\n '),
]);
}
在 Vue 3.x 版本生成的是这样一段 render code。
const _Vue = Vue
const { createElementVNode: _createElementVNode, createCommentVNode: _createCommentVNode, createTextVNode: _createTextVNode } = _Vue
const _hoisted_1 = /*#__PURE__*/_createTextVNode(" this is text. ")
return function render(_ctx, _cache) {
with (_ctx) {
const { createCommentVNode: _createCommentVNode, toDisplayString: _toDisplayString, createElementVNode: _createElementVNode, createTextVNode: _createTextVNode, openBlock: _openBlock, createElementBlock: _createElementBlock } = _Vue
return (_openBlock(), _createElementBlock("div", null, [
_createCommentVNode(" 这是一段注释 "),
_createTextVNode(_toDisplayString(msg) + " ", 1 /* TEXT */),
_createElementVNode("div", null, "hello, " + _toDisplayString(msg) + ".", 1 /* TEXT */),
_hoisted_1
]))
}
}
虽然看起来有很多差异,但是其实本质是一样的,生成的渲染函数字符串都是用with
包裹的字符串。with
的好处有两个:
- with 作用域和模板的作用域正好契合
- 编译生成
with(xxx)
可以在某种程度上实现对于作用域的动态注入
通过这两个好处,可以实现上文说的动态性。with 作用域和模板作用域契合 + 动态注入,模板中状态的变化可以直接映射到 render code
中,在通过每次 render code 执行生成新的 vnode ,通过前后 vnode 的对比,实现动态更新。
好,回到主题,接着往下。
解析 template 生成 AST
不管是 Vue 2 还是 Vue 3 parse 阶段就是对 template 做解析,生成 AST 抽象语法树。只是在一些细节存在差异。
Vue 2.0 AST VS Vue 3.0 AST
用一段简单的模板来看看在 Vue 2 和 Vue 3 生成的 AST 有什么不一样,模板如下:
<div>
<!-- 这是一段注释 -->
{{msg}}
<div>hello, {{msg}}.</div>
this is text.
</div>
Vue 2.x 生成的 AST
Vue 3.x 生成的 AST
我们发现尽管 Vue 2.x 和 Vue 3.x 版本在 AST 描述上有一些差异,但是 Vue 2.x 版本生成的 AST 还是 Vue 3.x 版本生成的 AST 本质上都是一样的, 都是一棵层级嵌套的 template 描述对象。AST 中的节点是可以完整地描述它在模板中映射的节点信息。
这里有一个注意点,Vue 3.x 版本的 AST 对象根节点其实是一个虚拟节点,它并不会映射到一个具体节点,另外它还包含了其他的一些属性,这些属性在后续的 AST 转换的过程中会赋值,并在生成代码阶段用到。
这个虚拟节点是干什么用的了?为什么要设计一个虚拟节点呢?
因为 Vue.js 3.x 和 Vue.js 2.x 有一个很大的不同,Vue.js 3.x 版本支持 Fragment 语法,即组件可以有多个根节点,比如:
<p>无用节点测试</p>
<div>
<!-- 这是一段注释 -->
{{msg}}
<div>hello, {{msg}}.</div>
this is text.
</div>
这种写法在 Vue.js 2.x 中会报错,提示模板只能有一个根节点。
而 Vue.js 3.0 允许了这种写法。但是对于 AST 来说必须有一个根节点,所以虚拟节点在这种场景下就非常有用了,它可以作为 AST 的根节点。
那么接下来我们看一下如何根据模板字符串来构建这个 AST 对象吧。
baseParse
解析 template 的入口就是 baseParse
函数。
我们来看看 baseParse
函数做了什么?
const ast = isString(template) ? baseParse(template, options) : template;
function baseParse(content, options = {}) {
// 创建解析上下文
const context = createParserContext(content, options);
const start = getCursor(context);
// 解析 template,并创建 AST
return createRoot(parseChildren(context, 0 /* DATA */, []), getSelection(context, start));
}
从实现来看,主要做三件事:
createParserContext:创建解析上下文
通过解析上下文,创建一个保存初始化信息的对象:
- options:解析相关的配置
- column:当前代码的列号
- line:当前代码的行号
- offset:当前代码相对原始代码的偏移量
- originalSource:表示最初的原始代码
- source:当前代码
- inPre:代码是否在 pre 标签内
- inVPre:代码是否在 v-pre 指令下
- onWarn:warn 函数
function createParserContext(content, rawOptions) {
const options = extend({}, defaultParserOptions);
let key;
for (key in rawOptions) {
// @ts-ignore
options[key] =
rawOptions[key] === undefined
? defaultParserOptions[key]
: rawOptions[key];
}
return {
// 解析相关的配置
options,
// 当前代码的列号
column: 1,
// 当前代码的行号
line: 1,
// 当前代码相对原始代码的偏移量
offset: 0,
// 表示最初的原始代码
originalSource: content,
// 当前代码
source: content,
// 代码是否在 pre 标签内
inPre: false,
// 代码是否在 v-pre 指令下
inVPre: false,
// warn 函数
onWarn: options.onWarn
};
}
上下文的作用就是在后续的解析过程中,对上下文的信息进行更新,用来表示当前解析的状态。
创建好上下文之后,就开始解析节点。
parseChildren:解析节点
parseChildren 函数会将 template 字符串进行逐一解析,将解析出来的 node
放入到一个数组nodes
中。然后会遍历生成的nodes
中每一个节点的信息对空白符进行处理。处理空白符的一个目的是提升编译效率。
function parseChildren(context, mode, ancestors) {
...
const nodes = [];
while (!isEnd(context, mode, ancestors)) {
...
}
...
let removedWhitespace = false;
if (mode !== 2 /* RAWTEXT */ && mode !== 1 /* RCDATA */) {
...
for (let i = 0; i < nodes.length; i++) {
...
}
}
return removedWhitespace ? nodes.filter(Boolean) : nodes;
}
createRoot:创建根节点
节点解析完成之后,baseParse 过程就剩之后一步,创建根节点。创建根节点的目的就是添加一个虚拟节点,原因在上文中有讲到,这里就不多赘述了。
function createRoot(children, loc = locStub) {
return {
type: 0 /* ROOT */,
children,
helpers: [],
components: [],
directives: [],
hoists: [],
imports: [],
cached: 0,
temps: 0,
codegenNode: undefined,
loc
};
}
唯一需要注意的就是根节点的 type 类型是 0 。在 Vue 2.x 中,节点类型就三种:
type = 1
的基础元素节点
type = 2
含有expression
和tokens
的文本节点
type = 3
的纯文本节点
或者是注释节点
在 Vue 3.x 版本,将节点类型分的比较细,type 类型也不止三种了。在后面会提到。
小结
由于编译这部分内容比较多,所以今天内容就先到这。下一篇文章,我们分析 Vue 3.x 中 template 生成 AST 的背后实现原理。Vue 3.x 是如果做 template 的解析?又和 Vue 2.x 中 template 生成 AST 有什么不同?带着疑问期待下一篇文章。