参考文档:javascript
在前几篇文章中,咱们介绍了
Vue
中的虚拟DOM
以及虚拟DOM
的patch(DOM-Diff)
过程,而虚拟DOM
存在的必要条件是得先有VNode
,那么VNode
又是从哪儿来的呢?这就是接下来几篇文章要说的模板编译。你能够这么理解:把用户写的模板进行编译,就会产生VNode
java
$mount
/*把本来不带编译的$mount方法保存下来,在最后会调用。*/
const mount = Vue.prototype.$mount
/*挂载组件,带模板编译*/
Vue.prototype.$mount = function ( el?: string | Element, hydrating?: boolean ): Component {
el = el && query(el)
/* istanbul ignore if */
if (el === document.body || el === document.documentElement) {
process.env.NODE_ENV !== 'production' && warn(
`Do not mount Vue to <html> or <body> - mount to normal elements instead.`
)
return this
}
const options = this.$options
// resolve template/el and convert to render function
/*处理模板templete,编译成render函数,render不存在的时候才会编译template,不然优先使用render*/
if (!options.render) {
let template = options.template
/*template存在的时候取template,不存在的时候取el的outerHTML*/
if (template) {
/*当template是字符串的时候*/
if (typeof template === 'string') {
if (template.charAt(0) === '#') {
template = idToTemplate(template)
/* istanbul ignore if */
if (process.env.NODE_ENV !== 'production' && !template) {
warn(
`Template element not found or is empty: ${options.template}`,
this
)
}
}
} else if (template.nodeType) {
/*当template为DOM节点的时候*/
template = template.innerHTML
} else {
/*报错*/
if (process.env.NODE_ENV !== 'production') {
warn('invalid template option:' + template, this)
}
return this
}
} else if (el) {
/*获取element的outerHTML*/
template = getOuterHTML(el)
}
if (template) {
/* istanbul ignore if */
if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
mark('compile')
}
/*将template编译成render函数,这里会有render以及staticRenderFns两个返回,这是vue的编译时优化,static静态不须要在VNode更新时进行patch,优化性能*/
const { render, staticRenderFns } = compileToFunctions(template, {
shouldDecodeNewlines,
delimiters: options.delimiters
}, this)
options.render = render
options.staticRenderFns = staticRenderFns
/* istanbul ignore if */
if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
mark('compile end')
measure(`${this._name} compile`, 'compile', 'compile end')
}
}
}
/*Github:https://github.com/answershuto*/
/*调用const mount = Vue.prototype.$mount保存下来的不带编译的mount*/
return mount.call(this, el, hydrating)
}
复制代码
经过mount
代码咱们能够看到,在mount
的过程当中,若是render
函数不存在(render
函数存在会优先使用render
)会将template
进行compileToFunctions
获得render
以及staticRenderFns
。譬如说手写组件时加入了template
的状况都会在运行时进行编译。而render function
在运行后会返回VNode
节点,供页面的渲染以及在update
的时候patch
。接下来咱们来看一下template
是如何编译的。node
咱们把写在
<template></template>
标签中的相似于原生HTML
的内容称之为模板。这时你可能会问了,为何说是“相似于原生HTML
的内容”而不是“就是HTML
的内容”?由于咱们在开发中,在<template></template>
标签中除了写一些原生HTML
的标签,咱们还会写一些变量插值,如,或者写一些Vue
指令,如v-on
、v-if
等。而这些东西都是在原生HTML
语法中不存在的,不被接受的。可是事实上咱们确实这么写了,也被正确识别了,页面也正常显示了,这又是为何呢?git
这就归功于
Vue
的模板编译了,Vue
会把用户在<template></template>
标签中写的相似于原生HTML
的内容进行编译,把原生HTML
的内容找出来,再把非原生HTML
找出来,通过一系列的逻辑处理生成渲染函数,也就是render
函数,而render
函数会将模板内容生成对应的VNode
,而VNode
再通过前几篇文章介绍的patch
过程从而获得将要渲染的视图中的VNode
,最后根据VNode
建立真实的DOM
节点并插入到视图中, 最终完成视图的渲染更新。github
而把用户在
template></template>
标签中写的相似于原生HTML
的内容进行编译,把原生HTML
的内容找出来,再把非原生HTML
找出来,通过一系列的逻辑处理生成渲染函数,也就是render
函数的这一段过程称之为模板编译过程。算法
所谓渲染流程,就是把用户写的相似于原生HTML的模板通过一系列处理最终反应到视图中称之为整个渲染流程。这个流程在上文中其实已经说到了,下面咱们以流程图的形式宏观的了解一下,流程图以下:express
从图中咱们也能够看到,模板编译过程就是把用户写的模板通过一系列处理最终生成render
函数的过程。编程
那么模板编译内部是怎么把用户写的模板通过处理最终生成render
函数的呢?这内部的过程是怎样的呢?
Vue
如何从<template></template>
标签中写的模板字符串中提取出元素的标签,属性,变量等,就要借助一个叫作抽象语法树的东西
所谓抽象语法树,在计算机科学中,抽象语法树(AbstractSyntaxTree,AST
),或简称语法树(Syntax tree
),是源代码语法结构的一种抽象表示。它以树状的形式表现编程语言的语法结构,树上的每一个节点都表示源代码中的一种结构。之因此说语法是“抽象”的,是由于这里的语法并不会表示出真实语法中出现的每一个细节。好比,嵌套括号被隐含在树的结构中,并无以节点的形式呈现;而相似于if-condition-then
这样的条件跳转语句,可使用带有两个分支的节点来表示。——来自百度百科
将一堆字符串模板解析成抽象语法树AST
后,咱们就能够对其进行各类操做处理了,处理完后用处理后的AST
来生成render
函数。其具体流程可大体分为三个阶段
一、模板解析阶段:将一堆模板字符串用正则等方式解析成抽象语法树AST
;
二、优化阶段:遍历AST
,找出其中的静态节点,并打上标记;
三、代码生成阶段:将AST
转换成渲染函数;
这三个阶段在源码中分别对应三个模块,下面给出三个模块的源代码在源码中的路径:
一、模板解析阶段——解析器——源码路径:src/compiler/parser/
index.js`;
二、优化阶段——优化器——源码路径:src/compiler/optimizer.js
;
三、代码生成阶段——代码生成器——源码路径:src/compiler/codegen/index.js
; 其对应的源码以下:
// 源码位置: /src/complier/index.js
function baseCompile (
template: string,
options: CompilerOptions
): CompiledResult {
/*parse解析获得ast树*/
const ast = parse(template.trim(), options)
/*
将AST树进行优化
优化的目标:生成模板AST树,检测不须要进行DOM改变的静态子树。
一旦检测到这些静态树,咱们就能作如下这些事情:
1.把它们变成常数,这样咱们就不再须要每次从新渲染时建立新的节点了。
2.在patch的过程当中直接跳过。
*/
optimize(ast, options)
/*根据ast树生成所需的code(内部包含render与staticRenderFns)*/
const code = generate(ast, options)
return {
ast,
render: code.render,
staticRenderFns: code.staticRenderFns
}
}
复制代码
能够看到 baseCompile
的代码很是的简短主要核心代码。
一、const ast =parse(template.trim(), options):parse
会用正则等方式解析 template
模板中的指令、class
、style
等数据,造成AST
。
二、optimize(ast, options): optimize
的主要做用是标记静态节点,这是 Vue
在编译过程当中的一处优化,挡在进行patch
的过程当中,DOM-Diff
算法会直接跳过静态节点,从而减小了比较的过程,优化了 patch
的性能。
三、const code =generate(ast, options): 将 AST
转化成render
函数字符串的过程,获得结果是render
函数的字符串以及staticRenderFns
字符串。
最终baseCompile
的返回值
{
ast: ast,
render: code.render,
staticRenderFns: code.staticRenderFns
}
复制代码
最终返回了抽象语法树( ast
),渲染函数( render
),静态渲染函数( staticRenderFns
),且render
的值为code.render
,staticRenderFns
的值为code.staticRenderFns
,也就是说经过 generate
处理 ast
以后获得的返回值 code
是一个对象。
下面再给出模板编译内部具体流程图,便于理解。流程图以下:
在解析整个模板的时候它的流程应该是这样子的:HTML解析器是主线,先用HTML解析器进行解析整个模板,在解析过程当中若是碰到文本内容,那就调用文本解析器来解析文本,若是碰到文本中包含过滤器那就调用过滤器解析器来解析。以下图所示:
解析器的源码位于/src/complier/parser
文件夹下,其主线代码以下:
// 代码位置:/src/complier/parser/index.js
/**
* Convert HTML string to AST.
*/
export function parse(template, options) {
// ...
parseHTML(template, {
warn,
expectHTML: options.expectHTML,
isUnaryTag: options.isUnaryTag,
canBeLeftOpenTag: options.canBeLeftOpenTag,
shouldDecodeNewlines: options.shouldDecodeNewlines,
shouldDecodeNewlinesForHref: options.shouldDecodeNewlinesForHref,
shouldKeepComment: options.comments,
start (tag, attrs, unary) {
if (inVPre) {
...
} else {
/*处理属性*/
processAttrs(element)
}
},
end () {
},
//这个地方处理 parseText
chars (text: string) {
if (text) {
let expression
if (!inVPre && text !== ' ' && (expression = parseText(text, delimiters))) {
children.push({
type: 2,
expression,
text
})
}
}
},
comment (text: string) {
}
})
return root
}
/*处理属性*/
function processAttrs (el) {
/*获取元素属性列表*/
const list = el.attrsList
let i, l, name, rawName, value, modifiers, isProp
for (i = 0, l = list.length; i < l; i++) {
.....
/*若是属性是v-bind的*/
if (bindRE.test(name)) { // v-bind
/*这样处理之后v-bind:aaa获得aaa*/
name = name.replace(bindRE, '')
.....
/*解析过滤器*/
value = parseFilters(value)
....
}
} else {
/*处理常规的字符串属性*/
// literal attribute
if (process.env.NODE_ENV !== 'production') {
const expression = parseText(value, delimiters)
....
}
}
}
}
复制代码
从上面代码中能够看到,parse
函数就是解析器的主函数,在parse
函数内调用了parseHTML
函数对模板字符串进行解析,在parseHTML
函数解析模板字符串的过程当中,若是遇到文本信息,就会调用文本解析器parseText
函数进行文本解析;若是遇到文本中包含过滤器,就会调用过滤器解析器parseFilters
函数进行解析。
parseHTML
在源码中,HTML
解析器就是parseHTML
函数,在模板解析主线函数parse
中调用了该函数,并传入两个参数,代码如上: 从代码中咱们能够看到,调用parseHTML
函数时为其传入的两个参数分别是:
一、template
:待转换的模板字符串;
二、options
:转换时所需的选项;
第一个参数是待转换的模板字符串,无需多言;重点看第二个参数,第二个参数提供了一些解析HTML
模板时的一些参数,同时还定义了4个钩子函数。这4个钩子函数有什么做用呢?咱们说了模板编译阶段主线函数parse
会将HTML
模板字符串转化成AST
,而parseHTML
是用来解析模板字符串的,把模板字符串中不一样的内容出来以后,那么谁来把提取出来的内容生成对应的AST
呢?答案就是这4个钩子函数
把这4个钩子函数做为参数传给解析器parseHTML
,当解析器解析出不一样的内容时调用不一样的钩子函数从而生成不一样的AST
。
paseHTML
源码以下:
function parseHTML(html, options) {
const stack = [] // 维护AST节点层级的栈
const expectHTML = options.expectHTML
const isUnaryTag = options.isUnaryTag || no
const canBeLeftOpenTag = options.canBeLeftOpenTag || no //用来检测一个标签是不是能够省略闭合标签的非自闭合标签
let index = 0 //解析游标,标识当前从何处开始解析模板字符串
let last, // 存储剩余还未解析的模板字符串
lastTag // 存储着位于 stack 栈顶的元素
// 开启一个 while 循环,循环结束的条件是 html 为空,即 html 被 parse 完毕
while (html) {
last = html;
// 确保即将 parse 的内容不是在纯文本标签里 (script,style,textarea)
if (!lastTag || !isPlainTextElement(lastTag)) {
let textEnd = html.indexOf('<')
/**
* 若是html字符串是以'<'开头,则有如下几种可能
* 开始标签:<div>
* 结束标签:</div>
* 注释:<!-- 我是注释 -->
* 条件注释:<!-- [if !IE] --> <!-- [endif] -->
* DOCTYPE:<!DOCTYPE html>
* 须要一一去匹配尝试
*/
if (textEnd === 0) {
// 解析是不是注释
if (comment.test(html)) {
}
// 解析是不是条件注释
if (conditionalComment.test(html)) {
}
// 解析是不是DOCTYPE
const doctypeMatch = html.match(doctype)
if (doctypeMatch) {
}
// 解析是不是结束标签
const endTagMatch = html.match(endTag)
if (endTagMatch) {
}
// 匹配是不是开始标签
const startTagMatch = parseStartTag()
if (startTagMatch) {
}
}
// 若是html字符串不是以'<'开头,则解析文本类型
let text, rest, next
if (textEnd >= 0) {
}
// 若是在html字符串中没有找到'<',表示这一段html字符串都是纯文本
if (textEnd < 0) {
text = html
html = ''
}
// 把截取出来的text转化成textAST
if (options.chars && text) {
options.chars(text)
}
} else {
// 父元素为script、style、textarea时,其内部的内容所有当作纯文本处理
}
//将整个字符串做为文本对待
if (html === last) {
options.chars && options.chars(html);
if (!stack.length && options.warn) {
options.warn(("Mal-formatted tag at end of template: \"" + html + "\""));
}
break
}
}
// Clean up any remaining tags
parseEndTag();
//parse 开始标签
function parseStartTag() {
}
//处理 parseStartTag 的结果
function handleStartTag(match) {
}
//parse 结束标签
function parseEndTag(tagName, start, end) {
}
}
复制代码
start
函数生成元素类型的AST
节点,代码以下;// 当解析到标签的开始位置时,触发start
start (tag, attrs, unary) {
const element: ASTElement = {
type: 1,
tag,
attrsList: attrs,
attrsMap: makeAttrsMap(attrs),
parent: currentParent,
children: []
}
}
复制代码
从上面代码中咱们能够看到,start
函数接收三个参数,分别是标签名tag
、标签属性attrs
、标签是否自闭合unary
。当调用该钩子函数时,内部会调用createASTElement
函数来建立元素类型的AST节点
end
函数;chars
函数生成文本类型的AST
节点;// 当解析到标签的文本时,触发chars
chars (text) {
if (text) {
let expression
if (!inVPre && text !== ' ' && (expression = parseText(text, delimiters))) {
children.push({
type: 2,
expression,
text
})
} else if (text !== ' ' || !children.length || children[children.length - 1].text !== ' ') {
children.push({
type: 3,
text
})
}
}
}
复制代码
当解析到标签的文本时,触发chars
钩子函数,在该钩子函数内部,首先会判断文本是否是一个带变量的动态文本,如“hello ”
。若是是动态文本,则建立动态文本类型的AST
节点;若是不是动态文本,则建立纯静态文本类型的AST
节点。
comment
函数生成注释类型的AST节点;comment (text: string, start, end) {
// adding anyting as a sibling to the root node is forbidden
// comments should still be allowed, but ignored
if (currentParent) {
const child: ASTText = {
type: 3,
text,
isComment: true
}
if (process.env.NODE_ENV !== 'production' && options.outputSourceRange) {
child.start = start
child.end = end
}
currentParent.children.push(child)
}
}
复制代码
当解析到标签的注释时,触发comment
钩子函数,该钩子函数会建立一个注释类型的AST
节点。
一边解析不一样的内容一边调用对应的钩子函数生成对应的AST
节点,最终完成将整个模板字符串转化成AST
,这就是HTML
解析器所要作的工做。
要从模板字符串中解析出不一样的内容,那首先要知道模板字符串中都会包含哪些内容。那么一般咱们所写的模板字符串中都会包含哪些内容呢?通过整理,一般模板内会包含以下内容:
解析注释比较简单,咱们知道HTML注释是以<!--开
头,以-->
结尾,这二者中间的内容就是注释内容,那么咱们只需用正则判断待解析的模板字符串html是否以<!--
开头,如果,那就继续向后寻找-->
,若是找到了,OK,注释就被解析出来了。代码以下:
const comment = /^<!\--/
if (comment.test(html)) {
// 若为注释,则继续查找是否存在'-->'
const commentEnd = html.indexOf('-->')
if (commentEnd >= 0) {
// 若存在 '-->',继续判断options中是否保留注释
if (options.shouldKeepComment) {
// 若保留注释,则把注释截取出来传给options.comment,建立注释类型的AST节点
options.comment(html.substring(4, commentEnd))
}
// 若不保留注释,则将游标移动到'-->'以后,继续向后解析
advance(commentEnd + 3)
continue
}
}
function advance (n) {
index += n // index为解析游标
html = html.substring(n)
}
复制代码
在上面代码中,若是模板字符串html
符合注释开始的正则,那么就继续向后查找是否存在-->
,若存在,则把html从第4位("<!--
"长度为4)开始截取,直到-->
处,截取获得的内容就是注释的真实内容,而后调用4个钩子函数中的comment
函数,将真实的注释内容传进去,建立注释类型的AST
节点。
上面代码中有一处值得注意的地方,那就是咱们日常在模板中能够在<template></template>
标签上配置comments
选项来决定在渲染模板时是否保留注释,对应到上面代码中就是options.shouldKeepComment
,若是用户配置了comments
选项为true
,则shouldKeepComment
为true
,则建立注释类型的AST
节点,如不保留注释,则将游标移动到'-->'
以后,继续向后解析。
解析条件注释也比较简单,其原理跟解析注释相同,都是先用正则判断是不是以条件注释特有的开头标识开始,而后寻找其特有的结束标识,若找到,则说明是条件注释,将其截取出来便可,因为条件注释不存在于真正的DOM
树中,因此不须要调用钩子函数建立AST
节点。代码以下:
// 解析是不是条件注释
const conditionalComment = /^<!\[/
if (conditionalComment.test(html)) {
// 若为条件注释,则继续查找是否存在']>'
const conditionalEnd = html.indexOf(']>')
if (conditionalEnd >= 0) {
// 若存在 ']>',则从本来的html字符串中把条件注释截掉,
// 把剩下的内容从新赋给html,继续向后匹配
advance(conditionalEnd + 2)
continue
}
}
复制代码
相较于前三种内容的解析,解析开始标签会稍微复杂一点,可是万变不离其宗,它的原理仍是相通的,都是使用正则去匹配提取。
首先使用开始标签的正则去匹配模板字符串,看模板字符串是否具备开始标签的特征,以下:
/**
* 匹配开始标签的正则
*/
const ncname = '[a-zA-Z_][\\w\\-\\.]*'
const qnameCapture = `((?:${ncname}\\:)?${ncname})`
const startTagOpen = new RegExp(`^<${qnameCapture}`)
const start = html.match(startTagOpen)
if (start) {
const match = {
tagName: start[1],
attrs: [],
start: index
}
}
// 以开始标签开始的模板:
'<div></div>'.match(startTagOpen) => ['<div','div',index:0,input:'<div></div>']
// 以结束标签开始的模板:
'</div><div></div>'.match(startTagOpen) => null
// 以文本开始的模板:
'我是文本</p>'.match(startTagOpen) => null
复制代码
在上面代码中,咱们用不一样类型的内容去匹配开始标签的正则,发现只有<div></div>
的字符串能够正确匹配,而且返回一个数组。
在前文中咱们说到,当解析到开始标签时,会调用4个钩子函数中的start
函数,而start
函数须要传递3个参数,分别是标签名tag
、标签属性attrs
、标签是否自闭合unary
。标签名经过正则匹配的结果就能够拿到,即上面代码中的start[1]
,而标签属性attrs
以及标签是否自闭合unary
须要进一步解析。
一、解析标签属性
咱们知道,标签属性通常是写在开始标签的标签名以后的,以下:
<div class="a" id="b"></div>
复制代码
另外,咱们在上面匹配是不是开始标签的正则中已经能够拿到开始标签的标签名,即上面代码中的start[0]
,那么咱们能够将这一部分先从模板字符串中截掉,则剩下的部分以下:
class="a" id="b"></div>
复制代码
那么咱们只需用剩下的这部分去匹配标签属性的正则,就能够将标签属性提取出来了,以下:
const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/
let html = 'class="a" id="b"></div>'
let attr = html.match(attribute)
console.log(attr)
// ["class="a"", "class", "=", "a", undefined, undefined, index: 0, input: "class="a" id="b"></div>", groups: undefined]
复制代码
能够看到,第一个标签属性class="a"
已经被拿到了。另外,标签属性有可能有多个也有可能没有,若是没有的话那好办,匹配标签属性的正则就会匹配失败,标签属性就为空数组;而若是标签属性有多个的话,那就须要循环匹配了,匹配出第一个标签属性后,就把该属性截掉,用剩下的字符串继续匹配,直到再也不知足正则为止,代码以下:
const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/
const startTagClose = /^\s*(\/?)>/
const match = {
tagName: start[1],
attrs: [],
start: index
}
while (!(end = html.match(startTagClose)) && (attr = html.match(attribute))) {
advance(attr[0].length)
match.attrs.push(attr)
}
复制代码
在上面代码的while
循环中,若是剩下的字符串不符合开始标签的结束特征(startTagClose
)而且符合标签属性的特征的话,那就说明还有未提取出的标签属性,那就进入循环,继续提取,直到把全部标签属性都提取完毕。
所谓不符合开始标签的结束特征是指当前剩下的字符串不是以开始标签结束符开头的,咱们知道一个开始标签的结束符有多是一个>
(非自闭合标签),也有多是/>
(自闭合标签),若是剩下的字符串(如></div>
)以开始标签的结束符开头,那么就表示标签属性已经被提取完毕了。
二、解析标签是不是自闭合
在HTML中,有自闭合标签(如<img src=""/>
)也有非自闭合标签(如<div></div>
),这两种类型的标签在建立AST节点是处理方式是有区别的,因此咱们须要解析出当前标签是不是自闭合标签。
解析的方式很简单,咱们知道,通过标签属性提取以后,那么剩下的字符串无非就两种,以下: `
<!--非自闭合标签-->
></div>
复制代码
<!--自闭合标签-->
/>
复制代码
因此咱们能够用剩下的字符串去匹配开始标签结束符正则,以下:
const startTagClose = /^\s*(\/?)>/
let end = html.match(startTagClose)
'></div>'.match(startTagClose) // [">", "", index: 0, input: "></div>", groups: undefined]
'/>'.match(startTagClose) // ["/>", "/", index: 0, input: "/><div></div>", groups: undefined]
复制代码
能够看到,非自闭合标签匹配结果中的end[1]
为""
,而自闭合标签匹配结果中的end[1]
为"/"
。因此根据匹配结果的
const startTagClose = /^\s*(\/?)>/
let end = html.match(startTagClose)
if (end) {
match.unarySlash = end[1]
advance(end[0].length)
match.end = index
return match
}
复制代码
通过以上两步,开始标签就已经解析完毕了,完整源码以下:
const ncname = '[a-zA-Z_][\\w\\-\\.]*'
const qnameCapture = `((?:${ncname}\\:)?${ncname})`
const startTagOpen = new RegExp(`^<${qnameCapture}`)
const startTagClose = /^\s*(\/?)>/
function parseStartTag () {
const start = html.match(startTagOpen)
// '<div></div>'.match(startTagOpen) => ['<div','div',index:0,input:'<div></div>']
if (start) {
const match = {
tagName: start[1],
attrs: [],
start: index
}
advance(start[0].length)
let end, attr
/**
* <div a=1 b=2 c=3></div>
* 从<div以后到开始标签的结束符号'>'以前,一直匹配属性attrs
* 全部属性匹配完以后,html字符串还剩下
* 自闭合标签剩下:'/>'
* 非自闭合标签剩下:'></div>'
*/
while (!(end = html.match(startTagClose)) && (attr = html.match(attribute))) {
advance(attr[0].length)
match.attrs.push(attr)
}
/**
* 这里判断了该标签是否为自闭合标签
* 自闭合标签如:<input type='text' />
* 非自闭合标签如:<div></div>
* '></div>'.match(startTagClose) => [">", "", index: 0, input: "></div>", groups: undefined]
* '/><div></div>'.match(startTagClose) => ["/>", "/", index: 0, input: "/><div></div>", groups: undefined]
* 所以,咱们能够经过end[1]是不是"/"来判断该标签是不是自闭合标签
*/
if (end) {
match.unarySlash = end[1]
advance(end[0].length)
match.end = index
return match
}
}
}
复制代码
经过源码能够看到,调用parseStartTag
函数,若是模板字符串符合开始标签的特征,则解析开始标签,并将解析结果返回,若是不符合开始标签的特征,则返回undefined。
解析完毕后,就能够用解析获得的结果去调用start
钩子函数去建立元素型的AST
节点了。
在源码中,Vue
并无直接去调start
钩子函数去建立AST节点,而是调用了handleStartTag
函数,在该函数内部才去调的start
钩子函数,为何要这样作呢?这是由于虽然通过parseStartTag
函数已经把建立AST节点必要信息提取出来了,可是提取出来的标签属性数组仍是须要处理一下,下面咱们就来看一下handleStartTag
函数都作了些什么事。handleStartTag
函数源码以下:
function handleStartTag (match) {
const tagName = match.tagName
const unarySlash = match.unarySlash
if (expectHTML) {
// ...
}
const unary = isUnaryTag(tagName) || !!unarySlash
const l = match.attrs.length
const attrs = new Array(l)
for (let i = 0; i < l; i++) {
const args = match.attrs[i]
const value = args[3] || args[4] || args[5] || ''
const shouldDecodeNewlines = tagName === 'a' && args[1] === 'href'
? options.shouldDecodeNewlinesForHref
: options.shouldDecodeNewlines
attrs[i] = {
name: args[1],
value: decodeAttr(value, shouldDecodeNewlines)
}
}
if (!unary) {
stack.push({ tag: tagName, lowerCasedTag: tagName.toLowerCase(), attrs: attrs })
lastTag = tagName
}
if (options.start) {
options.start(tagName, attrs, unary, match.start, match.end)
}
}
复制代码
handleStartTag
函数用来对parseStartTag
函数的解析结果进行进一步处理,它接收parseStartTag
函数的返回值做为参数。
handleStartTag
函数的开始定义几个常量:
const tagName = match.tagName // 开始标签的标签名
const unarySlash = match.unarySlash // 是否为自闭合标签的标志,自闭合为"",非自闭合为"/"
const unary = isUnaryTag(tagName) || !!unarySlash // 布尔值,标志是否为自闭合标签
const l = match.attrs.length // match.attrs 数组的长度
const attrs = new Array(l) // 一个与match.attrs数组长度相等的数组
复制代码
结束标签的解析要比解析开始标签容易多了,由于它不须要解析什么属性,只须要判断剩下的模板字符串是否符合结束标签的特征,若是是,就将结束标签名提取出来,再调用4个钩子函数中的end函数就行了。
首先判断剩余的模板字符串是否符合结束标签的特征,以下:
const ncname = '[a-zA-Z_][\\w\\-\\.]*'
const qnameCapture = `((?:${ncname}\\:)?${ncname})`
const endTag = new RegExp(`^<\\/${qnameCapture}[^>]*>`)
const endTagMatch = html.match(endTag)
'</div>'.match(endTag) // ["</div>", "div", index: 0, input: "</div>", groups: undefined]
'<div>'.match(endTag) // null
复制代码
上面代码中,若是模板字符串符合结束标签的特征,则会得到匹配结果数组;若是不合符,则获得null。
接着再调用end
钩子函数,以下:
if (endTagMatch) {
const curIndex = index
advance(endTagMatch[0].length)
parseEndTag(endTagMatch[1], curIndex, index)
continue
}
复制代码
解析文本也比较容易,在解析模板字符串以前,咱们先查找一下第一个<出如今什么位置,若是第一个<在第一个位置,那么说明模板字符串是以其它5种类型开始的;若是第一个<不在第一个位置而在模板字符串中间某个位置,那么说明模板字符串是以文本开头的,那么从开头到第一个<出现的位置就都是文本内容了;若是在整个模板字符串里没有找到<,那说明整个模板字符串都是文本。这就是解析思路,接下来咱们对照源码来了解一下实际的解析过程,源码以下:
et textEnd = html.indexOf('<')
// '<' 在第一个位置,为其他5种类型
if (textEnd === 0) {
// ...
}
// '<' 不在第一个位置,文本开头
if (textEnd >= 0) {
// 若是html字符串不是以'<'开头,说明'<'前面的都是纯文本,无需处理
// 那就把'<'之后的内容拿出来赋给rest
rest = html.slice(textEnd)
while (
!endTag.test(rest) &&
!startTagOpen.test(rest) &&
!comment.test(rest) &&
!conditionalComment.test(rest)
) {
// < in plain text, be forgiving and treat it as text
/**
* 用'<'之后的内容rest去匹配endTag、startTagOpen、comment、conditionalComment
* 若是都匹配不上,表示'<'是属于文本自己的内容
*/
// 在'<'以后查找是否还有'<'
next = rest.indexOf('<', 1)
// 若是没有了,表示'<'后面也是文本
if (next < 0) break
// 若是还有,表示'<'是文本中的一个字符
textEnd += next
// 那就把next以后的内容截出来继续下一轮循环匹配
rest = html.slice(textEnd)
}
// '<'是结束标签的开始 ,说明从开始到'<'都是文本,截取出来
text = html.substring(0, textEnd)
advance(textEnd)
}
// 整个模板字符串里没有找到`<`,说明整个模板字符串都是文本
if (textEnd < 0) {
text = html
html = ''
}
// 把截取出来的text转化成textAST
if (options.chars && text) {
options.chars(text)
}
复制代码
值得深究的是若是<不在第一个位置而在模板字符串中间某个位置,那么说明模板字符串是以文本开头的,那么从开头到第一个<出现的位置就都是文本内容了,接着咱们还要从第一个<的位置继续向后判断,由于还存在这样一种状况,那就是若是文本里面原本就包含一个<,例如1<2。为了处理这种状况,咱们把从第一个<的位置直到模板字符串结束都截取出来记做rest,以下:
while (
!endTag.test(rest) &&
!startTagOpen.test(rest) &&
!comment.test(rest) &&
!conditionalComment.test(rest)
) {
// < in plain text, be forgiving and treat it as text
/**
* 用'<'之后的内容rest去匹配endTag、startTagOpen、comment、conditionalComment
* 若是都匹配不上,表示'<'是属于文本自己的内容
*/
// 在'<'以后查找是否还有'<'
next = rest.indexOf('<', 1)
// 若是没有了,表示'<'后面也是文本
if (next < 0) break
// 若是还有,表示'<'是文本中的一个字符
textEnd += next
// 那就把next以后的内容截出来继续下一轮循环匹配
rest = html.slice(textEnd)
}
复制代码
上一章节咱们介绍了HTML
解析器是如何解析各类不一样类型的内容而且调用钩子函数建立不一样类型的AST节点。此时你可能会有个疑问,咱们上面建立的AST节点都是单首创建且分散的,而真正的DOM节点都是有层级关系的,那如何来保证AST节点的层级关系与真正的DOM节点相同呢?
关于这个问题,Vue也注意到了。Vue在HTML解析器的开头定义了一个栈stack
,这个栈的做用就是用来维护AST节点层级的,那么它是怎么维护的呢?经过前文咱们知道,HTML解析器在从前向后解析模板字符串时,每当遇到开始标签时就会调用start
钩子函数,那么在start
钩子函数内部咱们能够将解析获得的开始标签推入栈中,而每当遇到结束标签时就会调用end钩子函数,那么咱们也能够在end钩子函数内部将解析获得的结束标签所对应的开始标签从栈中弹出。请看以下例子:
假若有以下模板字符串:
<div><p><span></span></p></div>
复制代码
当解析到开始标签<div>
时,就把div
推入栈中,而后继续解析,当解析到<p>
时,再把p推入栈中,同理,再把span
推入栈中,当解析到结束标签</span>
时,此时栈顶的标签恰好是span
的开始标签,那么就用span
的开始标签和结束标签构建AST
节点,而且从栈中把span
的开始标签弹出,那么此时栈中的栈顶标签p
就是构建好的span
的AST
节点的父节点,以下图:
parseText
文本解析器的源码位于src/compiler/parser/text-parsre.js
中,代码以下:
const defaultTagRE = /\{\{((?:.|\n)+?)\}\}/g
const buildRegex = cached(delimiters => {
const open = delimiters[0].replace(regexEscapeRE, '\\$&')
const close = delimiters[1].replace(regexEscapeRE, '\\$&')
return new RegExp(open + '((?:.|\\n)+?)' + close, 'g')
})
export function parseText (text,delimiters) {
const tagRE = delimiters ? buildRegex(delimiters) : defaultTagRE
if (!tagRE.test(text)) {
return
}
const tokens = []
const rawTokens = []
/**
* let lastIndex = tagRE.lastIndex = 0
* 上面这行代码等同于下面这两行代码:
* tagRE.lastIndex = 0
* let lastIndex = tagRE.lastIndex
*/
let lastIndex = tagRE.lastIndex = 0
let match, index, tokenValue
while ((match = tagRE.exec(text))) {
index = match.index
// push text token
if (index > lastIndex) {
// 先把'{{'前面的文本放入tokens中
rawTokens.push(tokenValue = text.slice(lastIndex, index))
tokens.push(JSON.stringify(tokenValue))
}
// tag token
// 取出'{{ }}'中间的变量exp
const exp = parseFilters(match[1].trim())
// 把变量exp改为_s(exp)形式也放入tokens中
tokens.push(`_s(${exp})`)
rawTokens.push({ '@binding': exp })
// 设置lastIndex 以保证下一轮循环时,只从'}}'后面再开始匹配正则
lastIndex = index + match[0].length
}
// 当剩下的text再也不被正则匹配上时,表示全部变量已经处理完毕
// 此时若是lastIndex < text.length,表示在最后一个变量后面还有文本
// 最后将后面的文本再加入到tokens中
if (lastIndex < text.length) {
rawTokens.push(tokenValue = text.slice(lastIndex))
tokens.push(JSON.stringify(tokenValue))
}
// 最后把数组tokens中的全部元素用'+'拼接起来
return {
expression: tokens.join('+'),
tokens: rawTokens
}
}
复制代码
咱们看到,除开咱们本身加的注释,代码其实不复杂