Vue学习笔记 — 模板编译原理

Vue.js提供了模板语法,允许我们声明式地描述状态和DOM之间地绑定关系,然后通过模板来生成真实DOM并将其呈现在用户界面上

渲染步骤

将模板编译成渲染函数可以分为两个步骤,先将模板解析成**AST(Abstract Syntax Tree,抽象语法树)**,然后再使用AST生成渲染函数

由于静态节点不需要总是重新渲染,因此在生成AST之后与生成渲染函数之前会需要一个操作->遍历一遍AST,给所有静态节点做一个标记,这样在虚拟DOM更新节点时就会发现这个标记,也就不会重新渲染它

模板编译答题分为三个部分:

  • 将模板解析为AST
  • 遍历AST标记静态节点
  • 使用AST生成渲染函数

这三个部分在模板编译中分别抽象出三个模块来实现功能:

  • 解析器
  • 优化器
  • 代码生成器

解析器

解析器地功能就是将模板解析为AST

在解析器内部,分成了很多小的解析器->过滤器解析器,文本解析器、HTML解析器,通过主线将这些解析器组装在一起

文本解析器主要用于解析带有变量的文本Hello,不带有变量的文本是不需要使用文本解析器进行解析的

解析器的内部其实页分为了好几种解析器,有HTML解析器、文本解析器、以及过滤器解析器等其中最重要的就是HTML解析器,它在解析HTML的过程中会触发各种钩子函数,其中包括有开始标签钩子函数、结束标签钩子函数、文本钩子函数以及注释钩子函数

以下是相关的伪代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
parseHTML(template, {
start(tag, attrs, unary) { //标签名, 标签的属性, 是否是自闭合函数
// 每当解析到标签开始位置时,触发该函数
},
end() {
// 每当解析到标签结束位置时,触发该函数
},
chars(text) {
// 每当解析到文本时,触发该函数
},
comment(text) {
// 每当解析到注释时,触发该函数
}
})

当HTML解析器进行解析时会依次触发这些钩子函数,注意:哪怕是仅含有空格,也会触发start钩子函数

在parseHTML()函数中会循环对模板进行解析,每一轮循环中,都会进行字符串的截取,被截取的片段会分很多类型:

开始标签、结束标签、HTML注释、DOCTYPE,条件注释、文本

截取开始标签

在HTML解析器中,想分辨模板是否以开始标签开头不难,应该先判断是否以 < 开头, 如果不是,那么它一定不是以开始标签开头的模板,也就不需要执行开始标签的截取操作, 如果是以<开头的,那么就需要对其具体的标签类型进行解析判断,这也就需要开始使用正则表达式进行解析了

1
2
3
const ncname = `[a-zA-z_][\\w\\-\\.]*`
const qnameCapture = `((?:${ncname}\\:)?${ncname})`
const startTagOpen = new RegExp(`^<${qnameCapture}`)

对开始标签进行截取后,会通过正则表达式进行分析,开始标签的解析中需要判断是否为自闭合标签,例如<input/>就是自闭合标签,我们需要使用循环递归的方式解析标签中所携带的属性,并构建生成AST,之后我们需要根据标签的类型来进行下一步的解析,有如下几种情况:

  • 文本
  • 注释
  • 条件注释
  • DOCTYPE
  • 结束标签
  • 开始标签

我们使用栈维护DOM的层级,以便于更加清晰地知道节点之间的父子关系

文本

上面这些情况中最特殊的就是文本,文本的解析不需要正则表达式的参与,因为我们都知道从开始标签的结束结束标签的开始这之间存在的就是文本,所以我们解析时只需要对去除开始标签后的整体使用indexof('<')找到结束标签的开始位置,截取其之前的所有字符就是文本

为什么说文本特殊,因为文本本身就有两种,一种使 纯文本,而另一种则是携带有变量的文本,如果是纯文本则不需要进行热河处理,但是如果是携带变量的文本,则我们需要使用文本解析器进行更进一步的解析,因为携带变量的文本在我们使用虚拟DOM进行渲染时需要将文本中的变量替换为变量所代表的值

