# 解析器的作用
解析器的作用就是将模板解析成AST。
<div>
<p>{{name}}</p>
</div>
对应的AST:
{
tag: "div"
type: 1,
staticRoot: false,
static: false,
plain: true,
parent: undefined,
attrsList: [],
attrsMap: {},
children: [
{
tag: "p"
type: 1,
staticRoot: false,
static: false,
plain: true,
parent: {tag: "div", ...},
attrsList: [],
attrsMap: {},
children: [{
type: 2,
text: "{{name}}",
static: false,
expression: "_s(name)"
}]
}
]
}
AST用对象来描述节点,一个对象就是一个节点,对象的属性保存节点所需要的数据。如parent保存了父节点的描述对象;children保存了子节点的描述对象;type表示一个节点类型。当很多个独立的节点通过parent属性和children属性连在一起时,就变成了一个树,而这样一个用对象描述的节点树其实就是AST。
# 解析器内部运行原理
// 构建一个元素类型的AST节点
function createASTElement (tag, attrs, parent) {
return {
type: 1,
tag,
attrsList: attrs,
parent,
children: []
}
}
parseHTML(template, {
/*
tag: 标签名
attrs: 标签属性
unary: 是否是自闭合标签
*/
start (tag, attrs, unary) {
// 每当解析到标签的开始位置时,触发该函数
let element = createASTElement(tag, attrs, currentParent)
},
end () {
// 每当解析到标签的结束位置时,触发该函数
},
chars (text) {
// 每当解析到文本时,触发该函数
let element = {type: 3, text}
},
comment (text) {
// 每当解析到注释时,触发该函数
let element = {type: 3, text, isComment: true}
}
})
基于HTML解析器的逻辑,我们可以在每次触发钩子函数start时,把当前构建的节点推入栈中;每当触发钩子函数end时,就从栈中弹出一个节点。
咱们可以通过一个栈来构建AST的层级关系。
基于HTML解析器的逻辑,我们可以在每次触发钩子函数start时,把当前构建的节点推入栈中;每当触发钩子函数end时,就从栈中弹出一个节点。
解析HTML模板的过程就是循环的过程,简单来说就是用HTML模板字符串来循环,每轮循环都从HTML模板中截取一小段字符串,然后重复以上过程,直到HTML模板被截成一个空字符串时结束循环,解析完毕。
以下面这个例子简单说下解析的执行步骤:
<div>
<h1>我是Berwin</h1>
<p>我今年23岁</p>
</div>
- 模板的开始位置是div的开始标签,于是会触发钩子函数start。start触发后,会先构建一个div节点。此时发现栈是空的,这说明div节点是根节点,因为它没有父节点。最后,将div节点推入栈中,并将模板字符串中的div开始标签从模板中截取掉。
- 这时模板的开始位置是一些空格,这些空格会触发文本节点的钩子函数,在钩子函数里会忽略这些空格。同时会在模板中将这些空格截取掉。
- 这时模板的开始位置是h1的开始标签,于是会触发钩子函数start。与前面流程一样,start触发后,会先构建一个h1节点。此时发现栈的最后一个节点是div节点,这说明h1节点的父节点是div,于是将h1添加到div的子节点中,并且将h1节点推入栈中,同时从模板中将h1的开始标签截取掉。
- 这时模板的开始位置是一段文本,于是会触发钩子函数chars。chars触发后,会先构建一个文本节点,此时发现栈中的最后一个节点是h1,这说明文本节点的父节点是h1,于是将文本节点添加到h1节点的子节点中。由于文本节点没有子节点,所以文本节点不会被推入栈中。最后,将文本从模板中截取掉。
- 这时模板的开始位置是h1结束标签,于是会触发钩子函数end。end触发后,会把栈中最后一个节点弹出来。
- 这时模板的开始位置是一些空格,这些空格会触发文本节点的钩子函数,在钩子函数里会忽略这些空格。同时会在模板中将这些空格截取掉。
- 这时模板的开始位置是p开始标签,于是会触发钩子函数start。start触发后,会先构建一个p节点。由于第 ❺ 步已经从栈中弹出了一个节点,所以此时栈中的最后一个节点是div,这说明p节点的父节点是div。于是将p推入div的子节点中,最后将p推入到栈中,并将p的开始标签从模板中截取掉。
- 这时模板的开始位置又是一段文本,于是会触发钩子函数chars。当chars触发后,会先构建一个文本节点,此时发现栈中的最后一个节点是p节点,这说明文本节点的父节点是p节点。于是将文本节点推入p节点的子节点中,并将文本从模板中截取掉。
- 这时模板的开始位置是p的结束标签,于是会触发钩子函数end。当end触发后,会从栈中弹出一个节点出来,也就是把p标签从栈中弹出来,并将p的结束标签从模板中截取掉。
- 与第 ❷ 步和第 ❻ 步一样,这时模板的开始位置是一些空格,这些空格会触发文本节点的钩子函数并且在钩子函数里会忽略这些空格。同时会在模板中将这些空格截取掉。
- 这时模板的开始位置是div的结束标签,于是会触发钩子函数end。其逻辑与之前一样,把栈中的最后一个节点弹出来,也就是把div弹了出来,并将div的结束标签从模板中截取掉。
- 这时模板已经被截取空了,也就说明HTML解析器已经运行完毕。这时我们会发现栈已经空了,但是我们得到了一个完整的带层级关系的AST语法树。这个AST中清晰写明了每个节点的父节点、子节点及其节点类型。
核心代码:
const ncname = '[a-zA-Z_][\\w\\-\\.]*'
const qnameCapture = `((?:${ncname}\\:)?${ncname})`
// 开始标签
const startTagOpen = new RegExp(`^<${qnameCapture}`)
// 结束标签
const startTagClose = /^\s*(\/?)>/
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)
// 解析标签属性
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]
advance(end[0].length)
return match
}
}
}
如果剩余的HTML不符合开始标签的正则表达式规则parseStartTag返回undefined;如果符合开始标签规则,用解析结果调用start函数即可
// 开始标签
const startTagMatch = parseStartTag()
if (startTagMatch) {
// 将tagName、attrs和unary等数据取出来,然后调用钩子函数将这些数据放到参数中
handleStartTag(startTagMatch)
continue
}
# 截取结束标签
const ncname = '[a-zA-Z_][\\w\\-\\.]*'
const qnameCapture = `((?:${ncname}\\:)?${ncname})`
const endTag = new RegExp(`^<\\/${qnameCapture}[^>]*>`)
const endTagMatch = '</div>'.match(endTag)
const endTagMatch2 = '<div>'.match(endTag)
console.log(endTagMatch) // ["</div>", "div", index: 0, input: "</div>"]
console.log(endTagMatch2) // null
上述代码可以判断出剩余模板是否是结束标签,如果是结束标签:
1.需要截取模板
2.触发钩子函数
精简代码如下
const endTagMatch = html.match(endTag)
if (endTagMatch) {
html = html.substring(endTagMatch[0].length)
options.end(endTagMatch[1])
continue
}
# 截取注释
下面的代码用于截取注释
const comment = /^<!--/
if (comment.test(html)) {
const commentEnd = html.indexOf('-->')
if (commentEnd >= 0) {
// 只有options.shouldKeepComment为真时,才会触发钩子函数,否则只截取模板,不触发钩子函数
if (options.shouldKeepComment) {
options.comment(html.substring(4, commentEnd))
}
html = html.substring(commentEnd + 3)
continue
}
}
# 截取条件注释
条件注释一般用于解决兼容问题,如下:
// 不是IE的情况下会使用这个css文件。
'<![if !IE]><link href="non-ie.css" rel="stylesheet"><![endif]>'
vue中判断是否是条件注释核心的代码:
const conditionalComment = /^<!\[/
if (conditionalComment.test(html)) {
const conditionalEnd = html.indexOf(']>')
if (conditionalEnd >= 0) {
html = html.substring(conditionalEnd + 2)
continue
}
}
例子:
const conditionalComment = /^<!\[/
let html = '<![if !IE]><link href="non-ie.css" rel="stylesheet"><![endif]>'
if (conditionalComment.test(html)) {
const conditionalEnd = html.indexOf(']>')
if (conditionalEnd >= 0) {
html = html.substring(conditionalEnd + 2)
}
}
console.log(html) // '<link href="non-ie.css" rel="stylesheet"><![endif]>'
条件注释不需要出发钩子函数,可以看出vue中写条件注释基本是没用的
# 截取DOCTYPE
DOCTYPE只需截取无需触发钩子函数。截取DOCTYPE的代码如下:
const doctype = /^<!DOCTYPE [^>]+>/i
let html = '<!DOCTYPE html><html lang="en"><head></head><body></body></html>'
const doctypeMatch = html.match(doctype)
if (doctypeMatch) {
html = html.substring(doctypeMatch[0].length)
}
console.log(html) // '<html lang="en"><head></head><body></body></html>'
# 截取文本
如果HTML模板的第一个字符不是不是<那么就可以认定为文本
while (html) {
let text
let textEnd = html.indexOf('<')
// < 之前的所有字符都是文本,直接使用html.substring从模板的最开始位置截取到 < 之前的位置,就可以将文本截取出来
if (textEnd >= 0) {
text = html.substring(0, textEnd)
html = html.substring(textEnd)
}
// 如果模板中找不到 <,就说明整个模板都是文本
if (textEnd < 0) {
text = html
html = ''
}
// 触发钩子函数,将截取出来的文本放到参数中
if (options.chars && text) {
options.chars(text)
}
}
但如果<是文本的一部分该怎么处理呢?如:
1<2</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)
}
}
# 纯文本内容元素的处理
script、style和textarea这三个元素叫做纯文本内容元素,这三个标签包含的内容当做文本处理。
while (html) {
if (!lastTag || !isPlainTextElement(lastTag)) {
// 父元素为正常元素的处理逻辑
} else {
// 父元素为script、style、textarea的处理逻辑
const stackedTag = lastTag.toLowerCase()
const reStackedTag = reCache[stackedTag] || (reCache[stackedTag] = new RegExp('([\\s\\S]*?)(</' + stackedTag + '[^>]*>)', 'i'))
const rest = html.replace(reStackedTag, function (all, text) {
if (options.chars) {
options.chars(text)
}
return ''
})
html = rest
options.end(stackedTag)
}
}
# 文本解析器
这里针对的主要是带变量的文本
hello {{name}}
带变量的文本,我们需要借助文本解析器对它进行二次加工,因为带变量的文本在使用虚拟DOM进行渲染时,需要将变量替换成变量中的值。
parseHTML(template, {
chars (text) {
text = text.trim()
if (text) {
// currentParent当前节点的父节点,也就是栈中的最后一个节点
const children = currentParent.children
let expression
// 带变量的文本
if (expression = parseText(text)) {
// 构建一个带变量的文本类型的AST将其添加到父节点的children属性中
children.push({
type: 2,
expression,
text
})
} else {
// 不带变量的文本直接添加到父节点的children属性中
children.push({
type: 3,
text
})
}
}
}
})
// 判断文本是否为带变量的文本
function parseText (text) {
const tagRE = /\{\{((?:.|\n)+?)\}\}/g
// 如果纯文本直接返回
if (!tagRE.test(text)) {
return
}
const tokens = []
let lastIndex = tagRE.lastIndex = 0
let match, index
while ((match = tagRE.exec(text))) {
index = match.index
// 先把 {{ 前边的文本添加到tokens中
if (index > lastIndex) {
tokens.push(JSON.stringify(text.slice(lastIndex, index)))
}
// 把变量改成 _s(x) 这样的形式也添加到数组中
tokens.push(`_s(${match[1].trim()})`)
// 设置lastIndex来保证下一轮循环时,正则表达式不再重复匹配已经解析过的文本
lastIndex = index + match[0].length
}
// 当所有变量都处理完毕后,如果最后一个变量右边还有文本,就将文本添加到数组中
if (lastIndex < text.length) {
tokens.push(JSON.stringify(text.slice(lastIndex)))
}
return tokens.join('+')
}
带变量的文本被解析后得到expression变量值:
"Hello "+_s(name)
_s函数原理:
function toString (val) {
return val == null
? ''
: typeof val === 'object'
? JSON.stringify(val, null, 2)
: String(val)
}
简单说下JSON.stringify函数,一般我们用这个函数只会传入一个参数,上述代码输入的是三个参数,这三个参数都是什么意思呢?
JSON.stringify(value[, replacer[, space]])
- value: 必需, 要转换的 JavaScript 值(通常为对象或数组)
- replacer:可选。用于转换结果的函数或数组。
如果 replacer 为函数,则 JSON.stringify 将调用该函数,并传入每个成员的键和值。使用返回值而不是原始值。如果此函数返回 undefined,则排除成员。根对象的键是一个空字符串:""。
如果 replacer 是一个数组,则仅转换该数组中具有键值的成员。成员的转换顺序与键在数组中的顺序一样。
- space: 可选,文本添加缩进、空格和换行符,如果 space 是一个数字,则返回值文本在每个级别缩进指定数目的空格,如果 space 大于 10,则文本缩进 10 个空格。space 也可以使用非数字,如:\t。
parseText方法中使用了exec方法用于检索字符串中的正则表达式的匹配。
这里我们简单回顾下这个方法的使用。
语法:
RegExpObject.exec(string)
参数:
string 必需。要检索的字符串。
返回值:
返回一个数组,其中存放匹配的结果。如果未找到匹配,则返回值为 null。
说明:
exec() 方法的功能非常强大,它是一个通用的方法,而且使用起来也比 test() 方法以及支持正则表达式的 String 对象的方法更为复杂。
如果 exec() 找到了匹配的文本,则返回一个结果数组。否则,返回 null。此数组的第 0 个元素是与正则表达式相匹配的文本,第 1 个元素是与 RegExpObject 的第 1 个子表达式相匹配的文本(如果有的话),第 2 个元素是与 RegExpObject 的第 2 个子表达式相匹配的文本(如果有的话),以此类推。除了数组元素和 length 属性之外,exec() 方法还返回两个属性。index 属性声明的是匹配文本的第一个字符的位置。input 属性则存放的是被检索的字符串 string。我们可以看得出,在调用非全局的 RegExp 对象的 exec() 方法时,返回的数组与调用方法 String.match() 返回的数组是相同的。
但是,当 RegExpObject 是一个全局正则表达式时,exec() 的行为就稍微复杂一些。它会在 RegExpObject 的 lastIndex 属性指定的字符处开始检索字符串 string。当 exec() 找到了与表达式相匹配的文本时,在匹配后,它将把 RegExpObject 的 lastIndex 属性设置为匹配文本的最后一个字符的下一个位置。这就是说,您可以通过反复调用 exec() 方法来遍历字符串中的所有匹配文本。当 exec() 再也找不到匹配的文本时,它将返回 null,并把 lastIndex 属性重置为 0。
如果在一个字符串中完成了一次模式匹配之后要开始检索新的字符串,就必须手动地把 lastIndex 属性重置为 0。
无论 RegExpObject 是否是全局模式,exec() 都会把完整的细节添加到它返回的数组中。这就是 exec() 与 String.match() 的不同之处,后者在全局模式下返回的信息要少得多。因此我们可以这么说,在循环中反复地调用 exec() 方法是唯一一种获得全局模式的完整模式匹配信息的方法。
这里注意下和match的区别:
1.exec是正则表达式方法,不是字符串方法,它的参数是字符串;match是字符串提供的方法,参数是正则表达式
res=/\d/.exec(str)
res=str.match(/\d/)
2.g对exec没有任何影响;
- 没g没分组
只返回第一个匹配元素;
var str = 'cat1,cat2'
var p = /at/;
console.log(p.exec(str)) // ["at", index: 1, input: "cat1,cat2", groups: undefined]
console.log(str.match(p)) // ["at", index: 1, input: "cat1,cat2", groups: undefined]
- 有g没分组 exec只返回第一个匹配,而match会返回所有的匹配。
var str1 = 'cat1,cat2';
var p1 = /at/g
console.log(p1.exec(str1)) // ["at", index: 1, input: "cat1,cat2", groups: undefined]
console.log(str1.match(p1)) // ["at", "at"]
- 没g有分组 返回的数组包含多个元素,第一个元素是找到的匹配,之后的依次是该匹配的第一个、第二个。。。个分组
var str2 = 'cat1,cat2,cat3,cat4'
var p2 = /(c)(at)(\d)/;
console.log(p2.exec(str2)) // ["cat1", "c", "at", "1", index: 0, input: "cat1,cat2,cat3,cat4", groups: undefined]
console.log(str2.match(p2)) // ["cat1", "c", "at", "1", index: 0, input: "cat1,cat2,cat3,cat4", groups: undefined]
- 有g有分组 返回找到所有同时满足所有分组的元素。
var str2 = 'cat1,cat2,cat3,cat4'
var p2 = /(c)(at)(\d)/g;
console.clear()
console.log(p2.exec(str2)) // ["cat1", "c", "at", "1", index: 0, input: "cat1,cat2,cat3,cat4", groups: undefined]
console.log(str2.match(p2)) // ["cat1", "cat2", "cat3", "cat4"]
# 总结
解析器的作用是通过模板得到AST(抽象语法树)。
生成AST的过程需要借助HTML解析器,当HTML解析器触发不同的钩子函数时,我们可以构建出不同的节点。
随后,我们可以通过栈来得到当前正在构建的节点的父节点,然后将构建出的节点添加到父节点的下面。
最终,当HTML解析器运行完毕后,我们就可以得到一个完整的带DOM层级关系的AST。
HTML解析器的内部原理是一小段一小段地截取模板字符串,每截取一小段字符串,就会根据截取出来的字符串类型触发不同的钩子函数,直到模板字符串截空停止运行。
文本分两种类型,不带变量的纯文本和带变量的文本,后者需要使用文本解析器进行二次加工。