在 Vue 中创建 HTML,我们可以通过模板、手动书写渲染函数或使用 JSX 这几种方式。其中渲染函数是最原始的方式,模板最终也会被编译为渲染函数 —— 执行渲染函数后,我们可以得到 vnode 用于虚拟 DOM 渲染。
今天就来了解一下 Vue 中较为重要的模板编译原理吧,看看我们平时书写的模板是如何转化为最终的视图的。
一、整体流程
我们平时使用 Vue 模板时,可能是这样的:
<template>
<div class="container">
<h2 v-if="showTitle">{{ isCheck ? '查看' : id ? '编辑' : '新建' }}</h2>
</div>
<template>
<script>
export default {
data() {
return {
isCheck: true,
id: '123',
showTitle: true
}
}
</script>
我们可以直接将变量嵌入到 HTML 中,还能使用 Vue 提供的 v- 指令等等,十分方便,那 Vue 底层到底是怎么编译我们所书写的模板的呢?
先说结论:
模板编译的主要目标就是生成渲染函数,而渲染函数的作用是每次执行它,它就会使用当前最新的状态生成一份新的 vnode,然后使用这个 vnode 进行渲染。
将模板编译成渲染函数可以分两个步骤,先将模板解析成 AST(Abstract Syntax Tree,抽象语法树),然后再使用 AST 生成渲染函数。但是由于静态节点不需要总是重新渲染,所以在生成 AST 之后、生成渲染函数之前这个阶段,可以做一个操作,那就是遍历一遍 AST,给所有静态节点做一个标记,这样在虚拟 DOM 中更新节点时,如果发现节点有这个标记,就不会重新渲染它。
所以,在大体逻辑上,模板编译分三部分内容:
-
将模板解析为 AST
-
遍历 AST 标记静态节点
-
使用 AST 生成渲染函数
这三部分内容在模板编译中分别抽象出三个模块来实现各自的功能,分别是:
-
解析器
-
优化器
-
代码生成器
1. 解析器 —— 将模板解析成 AST
在解析器内部,分成了很多小解析器,其中包括过滤器解析器(解析过滤器)、文本解析器(解析带变量的文本)和 HTML 解析器(核心)。
AST 和 vnode 类似,都是使用 JavaScript 中的对象来表示节点。
2. 优化器 —— 标记静态节点
什么是静态节点?没有使用任何变量,后续不会发生变化的节点。比如:
<p>我是一个静态节点,因为我没用到什么变量。</p>
为什么要标记静态节点?避免做一些无用功,因为静态节点不会随着状态的变化而变化,在虚拟 DOM patch 时可以跳过。
3. 代码生成器——将 AST 转换成“代码字符串”
什么是代码字符串?就是我们要输入到渲染函数中的内容。举个例子:
<h1 @click="c">Hello world!</h1>
这样的模板经过编译之后生成的“代码字符串”大概长这个样子:
`with(this){
return _c(
'h1',
{
on:{'click': c}
},
[-v('Hello world!')]
)
}`
// 这里 _c 表示创建元素类型的vnode
// _v 表示创建文本类型的 vnode
生成的代码字符串将用于渲染函数:
const code = `with(this) {
return _c(
'h1',
{
on:{'click': c}
},
[-v('Hello world!')]
)
}`
const hello = new Function(code)
hello() // 会生成对应的 vnode
二、解析器
前面提到了,解析器的作用是将模板解析为 AST。其实 AST 不是什么神奇的东西,它就是 JavaScript 中的对象而已,对象中的属性保存各个节点的属性数据。
比如这样一个简单的模板:
<div>
<h2>{{ name }}</h2>
</div>
它转换为 AST 之后大概长这样:
{
tag: "div",
type: 1,
parent: undefined,
children: [
{
tag: "h2",
type: 1,
parent: {
tag: "div",
// ...
},
children: [
{
type: 2,
text: "{{name}}",
expression: "_s(name)",
},
],
},
],
}
当然这只是一个例子,真正的 AST 会有更多的属性。
1. 解析器内部运行原理
前面提到,在解析器内部分成了很多小解析器,其中包括过滤器解析器、文本解析器和 HTML 解析器。其中最重要的是 HTML 解析器,这里来看看它是如何工作的。
解析器会从前往后解析,伪代码:
parseHTML(template, {
start(tag, attrs, unary) {
// 解析到标签的开始位置,触发
// 参数:标签名,标签属性,是否自闭合标签
const element = createASTElement(tag, attrs, currentParent);
},
end() {
// 解析到标签的结束位置,触发
},
chars(text) {
// 解析到文本时,触发
const element = { type: 3, text }
},
comment(text) {
// 解析到注释时,触发
const element = { type: 3, text, isComment: true }
},
})
AST 节点是有层级关系的,我们需要一套实现层级关系的逻辑,让每个 AST 节点都知道它的父级是谁。怎么实现呢?我们维护一个栈即可。由于解析器解析是从前往后,所以我们可以在触发 start 时将当前构建的节点推入栈中,触发 end 时,从栈中弹出一个节点。⏏️ 这样就可以记录它们的层级关系了。
以这个简单模板为例:
<div>
<h2>{{ name }}</h2>
</div>
它构建为 AST 的过程大致如下(空格部分忽略):
- 模板的开始位置是 div 的开始标签,于是会触发 start 钩子函数,触发后会构建一个 div 节点,此时栈为空,说明这个 div 节点没有父级节点。将这个 div 节点推入栈中,模板字符串中的
<div>部分被截取掉。 - 此时模板的开始位置是 h2 的开始标签,触发 start 钩子函数,触发后会构建一个 h2 节点,此时栈中最后一个节点是 div 节点,说明 h2 节点的父级节点是 div 节点,将 h2 节点添加至 div 节点的子节点中。将这个 h2 节点推入栈中,模板字符串中的
<h2>部分被截取掉。 - 此时模板的开始位置是一段文本,会触发钩子函数 chars。chars 触发后,会先构建一个文本节点,此时发现栈中的最后一个节点是 h2,这说明文本节点的父节点是 h2,于是将文本节点添加到 h2 节点的子节点中。由于文本节点没有子节点,所以文本节点不会被推入栈中。最后,将文本
{{ name }}从模板中截取掉。 - 此时模板的开始位置是 h2 的结束标签
</h2>,于是会触发钩子函数 end。当 end 触发后,会从栈中弹出一个节点出来,也就是把 h2 节点从栈中弹出来,并将</h2>从模板中截取掉。 - 此时模板的开始位置是 div 的结束标签,于是会触发钩子函数 end。当 end 触发后,会从栈中弹出一个节点出来,也就是把 div 节点从栈中弹出来,并将
</div>从模板中截取掉。 - 模板被截取空了,HTML 解析完毕。
了解了构建 AST 的大致过程,我们需要进一步了解它是如何实现每一轮循环中的截取的呢?它怎么知道哪个是开始标签、结束标签或者文本注释呢?答案就是 —— 正则匹配。从上面的构建过程中可以看到,每一轮循环都是从模板字符串的“开头”截取的,所以如果想要判断是否为开始标签,我们需要先判断:
- HTML 模板是不是以 < 开头
- 借助正则表达式判断是否符合开始标签的特征
- 解析标签属性(也是一小部分一小部分去截取)
更为详细的步骤可以去看 Vue 源码 html-parser.js 中的 parseStartTag 方法 👀 截取其余类型的节点同理。
前面提到需要维护一个栈来记录节点之间的层级关系,这个栈还有另一个作用,那就是能判断 HTML 标签是否可以正确闭合。【这里你是不是想到了 leetcode 经典算法题:有效括号 👀】
比如:
<div><p></span></div>
这里解析到 </span> 结束标签时,此时栈中最后一个节点是<p>,<p> 和 </span> 是不匹配的,所以会报错~
总结来说,HTML 解析器的整体逻辑大致是这样的:
// 首先,parseHTML是一个函数,它有两个参数:模板和选项
function parseHTML(template, options) {
// 我们将模板一小段一小段进行截取,用循环来实现
while (template) {
// lastTag 表示父元素
if (!lastTag || !isPlainTextElement(lastTag)) {
// 父元素为正常元素
// 可能的情况有:文本、注释、条件注释、DOCTYPE、结束标签、开始标签
// 除了文本以外,其余都是以 < 开头的
let textEnd = template.indexOf("<");
if (textEnd === 0) {
// 以 < 开头
// 分别判断是不是注释、条件注释、DOCTYPE、结束标签、开始标签
}
if (textEnd < 0) {
// 肯定是文本
text = template;
template = "";
}
// 处理文本
if (text) {
options.chars(text);
}
} else {
// 父元素为 script/style/textarea
// 纯文本元素,截取并触发 chars钩子函数即可
}
}
}
2. 文本解析器
文本分两类,一类是纯文本,另一类是带变量的文本。在 HTML 解析器解析文本时,不会区分文本中是否有变量,这部分工作由文本解析器 parseText 完成。
parseText 首先需要判断文本中是否含有变量,我们知道变量的存在形式是 {{}},所以:
function parseText(text) {
// 好烦正则 0.0
const tagRE = /\{\{((?:.|\n)+?)\}\}/g;
if (!tagRE(text)) {
return;
}
const match = tagRE.exec(text);
// 匹配到 {{}},截取出来,将其转换为 _s()
}
_s 是 这个 toString 函数的别名:
So,诸如 {{name}} 的变量将被转换为 _s(name)。
三、优化器
优化器的作用是在 AST 中找到静态节点并将其标记出来。具体来说,它会标记所有的静态节点和静态根节点。前面已经提及什么是静态节点,那静态根节点又是什么呢?如果一个节点的所有字节点都是静态节点且它的父节点是动态节点,那么这个节点就是静态根节点。
表现到代码中,是将静态节点的 static 属性设置为 true,将静态根节点的 staticRoot 属性设置为 true。源码中的实现为:
function optimize(root) {
if (!root) return;
// 标记静态节点
markStatic(root);
// 标记静态根节点
markStaticRoots(root);
}
function markStatic(node) {
// 判断当前节点是否为静态节点
node.static = isStatic(node);
if (node.type === 1) {
// 1 表示元素节点
for (let i = 0, l = node.children.length; i < l; i++) {
const child = node.children[i];
// 递归判断子节点
markStatic(child);
// 如果子节点是动态节点,那父节点也属于动态节点
if (!child.static) {
node.static = false;
}
// 这一步可以保证静态节点的所有子节点都是静态节点
}
}
}
function isStatic(node) {
if (node.type === 2) {
// 2 表示带变量的动态文本节点
return false;
}
if (node.type === 3) {
// 3 表示不带变量的纯文本节点
return true;
}
return !!(
node.pre || // 如果使用了 v-pre 直接断定为静态节点,因为 v-pre 用于跳过编译,显示原始标签及内容
(!node.hasBindings && // 没有动态绑定
!node.if &&
!node.for && // 没有v-if或v-for
!isBuiltInTag(node.tag) && // 不是内置标签
isPlatformReservedTag(node.tag) && // 不是组件
!isDirectChildOfTemplateFor(node) && // 父节点不能是带v-for的template标签
Object.keys(node).every(!isStaticKey)) // 节点中不存在动态节点才有的属性
);
}
// markStatic 标记静态节点时,我们可以确保,静态节点的所有子节点都是静态节点
// 所以标记静态根节点的逻辑是:从上到下找到的第一个静态节点一定是静态根节点
function markStaticRoots(node) {
if (node.type === 1) {
// 1 表示元素节点
if (node.static && node.children.length) {
node.staticRoot = true;
// d]当前节点如果是静态根节点,就无须判断子节点了,直接return
return;
} else {
node.staticRoot = false;
}
if (node.children) {
for (let i = 0, l = node.children.length; i < l; i++) {
const child = node.children[i];
// 递归判断子节点
markStaticRoots(node.children[i]);
}
}
}
}
四、代码生成器
代码生成器的作用是将 AST 转换成渲染函数中的代码字符串,用于传入渲染函数中执行从而生成 vnode。
比如之前举的例子:
`with(this){
return _c(
'h1',
{
on:{'click': c}
},
[-v('Hello world!')]
)
}`
// 这里 _c 表示创建元素类型的vnode
// _v 表示创建文本类型的 vnode
可以看到,代码字符串其实是嵌套的函数调用。那具体地,它怎么生成呢?
代码字符串的生成过程是递归的过程,从顶向下处理 AST 节点。节点有三种类型,根据节点类型生成对应的代码字符串,由 with 语句包裹起来:
- 元素节点:由
createElement方法创建 (别名_c)参数:_c(<tagname>,<data>,<children>)
function genElement(el, state) {
// plian=true 表示节点没有属性,是在编译时生成的属性
// genData 用于生成节点属性字符串
const data = el.plian ? undefined : genData(el, state);
const children = genChildren(el, state);
// 拼接起来
code = `_c('${el.tag}'${
data ? `,${data}` : "" // data
}${
children ? `,${children}` : "" // children
})`;
return code;
}
- 文本节点:由
createTextVNode方法创建 (别名_v)
function genText(text) {
return `_v(${
text.type === 2
? text.expression // no need for () because already wrapped in _s()
: JSON.stringify(text.text)
// 这里用 JSON.stringify 给文本包一层字符串
// 比如:"'这里是字符串'"
})`;
}
- 注释节点:由
createEmptyVNode方法创建 (别名_e)
function genComment(comment) {
return `_e(${JSON.stringify(comment.text)})`;
}
代码字符串的生成就是字符串拼接的过程。
PS:参考《深入浅出 Vue.js》