"Hello "被解析时

这是一个携带变量的文本,触发chars函数后,

会得到一个这样的expression变量"Hello " + _s(name)

_s就是toString()的别名

1
2
3
4
5
6
7
function toString(val) {
return val == null
? ''
: typeof val === 'object'
? JSON.stringify(val, null, 2)
: String(val)
}

在文本解析器中第一步就是对使用正则表达式对文本进行解析,也就是判断是不是纯文本,如果携带变量那么就会进行二次加工,将变量加载在文本中。

解析器的作用就是通过模板得到AST(抽象语法树)

生长城AST的过程需要借助HTMLi基尔希奇,当解析器触发不同的钩子函数时,可以构建出不同的节点,随后可以通过栈来确立他们之间的层级关系,最后我们可以得到一个完整的带DOM层级关系的AST

优化器

优化器的目标是遍历AST,检测出所有静态子树,并为其添加标记

优化器的主要功能就是避免一些无用功来提升性能

什么是静态子树

静态子树就是指 那些在AST中永远不会发生改变的节点,例如一个不带有任何变量的纯文本节点就是一个静态子树,而一个带有变量的文本就不是,因为他的内容会随着变量的更改而更改

标记静态子树的好处:

  • 每次重新渲染的时候,不需要为静态子树创建新的节点
  • 在虚拟DOM中打补丁(patching)的过程中可以跳过

以上两点好处能够有效节省JavaScript的运算成本

优化器内部对的主要步骤:

  1. 在AST中找出所有的静态节点并打标记
  2. 在AST中赵出所有的静态根节点并打上标记

静态根节点: 如果一个节点下面的所有子节点都是静态节点,它的父级是动态节点,那么它就是静态根节点

静态节点与静态根节点在AST中的表示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
//静态节点
/*
我是静态节点


*/
//AST表示
{
type: 1,
tag: 'p',
staticRoot: false,
static: true,
...
}

//静态根节点
/*


我是静态节点1


我是静态节点2


我是静态节点3


*/

//AST表示
{
type: 1,
tag: 'ul'
staticRoot: true,
static: true
}

所有的AST节点经过优化器处理后,都会添加static属性和staticRoot属性,而优化器也正是通过这两个属性来对静态节点和静态根节点进行标记的

找出并标记静态节点

基本操作就是从根节点开始向内层循环,判断根节点是不是静态节点,再用相同的方式处理子节点,直到所有子节点都处理完毕,就退出循环

节点标记函数就是首先通过isStatic函数对节点是否是静态节点进行判断,然后根据判断结果对static属性进行修改

1
2
3
4
5
6
7
8
9
10
11
12
function markStatic (node) {
node.static = isStatic(node)
if(node.type === 1) {
for(let i = 0, len = node.children.length; i < len; i++) {
markStatic(child)
}

if(!child.static) { //防止出现父节点被标记静态而子节点为动态节点
node.static = false
}
}
}

上面提到并且调用了isStatic函数,它在源码中的实现是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function isStatic (node) {
if(node.type === 2) { //带有变量的文本节点
return false
}
if(ndoe.type === 3) { //不带变量的纯文本节点
return true
}
return !!(node.pre || (
!node.hasBindings && //没有动态绑定
!node.is && !node.for && //没有v-if和v-for
!isBuiltInTag(node.tag) &&//不是内置标签
isPlatformReservedTag(node.tag) &&//不是组件
!isDirectChildOfTemplateFor(node) &&
Object.keys(ndoe).every(isStaticKey)
))
}

