目录
1 解析器的作用
解析器要实现的功能是将模板解析成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)"
}]
}
]
}
2 解析器内部运行原理
解析器也分好几个子解析器,比如:html解析器、文本解析器、过滤解析器,主要是html解析器,html解析器的伪代码如下所示:
parseHTML(template,{
start(tag,attrs,unary){
// 解析到标签开始位置时,触发该函数
},
end(){
// 解析到标签结束位置时,触发该函数
},
chars(text){
// 解析到文本时,触发该函数
},
comment(text){
// 解析到注释时,触发该函数
}
})
比如:<div><p>我是lisa</p></div>当被html解析器解析时,所触发的钩子函数依次是:start、start、chars、end、end;
当html解析器不再触发钩子函数时,就说明所有模板都解析完毕,所有类型的节点都在钩子函数中构建完成,即AST构建完成。
在start钩子函数中,使用三个参数来创建一个元素类型的AST节点,例如:
function createASTElement(tag,attrs,parent){
return {
type:1,
tag,
attrsList:attrs,
parent,
children:[]
}
}
parseHTML(template,{
start(tag,attrs,unary){
let element = createASTElement(tag,attrs,unary)
},
chars(text){
let element = { type:3,text }
},
comment(text){
let element = { type:3,text,isComment:true }
}
})
构建AST层级关系其实非常简单,只需要维护一个栈(stack)即可,用栈来记录层级关系,这个层级关系也可以理解为dom的深度。
html解析器在解析html时,是从前往后解析。每当遇到开始标签,就触发钩子函数start,并把当前节点推入栈中;遇到结束标签,就触发end钩子函数,从栈中弹出一个节点。如下图所示:
模板:
<div>
<h1>我是Berwin</h1>
<p>我今年23岁</p>
</div>
上面模板被解析成AST的过程如下图所示:
3 html解析器
3.1 运行原理
解析html模板的过程就是循环的过程,简单来说就是用html模板字符串来循环,每轮循环从html模板中截取一小段字符串,然后重复以上过程,直到html模板被截成一个空字符串结束循环,解析完毕。
手动模拟html解析器的解析过程:
<div>
<p>{{name}}</p>
</div>
最初始的html模板:
`<div>
<p>{{ name }}</p>
</div>`
第一轮循环时,截取出一段字符串<div>,并触发钩子函数start,截图后的结果为:
`
<p>{{ name }}</p>
</div>`
第二轮时,截取出一段字符串:
`
`
并且触发钩子函数chars,截取后的结果为:
`<p>{{ name }}</p>
</div>`
第三轮循环时,截取出一段字符串<p>,并触发钩子函数start,截取后的结果为:
`{{ name }}</p>
</div>`
第四轮循环时,截取出一段字符串{{ name }},并触发钩子函数chars,截取后的结果为:
`</p>
</div>`
第五轮循环时,截取出一段字符串</p>,并触发钩子函数end,截取结果为:
`
</div>`
第六轮循环时,截取出一段字符串:
`
`
并触发钩子函数chars,截取后的结果为:
`</div>`
第七轮循环时,截取出一段字符串</div>,并触发end钩子函数,截取后的结果为:
``
解析完毕。
被截取的片段分为很多种类型:
1.开始标签;
2.结束标签;
3.html注释,<!-- 注释 -->;
4.DOCTYPE;
5.条件注释;
6.文本;
3.2 截取开始标签
如何确定模板是不是开始标签开头(下面代码不包含标签属性的解析)?
// 正则表达式来匹配模板以开始标签开头
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>"]
// 以结束标签开始的模板
"</div>".match(startTagOpen) // null
当html标签解析到标签开始时,会触发钩子函数start,同时会给出三个参数,分别是标签名(tagName)、属性(attrs)和自闭合标识(unary)。
接下来就是解析标签名、属性、自闭合标识,解析后得到下面的数据结构:
const start = '<div></div>'.match(startTagOpen)
if (start) {
const match = {
tagName: start[1],
attrs: [],
}
}
1.解析标签属性
解析完开始标签开头之后,模板中伪代码的样例如下:' class="box"></div>'
每解析一个属性就截取一个属性。如果截取完后,剩下的html模板依然符合标签属性的正则表达式,那说明还有剩余的属性需要处理,此时需要重复执行前面的流程,直到剩余的模板不存在属性,也就是剩余的模板不存在符合正则表达式所预设的规则。
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)
}
解析后的结果为:
{
tagName: 'div',
attrs: [
[' class="box"', 'class', '=', 'box', null, null],
[' id="el"', 'id', '=', 'el', null, null],
],
}
2.解析自闭合标识
自闭合标签没有子节点的,所以我们提前构建AST层级时,需要维护一个栈,而一个节点是否需要推入到栈中,可以使用自闭合标识来判断。
代码如下:
function parseStartTagEnd(html) {
const startTagClose = /^\s*(\/?)>/
const end = html.match(startTagClose)
const match = {}
if (end) {
match.unarySlash = end[1]
html = html.substring(end[0].length)
return match
}
}
console.log(parseStartTagEnd('></div>')) // {unarySlash:""}
console.log(parseStartTagEnd('/><div></div>')) // {unarySlash:"/"}
3.实现源码
解析开始标签时,被拆分成三个部分,分别是标签名、属性、结尾。vue中的源码如下:
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
}
}
}
如果调用它后得到了解析结果,那么说明剩余模板得开始部分符合开始标签得规则,此时将解析出来得结果取出来并调用钩子函数start即可:
const startTagMatch = parseStartTag()
if(startTagMatch){
handleStartTag(startTagMatch)
continue
}
所有解析操作都运行在循环中,所以continue的意思是这一轮的解析工作已经完成,可以进行下一轮解析工作。
从代码中可以看出,如果调用parseStartTag之后有返回值,那么会进行开始标签的处理,其处理逻辑主要在handleStartTag中。这个函数的主要目的就是将tarName、attrs、unary等数据取出来,然后调用钩子函数将这些数据放到参数中。
3.3 截取结束标签
只有html模板的第一个字符是<时,我们才需要确认它到底是不是结束标签。
正则匹配代码如下:
const ncname = '[a-zA-Z_][\\w\\-\\.]*'
const qnameCapture = `((?:${ncname}\\:)?${ncname})`
const endTag = new RegExp(`^<\\/${qnameCapture}[^>]*>`)
const endTagMatch = '</div>'.match(endTag) // ["</div>","div",index:0,input:"</div>"]
const endTagMatch2 = '<div>'.match(endTag) // null
分辨出剩余模板是否是结束标签后,还需要截取模板,和触发钩子函数。vue源码精简后:
const endTagMatch = html.match(endTag)
if (endTagMatch) {
html = html.substring(endTagMatch[0].length)
urlToHttpOptions.end(endTagMatch[1])
continue
}
3.4 截取注释
const comment = /^<!--/
if(comment.test(html)){
const commentEnd = html.indexOf("-->")
if(commentEnd >= 0){
if(options.shouldKeepComment){
options.comment(html.substring(4,commentEnd))
}
html = html.substring(commentEnd + 3)
continue
}
}
3.5 截取条件注释
const conditionalComment = /^<!\[/
if(conditionalComment.test(html)){
const conditionalEnd = html.indexOf("]>")
if(conditionalEnd){
html = html.substring(conditionalEnd + 2)
continue
}
}
3.6 截取DOCTYPE
const doctype = /^<!DOCTYPE [^>]+>/i
const doctypeMatch = html.match(doctype)
if(doctypeMatch){
html = html.substring(doctypeMatch[0].length)
continue
}
3.7 截取文本
如果html模板的第一个字符不是<,那么它一定是文本了。
while (html) {
let text
let textEnd = html.indexOf("<")
// 截取文本
if(textEnd >= 0){
rest = html.slice(textEnd)
/**
* while用于解决文本中存在<的问题,如果剩余的模板不符合任何被解析的类型,
* 那么重复解析文本,知道剩余模板符号被解析的类型为止
*/
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(0,textEnd)
}
text = html.substring(0,textEnd)
html = html.substring(textEnd)
}
// 如果模板中找不到<,就说明整个模板都是文本
if(textEnd < 0){
text = html
html = ""
}
// 触发钩子函数
if(options.chars && text){
options.chars(text)
}
}
3.8 纯文本内容元素的处理
什么是纯文本内容元素呢?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'
))
// 把文本截取出来并触发钩子函数chars
const rest = html.replace(reStackedTag, function (all, text) {
if (options.chars) {
options.chars(text)
}
return ''
})
html = rest
options.end(stackedTag)
}
}
3.9 使用栈维护dom层级
每解析到开始标签,就向栈中推进去一个;每解析到标签结束,就弹出一个。同时,html解析器中的栈还有另一个作用,可以检测html标签是否正确闭合。
3.10 整体逻辑
如果通过<分辨出即将解析的这一小部分字符不是文本而是标签类,那么标签类有那么多类型,需要进一步分辨是哪种类型:
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 {
// 父元素为script、style、textarea的处理逻辑
}
}
}
4 文本解析器
文本解析器得作用是解析文本。准确得说是对html解析器解析出来得文本进行二次加工。
文本其实分两种类型,一种是纯文本(不需要任何处理),另一种是带变量得文本(需要解析器进一步解析)。
每当html解析器解析到文本时,都会触发chars函数,并且从参数中得到解析出来的文本。在chars函数中,需要构建文本类型的AST,并将它添加到父节点的children属性中。
如果遇到带变量的文本,需要进行二次加工,代码如下所示:
parseHTML(template, {
// ...
chars(text) {
text = text.trim()
if (text) {
const children = currentParent.children
let expression
/**
* 执行parseText后又返回值,说明是带变量的文本,加工后添加到children中;
* 否则说明是普通文本,直接添加到children中
*/
if ((expression = parseText(text))) {
children.push({
type: 2,
expression,
text,
})
} else {
children.push({
type: 3,
text,
})
}
}
},
// ...
})
例如:"Hello {{ name }}"解析后:"Hello_" + _s(name)
上面代码中_s就是下面这个toString函数的别名:
function toString(val) {
return val == null
? ''
: typeof val === 'object'
? JSON.stringify(val, null, 2)
: String(val)
}
案例:
var obj = { name: "Berwin"}
with(obj){
function toString(val) {
return val == null
? ''
: typeof val === 'object'
? JSON.stringify(val, null, 2)
: String(val)
}
console.log("Hello " + toString(name)) // "Hello Berwin"
}
事实上,最终AST会转换成代码字符串放在with中执行。
在文本解析器中,先使用正则表达式来匹配是否为带变量的文本,纯文本返回undefined:
function parseText(text) {
const tagRE = /\{\{((?:.|\n)+?)\}\}/g
if (!tagRE(text)) {
return
}
const tokens = []
let lastIndex = tagRE.lastIndex = 0
let match,index
while (match = tagRE.exec(text)) {
index = match.index
// 先把 {{ 前边的文本添加到token中
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("+")
}
这段代码有个关键的地方在lastIndex:每处理完一个变量后,会重新设置lastIndex的位置,这样可以保证如果后面还有其他变量,那么在下一轮循环时可以从lastIndex的位置开始向后匹配,而lastIndex之前的文本将文本将不再被匹配。
5 总结
解析器的作用是通过模板得到AST。
生成AST的过程需要借助html解析器,当html解析器触发不同的钩子函数时,我们可以构建出不同的节点。
随后,我们通过栈来得到当前正在构建的节点的父节点,然后将构建出的节点添加到父节点的下面。
最终,当html解析器运行完毕后,可以得到一个完整的带dom层级关系的AST。
html解析器的内部原理:一小段一小段地截取模板字符串,每截取一小段字符串,就会根据截取出来的字符串类型触发不同的钩子函数,直到模板字符串截空停止运行。
文本分两种类型,不带变量的纯文本和带变量的文本,带变量的文本需要使用文本解析器进行二次加工。
注:本文章来自于《深入浅出vue.js》(人民邮电出版社)阅读后的笔记整理