本文共--字 阅读约--分钟 | 浏览: -- Last Updated: 2022-02-19
在底层实现上,Vue会将模板编译成虚拟DOM渲染函数。当应用状态发生变化时,Vue就会结合响应式系统,聪明地找出最小数量的组件进行重新渲染以及最少量地进行DOM操作。
模板编译的主要目的就是生产渲染函数,而渲染函数的作用是每次执行它,它就会使用当前最新的状态生成一份新的 vnode,然后使用这个 vnode 进行渲染。
模板编译成渲染函数,先将模板解析成 AST (Abstract Syntax Tree, 抽象语法树),然后再使用 AST 生成渲染函数。
但由于静态节点不需要总是重新渲染,所以在生成AST之后,生成渲染函数之前这个阶段,还需要进优化,给所有的静态节点做一个标记,这样虚拟DOM中更新节点时,发现节点有这个标记就不会去重新渲染它。
所以,模板编译分三部分内容:
在解析器内部,分成了很多小解析器,其中包括过滤器解析器、文本解析器和 HTML 解析器,然后通过一条主线将这些解析器组装在一起。
主线上做的事就是监听HTML解析器,每当触发钩子函数时(每当解析到HTML标签的开始位置、结束位置、文本或注释时,都会触发钩子函数),就生成对应的AST节点,在生成AST之前,会根据类型使用不同的方式生成不同的AST,这个AST与vnode有点类似,都是使用 JavaScript 中的对象来表示节点。
// 伪代码
parseHTML(template, {
start(tag, attrs, unary){
// 每当解析到开始标签位置(<div>)的时候 触发该函数
// 构建元素类型的节点
},
end(){
// 每当解析到标签的结束位置(</div>)时,触发该函数
},
chars(text){
// 每当解析到文本时,触发该函数
// 构建文本类型的节点
},
comment(text){
// 每当解析到注释是,触发该函数
// 构建注释类型的节点
},
})
在 start
钩子函数中的,三个参数分别是 标签名、标签属性、是否是自闭合标签(单标签),我们可以使用这三个参数来构建一个元素类型的 AST 节点:
parseHTML(template, {
start(tag, attrs, unary){
// currentParent 用来记录当前节点的父节点是?
let element = createASTElement(tag, attrs, currentParent)
},
}
function createASTElement(tag, attrs, parent) {
return {
type: 1, // 节点的类型 1代表元素节点 3代表文本节点
tag,
attrsList: attr,
parent,
children: [],
} // 表示AST的节点对象
}
创建文本类型的AST节点:
parseHTML(template, {
chars(text){
let element = { type: 3, text }
},
}
创建注释节点的AST节点:
parseHTML(template, {
comment(text) {
let element = { type: 3, text, isComment: true }
}
})
一个AST节点具有父节点和子节点,所以我们需要一套逻辑来实现层级关系,让每一个AST节点都能找到它的父级。
构建AST层级关系也非常简单,我们只需要维护一个栈即可,用栈来记录层级关系,也可以理解为DOM的深度。HTML解析器是从前往后解析的,每当遇到开始标签就会触发钩子函数 start
,此时我们将当前需要构建的节点推入栈中,每当遇到结束标签时触发钩子函数 end
时,就从栈中弹出一个节点,这样就可以保证每当触发钩子函数 start
的时候,栈的最后一个节点就是当前正在构建节点的父节点。
<div>
<p>我是p</p>
<button>我是button</button>
</div>
针对以上一个DOM,流程大致如下:
div
的start
钩子,将div
推入栈中div
的chars
钩子(因为div
和p
之间的空格会被当做文本触发文本钩子,在钩子函数会忽略这些空格)p
的start
钩子,此时栈中最后一个是div
,所以div
是p
的父元素,然后将p
推入栈中,栈结构变成 [div, p]
p
的chars
钩子、接着end
钩子,将p
从栈中弹出,此时的栈中仍然就只有div
。button
的start
钩子,此时的栈中的最后一个是div
,所以div
也是button
的父元素,将button
推入栈中,栈结构变成[div, button]
事实上,解析HTML模板的过程就是一个循环的过程,简单来说就是用 HTML模板字符串 来循环,每轮循环都从HTML模板中截取一小段字符串,然后重复以上过程,直到HTML模板被截成一个空字符串时结束循环,解析完毕。
每触发一个钩子都会将模板中的某部分截取掉,当整个模板被截取空了,说明HTML解析器已经解析完毕。
`
<div>
<p>我是p</p>
<button>我是button</button>
</div>
`
// 遇到开始标签并触发start处理后
`
<p>我是p</p>
<button>我是button</button>
</div>
`
// 遇到空格触发chars处理后
`<p>我是p</p>
<button>我是button</button>
</div>
`
// 遇到开始标签
`我是p</p>
<button>我是button</button>
</div>
`
// ...
``
1、截取开始标签
const ncname = '[a-zA-Z_][\\w\\-\\.]*'
const qnameCapture = `((?:${ncname}\\:?${ncname}))`
const startTagOpen = new RegExp(`^<${qnameCapture}`)
'<div></div>'.match(startTagOpen) // [ '<div', 'div', index: 0, input: '<div></div>', groups: undefined ]
'</div><div>hello world</div>'.match(startTagOpen) // null
'我是文本<div></div>'.match(startTagOpen) // null
当完成上面的解析时,我们可以得到这样一个数据结构:
const start = '<div></div>'.match(startTagOpen)
if (start) {
const match = {
tagName: start[1],
attrs: []
}
}
事实上,上面的匹配,只能匹配开始标签的一小部分 <div
,连标签都不全,这是因为开始标签会被分为3部分,分别是标签名、属性和结尾。
解析标签属性的代码如下:
const startTagClose = /^\s*(\/?)>/;
const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/
let html = ' class="box" id="el"></div>'
let end, attr;
const match = { tagName: 'div', attrs: [] }
while(!(end = html.match(startTagClose)) && (attr = html.match(attribute))) {
html = html.substring(attr[0].length);
match.attrs.push(attr)
}
console.log(match)
// {
// tagName: 'div',
// attrs: [
// [
// ' class="box"',
// 'class',
// '=',
// 'box',
// undefined,
// undefined,
// index: 0,
// input: ' class="box" id="el"></div>',
// groups: undefined
// ],
// [
// ' id="el"',
// 'id',
// '=',
// 'el',
// undefined,
// undefined,
// index: 0,
// input: ' id="el"></div>',
// groups: undefined
// ]
// ]
// }
这样就能将标签上的所有属性解析出来了。
针对自闭合标签,又是另外一套逻辑,因为自闭合标签是没有子节点的,前文中提到构建AST层级时需要维护的一个栈,而一个节点是否需要推入栈中,可以使用这个自闭合标识来判断。
// 整个截取开始标签的逻辑
const ncname = '[a-zA-Z_][\\w\\-\\.]*'
const qnameCapture = `((?:${ncname}\\:?${ncname}))`
const startTagOpen = new RegExp(`^<${qnameCapture}`)
const startTagClose = /^\s*(\/?)>/
const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<div>`]+)))?/
function advance(n) {
html = html.substring(n) // 截取
}
function parseStartTag() { // 解析开始标签
const start = html.match(startTagOpen)
if (start) { // 是开始标签
const match = {
tagName: start[1],
attrs: []
}
advance(start[0].length); // 截掉开始标签的前部分即 <div
// 解析标签属性
let end, attr;
while(!(end = html.match(startTagClose)) && (attr = html.match(attribute))) {
advance(attr[0].length);
match.attrs.push(attr)
}
// 判断是否是自闭合标签
if (end) {
match.unarySlash = end[1] // 是闭合标签,unarySlash就有值为 /
advance(end[0].length)
return match;
}
}
}
let html = '<div class="box" id="el"></div>'
let result = parseStartTag();
console.log(result); // { tagName: 'div', attrs: [...], unarySlash: '' }
console.log(html); // </div> 截完所有的开始标签部分
html = '<input class="name" id="el2" />'
result = parseStartTag();
console.log(result); // { tagName: 'input', attrs: [...], unarySlash: '/' }
console.log(html); // '' 自闭合标签只需要解析 开始标签就可以解析完毕
调用 parseStartTag
可以将模板开始部分的开始标签解析出来,如果模板不符合开始标签的正则表达式,parseStartTag
则返回 undefined
,因此,如果调用得到了结果,那么说明符合,将解析出来的结果取出来并调用 start
钩子即可。
const startTagMatch = parseStartTag();
if (startTagMatch) {
handleStartTag(startTagMatch)
continue
}
// handleStartTag 就是将 startTagMatch 中的 tagName attrs unarySlash 处理为 tag attrs unary 传递start钩子
2、截取结束标签
结束标签的截取要比开始标签简单的多,因为它不需要解析什么,只需要分辨当前是否已经截取到结束标签,如果是,截掉这部分,触发end
钩子即可
const ncname = '[a-zA-Z_][\\w\\-\\.]*'
const qnameCapture = `((?:${ncname}\\:?${ncname}))`
const endTag = new RegExp(`^<\\/${qnameCapture}[^>]*>`)
// '</div>'.match(endTag) // [ '</div>', 'div', index: 0, input: '</div>', groups: undefined ]
// '<div>'.match(endTag) // null
const endTagMatch = html.match(endTag)
if (endTagMatch) {
html = html.substring(endTagMatch[0].length)
options.end(endTagMatch[1]) // 触发钩子
continue
}
3、截取注释
const comment = /^<!--/
if (comment.test.html) {
const commentEnd = html.indexOf('-->')
if (commentEnd >= 0) {
if (options.shouldKeepComment) { // 只有这个配置为true时才触发钩子函数,将注释的内容传递过去
options.comment(html.substring(4, commentEnd))
}
// 否则直接截取调所有的注释部分
html = html.substring(commentEnd+3)
continue
}
}
4、截取条件注释
条件注释不需要触发钩子函数,只需要截取掉就可以了
let html = '<![if !IE]><link href ="non-ie.css" rel="stylesheet"><![endif]>'
const conditionalComment = /^<!\[/
if (conditionalComment.test(html)) {
const conditionalCommentEnd = html.indexOf(']>')
if (conditionalCommentEnd >= 0) {
html = html.substring(conditionalCommentEnd+2)
continue
}
}
console.log(html); // <link href ="non-ie.css" rel="stylesheet"><![endif]>
// 继续解析会将link当做标签进行处理然后截掉
// <![endif]> 又符合 conditionalComment 则也会直接被截掉
所以在Vue模板中条件注释是没有意义的,写了也白写,因为不会判断条件,但会处理条件注释的内容,即<link href ="non-ie.css" rel="stylesheet">
必然会出现在模板中。
5、截取DOCTYPE
DOCTYPE与条件注释相同,也不需要触发钩子函数,只将匹配到的截取掉就可以了。
// <!DOCTYPE html>
const doctype = /^<!DOCTYPE [^>]+>/i
const doctypeMatch = html.match(doctype)
if (doctypeMatch) {
html = html.substring(doctypeMatch[0].length)
continue
}
6、截取文本
在HTML模板中,开始标签、结束标签和注释等,都是 <
开头,而当剩余模板不是 <
开头时,那么它一定就是文本的,例如剩余模板会长这样文本</div>
// 处理文本
while(html) {
let text, rest, next;
let textEnd = html.indexOf('<')
if (textEnd > 0) {
rest = html.slice(textEnd);
// 针对文本中有 < 的情况
while(
!endTag.test(rest) &&
!startTagOpen.test(rest) &&
!comment.test(rest) &&
!conditionalComment.test(rest)
) {
// 如果满足以上判断 则将 < 视为纯文本
next = rest.indexOf('<', 1) // 是否还有 <
if (next < 0 ) break; // 跳出循环
textEnd += next
rest = html.slice(textEnd) // 继续截取判断,直到 < 是属于开始标签、结束标签、注释、条件注释的一部分,才不将其当做文本处理
}
text = html.substring(0, textEnd) // 截取文本内容
html = html.substring(textEnd) // 截掉整个文本
}
if (textEnd < 0) { // 如果找不到 < 说明整个模板都是文本
text = html;
html = '';
}
if (options.chars && text) {
options.chars(text) // 触发钩子函数
}
}
7、纯文本内容元素
什么是纯文本内容元素,script
、style
、textarea
这三种元素叫作纯文本内容元素,前面介绍的开始标签、结束标签、文本、注释的截取时,都是默认当前要截取的元素的父级元素不是纯文本内容元素,事实上,是处在一个判断的:
// html = '<div><script>console.log(1)</script></div>'
while(html) {
// lastTag 标识父元素 isPlainTextElement简单的关于script style textarea的判断
if (!lastTag || !isPlainTextElement(lastTag)) {
// 父元素为正常元素的处理
} else {
// 父元素为纯文本内容元素的处理 // 此时html = 'console.log(1)</script></div>' 开始标签会被处理
const stackedTag = lastTag.toLowercase();
const reStackedTag = reCache[stackedTag] || (reCache[stackedTag] = new RegExp('([\\s\\S]*?)(</' + stackedTag + '[^>]*>)', 'i'))
const rest = html.replace(reStackedTag, function (all, text) {
// 此时的text就是结束标签前的所有内容 text = 'console.log(1)'
if (options.chars) {
options.chars(text)
}
return ''; // 返回空字符则rest,就是指将匹配到的结束标签的所有内容截掉了
})
html = rest // html = '</div>'
options.end(stackedTag) // stackedTag = 'script'
}
}
8、整体逻辑
首先,HTML解析器是一个函数,有两个参数,模板和选项,一点点的解析模板然后根据情况触发对应的钩子。
export function parseHTML(html, options) {
while(html) {
if (!lastTag || !isPlainTextElement(lastTag)) {
let textEnd = html.indexOf('<')
if (textEnd === 0) {
if (comment.test(html)) {
// 注释的处理
continue
}
if (conditionalComment.test(html)) {
// 条件注释的处理
continue
}
const doctypeMatch = html.match(doctype)
if (doctypeMatch) {
// DOCTYPE的处理
continue
}
const endTagMatch = html.match(endTag)
if (endTagMatch) {
// 结束标签的处理
continue
}
const startTagMatch = parseStartTag()
if (startTagMatch) {
// 开始标签的处理
continue
}
}
let text, rest, next;
if (textEnd >= 0) {
// 解析文本
}
if (textEnd < 0) {
text = html
html = '';
}
if (options.chars && text) {
options.chars(text)
}
} else {
// 纯文本内容元素的处理
}
}
}
文本解析器就是解析文本,是对HTML解析器中的出来的文本进行二次加工,因为文本存在两种情况,一种是纯文本,另一种是带双花括号的变量(例, hello {{name}}
)的文本。
每当HTML解析器解析到文本时,都会触发 chars
钩子,并且从参数中得到文本,在 chars
函数中,我们需要构建文本类型的 AST,并将它添加到父节点的 children 属性中。
所以,在这里会对纯文本和带变量的文本分别处理。
parseHTML(template, {
chars(text) {
text = text.trim() // 去除头尾的空字符串
if (text) {
const children = currentParent.children
let expression // 存储变量表达式
if (expression = parseText(text)) {
children.push({
type: 2,
expression,
text
})
} else {
children.push({
type: 3,
text
})
}
}
}
})
即如果执行 parseText()
有结果,则说明文本是带变量的文本,并且已经通过文本解析器parseText
的二次加工了,此时会构建一个带变量的文本类型的AST并将其添加到父节点的children属性中。
function parseText(text) {
const tagRE = /\{\{((?:.|\r?\n)+?)\}\}/g;
if (!tagRE.test(text)) { // 没有匹配到双花括号 即文本中不含有变量
return;
}
const tokens = []
let lastIndex = tagRE.lastIndex = 0;
let match, index;
while ((match = tagRE.exec(text))) {
// console.log(match); // [ '{{name}}', 'name', index: 2, input: '你好{{name}}', groups: undefined]
index = match.index;
// 先把 {{ 前边的文本添加到tokens中
if (index > lastIndex) {
tokens.push(JSON.stringify(text.slice(lastIndex, index)))
}
// 把变量变成 _s(variableName) _s是用来将变量值转换为字符串的函数
tokens.push(`_s(${match[1].trim()})`) // match[1] 就是双花括号内部的值
// 设置 lastIndex 来保证下一次循环
lastIndex = index + match[0].length
}
// 当所有的变量处理完成 如果最后一个变量右边还有文本 就将文本添加到数组中
if (lastIndex < text.length) {
tokens.push(JSON.stringify(text.slice(lastIndex)))
}
return tokens.join('+')
}
console.log(parseText('你好{{name}}')); // '"你好"+_s(name)'
console.log(parseText('你好 jack')); // undefined
console.log(parseText('你好{{name}},你今年已经 {{ age }} 岁了')); // '"你好"+_s(name)+",你今年已经 "+_s(age)+" 岁了"'
最终的使用环境会是这样
var obj = { name: 'jack' , age: 22}
with(obj) { // 处于 obj的作用域下
function _s (val) {
return val == null ? '' : (typeof val === 'object' ? JSON.stringify(val, null, 2) : String(val))
}
const expression = '"你好"+_s(name)+",你今年已经 "+_s(age)+" 岁了"'
console.log(eval(expression)) // 你好jack,你今年已经 22 岁了
console.log("你好" + _s(name)) // 你好jack
}
解析器的作用是通过模板得到 AST (抽象语法树),生成AST的过程需要借助HTML解析器,当HTML解析器触发不同的钩子函数时,我们可以构建出不同的节点。随后,可以通过栈来得到当前正在构建的节点的父节点,然后将构建出的节点添加到父节点的下面,最终解析完毕后,就可以得到一个完整的带DOM层级关系的AST。
HTML解析器的内部原理是一小段一小段地截取模板字符串,每截取一小段就会根据截取出来的的字符串类型触发不同的钩子,直到模板字符串截空停止运行。
优化器会遍历AST,检测出所有的静态子树并给其打标记,标记静态子树有两点好处:
而优化器内部实现主要分为两个部分,遍历AST找出所有的静态节点打上标记、遍历AST找出所有的静态根节点并打上标记。
静态根节点是指一个节点下所有子节点都是静态节点,并且它的父级是动态节点,那么它就是静态根节点。
静态节点在AST的对应属性为 { static: true }
, 而静态根节点的对应属性为 { staticRoot: true }
。
比如,<div id="el">hello {{name}}</div>
,这个模板转化为AST就是下面这个样子:
let AST = {
type: 1,
tag: 'div',
attrsList: [
{
name: 'id',
value: 'el'
}
],
attrsMap: {
'id': 'el'
},
children: [
{
type: 2,
expression: '"hello"+_s(name)',
text: 'hello {{name}}'
}
],
plain: false,
attrs: [
{
name: 'id',
value: 'el'
}
]
}
// 经过器优化之后
AST = {
...,
children: [
{
type: 2,
expression: '"hello"+_s(name)',
text: 'hello {{name}}',
static: false,
}
],
...,
static: false,
staticRoot: false
}
而优化器是如何处理的呢?
export function optimize(root) {
if (!root) return
markStatic(root)
markStaticRoots(root)
}
function markStatic(node) {
node.static = isStatic(node)
if (node.type === 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 markStaticRoots(node) {
if (node.type === 1) {
// 要使这个节点符合静态根节点的操作,它必须要有子节点
// 并要求子节点不能是一个只有静态文本的子节点
if (node.static && node.children.length && !(node.children.length === 1 && node.children[0].type === 3)) {
// 是只有一个静态文本的子节点,则说明是静态根节点,结束判断
node.staticRoot = true
return
} else {
// 否则,则不是,继续判断
node.staticRoot = false
}
if (node.children) {
for (let i = 0, l = node.children.length; i < l; i++) {
markStaticRoots(node.children[i]);
}
}
}
}
function isStatic(node) {
if (node.type === 2) {
return false; // 带变量的动态文本节点
}
if (node.type === 3) {
return true; // 不带变量的纯文本节点
}
return !!(node.pre || ( // 使用v-pre则直接断定为一个静态标签
!node.hasBindings && // 没有动态绑定
!node.if && node.for && // 没有使用v-if 或 v-for 或 v-else
!isBuiltInTag(node.tag) && // 不是内置标签
isPlatformReservedTag(node.tag) && // 不是组件,即标签名是HTML保留标签和svg保留标签
!isDirectChildOfTemplateFor(node) && // 当前节点的父节点不能带v-for指令的template标签
Object.keys(node).every(isStaticKey) // 不存在动态属性
))
}
将AST转换成渲染函数中的内容,这个内容可以成为“代码字符串”,例如下面这个模板:
<p title="test" @click="handle">hello {{ name }}</p>
生成后的代码字符串是
with(this){ // with可以延长作用域 当前代码块中this上的属性和方法就变成了“全局”的
return _c( // _c createElement的别称 参数列表(元素标签,属性对象,children)
'p',
{
attrs: { "title": "test" },
on: {"click": "handle"}
},
[_v("hello "+_s(name))],
)
}
// 这是一个字符串
会将这个代码字符串,放到渲染函数里面,当渲染函数被导出到外界后,模板编译的任务就完成了。
// 如何将代码字符串放到函数里?
const code = `with(this){return 'hello world'}`
const hello = new Function(code);
console.log(hello()); // 'hello world'
实际中,这个渲染函数执行后就可以生成一份Vnode(即通过执行createElement),而虚拟DOM可以通过这个Vnode来渲染视图。
生成代码字符串是一个递归的过程,从顶向下依次处理每一个AST节点。使用_c (createElement)
来创建元素节点, 使用_v (createTextVNode)
创建文本节点,使用_e (createEmptyVNode)
创建注释节点。
每处理一个AST节点,就会生成一个与节点类型对应的代码字符串。
例如:
<div id="el">
<div>
<p>hello {{ name }}</p>
</div>
</div>
最终的代码字符串会是这样
// 遍历递归结束之后就可以得到一个完整的代码字符串
`
with(this){
return _c("div", {attrs: {"id": "el"} }, [
_c("div", [
_c("p", [
_v("hello "+_s(name))
])
])
])
}
`
代码生成器就是根据AST对象遍历生成对应的 _c 、_v 、_e 的函数调用字符串,实质就是字符串的拼接的过程。
function genElement(el, state) {
// 如果 el.plain 是true,则说明节点没有属性
const data = el.plain ? undefined : genData(el, state)
const children = genChildren(el, data)
code = `_c(
'${el.tag}'
${data ? `,${data}` : ''}
${children ? `,${children}`: ''}
)`
return code
}
function genData(el: ASTElement, state) {
let data = '{'
if (el.key) {
data += `key: ${el.key},`
}
if (el.ref) {
data += `ref: ${el.ref},`
}
if (el.pre) {
data += `pre: ${el.pre},`
}
// 类似的还有很多的判断
// 最终删除最后一个属性的末尾逗号
data = data.replace(/,$/, '') + '}'
return data;
}
function genChildren(el, state) {
const children = el.children
if (children.length) {
return `[${children.map(child => genNode(child, state)).join(',')}]`
}
}
function genNode(node, state) {
if (node.type === 1) {
return genElement(node, state)
}
if (node.type === 3 && node.isComment) {
return genComment(node)
}
return genText(node)
}
function genText(node) {
return `_v(
${node.type === 2 ? node.expression : JSON.stringify(node.text)}
)`
}
// type为2则是带有动态变量的文本节点,所以返回表达式 '"hello"+_s(name)'
// 为什么纯文本内容要再使用JSON.stringify() 转换一次
// 因为不装换会是 _v(hello) 最终被执行时语法上就是错误的 应该是 _v('hello')
function genComment(node) {
return `_e(
${JSON.stringify(node.text)}
)`
}