符合静态节点的条件也十分苛刻:

  • 不能使用动态绑定语法,也就是标签中不能有v-, @, :,开头的属性
  • 不能使用v-if,v-forv-else等指令
  • 不能是内置标签,即slotcomponent
  • 不能使组件,即标签名必须是保留标签
  • 当前节点的父节点不能是带有v-for指令的template标签
  • 节点中不存在动态节点才会有的属性(静态节点的属性范围:typetagattrsListattrMapplainparentchildrenattrsstaticClassstaticStyle

找出并标记所有静态根节点

这个过程与上面标记静态节点的过程类似,不同的就是静态根节点的寻找过程中,如果我们确定了静态根节点,那么我们就没有继续向下寻找了

我们上面的代码中提到过这样一个逻辑,静态节点的所有子节点也都是静态节点,所以我们在遍历时所见到的第一个静态节点肯定是静态根节点,它所有的子节点也肯定是静态节点

!!!注意:有一种例外情况就是当一个节点没有任何子节点,知识独立的静态节点时,它是静态根节点,但是不需要标记为静态根节点,因为完全没有必要,优化的成本反而更高

代码生成器

代码生成器是模板编译的 最后一步,它的作用是将AST转换成渲染函数中的内容,这个内容称为”代码字符串“

例如:

1
// 代码丢失

生成的代码为:
with(this){return _c('p',{attrs:{"title":"Sleepygod"},on:{"click":c}},[_v("1")])}
格式化后是这样的:

1
2
3
4
5
6
7
8
9
10
11
with (this) {
return _c(
"div",
{
attrs: {"id": "el"}
},
[
_v("Hello " + _s(name))
]
)
}

这样将一个代码字符串导出到外界使用时,会将代码字符串放到函数中,这个函数叫做渲染函数

渲染函数的作用是创建vnode,代码字符串中的_c_v都是创建vnode的方法,_c是创建元素类型的vnode_v是创建文本类型的vnode

_c就是createElement的别称,它是虚拟DOM中提供的方法,他的作用是创建虚拟节点

通过AST生成代码字符串

节点共有三种类型:元素节点、文本节点、注释节点

对应的创建方法: createElementcreateTextVNodecreateEmptyVNode

对应的别名:_c_v_e

原理

不同类型的节点生成方式是不一样的

元素节点

生成元素节点,其实就是生成一个_c的函数调用字符串

1
2
3
4
5
6
7
8
9
10
function getElement (el, state) {
const data = el.plain ? undefined : getData(el, state)

const children = genChilderen(el, state)
code = `_c('${el.tag}'
${data ? `${data}` : ''}
${children ? `,${children}` : ''}
)`
return code
}

plain属性是在编译的时候发现的,如果节点没有属性,就会把plain设置为true

代码中的主要逻就是通过getDatagetChildren分别获取datachildren,然后将他们拼接到字符串指定的位置,最后把拼好的_c(tagName, data, children)返回,这样一个元素节点的代码字符串就生成好了

而data与children也是字符串,他们的生成方式就是先给data赋值'{'然后发现节点中有哪些属性数据,就将他们直接拼接在data中,最后拼接一个'}'这样data就生成好了

文本节点

文本节点的生成依旧非常简单,我们只需要将文本放在_v这个函数的参数中即可:

1
2
3
4
5
6
function getText (text) {
return `_v(${text.type === 2
? text.expression
: JSON.stringify(text.text)
})`
}

我们会判断文本的类型,如果是动态文本就使用expression,如果是静态文本,就使用text

注释节点

注释节点的生成方式与文本节点相似

1
2
3
function getComment (comment) {
return `_e(${JSON.stringify(comment.text)})`
}

总结

模板编译是Vue最重要的一部分,先由解析器进行代码解析,生成AST,再由优化器对AST进行优化,将所有静态节点和静态根节点惊醒标记,然后由代码生成器生成渲染函数所需要的渲染字符串,最后交给虚拟DOM的渲染函数进行渲染操作。

近期总结

前前后后总共学习了5、6天左右的时间,中间也经历了几次面试,之后可能会更新面试经历吧,还没有面试成功,还没有找到实习,直接开始准备秋招了,加油加油!!!