0
点赞
收藏
分享

微信扫一扫

学习vue源码(7)手写解析器(中)

柠檬果然酸 2022-03-18 阅读 46


学习vue源码(7)手写解析器(中)_html

2. 解析自闭合标识

如果我们接着上面的例子继续解析的话,目前剩余的模板是下面这样的:

'></div>'

开始标签中结尾部分解析的主要目的是解析出当前这个标签是否是自闭合标签。

举个例子:

<div></div>

这样的div标签就不是自闭合标签,而下面这样的input标签就属于自闭合标签:

<input type="text" />

自闭合标签是没有子节点的,所以前文中我们提到构建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: "/"}

这段代码可以正确解析出开始标签是否是自闭合标签。

从代码中打印出来的结果可以看到,自闭合标签解析后的unarySlash属性为/,而非自闭合标签为空字符串。

3. 实现源码

前面解析开始标签时,我们将其拆解成了三个部分,分别是标签名、属性和结尾。我相信你已经对开始标签的解析有了一个清晰的认识,接下来看一下Vue.js中真实的代码是什么样的:

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
}
}
}

上面的代码是Vue.js中解析开始标签的源码,这段代码中的html变量是HTML模板。

调用parseStartTag就可以将剩余模板开始部分的开始标签解析出来。如果剩余HTML模板的开始部分不符合开始标签的正则表达式规则,那么调用parseStartTag就会返回undefined。因此,判断剩余模板是否符合开始标签的规则,只需要调用parseStartTag即可。如果调用它后得到了解析结果,那么说明剩余模板的开始部分符合开始标签的规则,此时将解析出来的结果取出来并调用钩子函数start即可:

// 开始标签
const startTagMatch = parseStartTag()
if (startTagMatch) {
handleStartTag(startTagMatch)
continue
}

前面我们说过,所有解析操作都运行在循环中,所以continue的意思是这一轮的解析工作已经完成,可以进行下一轮解析工作。

从代码中可以看出,如果调用parseStartTag之后有返回值,那么会进行开始标签的处理,其处理逻辑主要在handleStartTag中。这个函数的主要目的就是将tagName、attrs和unary等数据取出来,然后调用钩子函数将这些数据放到参数中。

3 截取结束标签

结束标签的截取要比开始标签简单得多,因为它不需要解析什么,只需要分辨出当前是否已经截取到结束标签,如果是,那么触发钩子函数就可以了。

那么,如何分辨模板已经截取到结束标签了呢?其道理其实和开始标签的截取相同。

如果HTML模板的第一个字符不是<,那么一定不是结束标签。只有HTML模板的第一个字符是<时,我们才需要进一步确认它到底是不是结束标签。

进一步确认时,我们只需要判断剩余HTML模板的开始位置是否符合正则表达式中定义的规则即可:

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

上面代码可以分辨出剩余模板是否是结束标签。当分辨出结束标签后,需要做两件事,一件事是截取模板,另一件事是触发钩子函数。而Vue.js中相关源码被精简后如下:

const endTagMatch = html.match(endTag)
if (endTagMatch) {
html = html.substring(endTagMatch[0].length)
options.end(endTagMatch[1])
continue
}

可以看出,先对模板进行截取,然后触发钩子函数。

4 截取注释

分辨模板是否已经截取到注释的原理与开始标签和结束标签相同,先判断剩余HTML模板的第一个字符是不是<,如果是,再用正则表达式来进一步匹配:

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
}
}

在上面的代码中,我们使用正则表达式来判断剩余的模板是否符合注释的规则,如果符合,就将这段注释文本截取出来。

这里有一个有意思的地方,那就是注释的钩子函数可以通过选项来配置,只有options.shouldKeepComment为真时,才会触发钩子函数,否则只截取模板,不触发钩子函数。

5 截取条件注释

条件注释不需要触发钩子函数,我们只需要把它截取掉就行了。

截取条件注释的原理与截取注释非常相似,如果模板的第一个字符是<,并且符合我们事先用正则表达式定义好的规则,就说明需要进行条件注释的截取操作。

在下面的代码中,我们通过indexOf找到条件注释结束位置的下标,然后将结束位置前的字符都截取掉:

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]>'

从打印结果中可以看到,HTML中的条件注释部分截取掉了。

通过这个逻辑可以发现,在Vue.js中条件注释其实没有用,写了也会被截取掉,通俗一点说就是写了也白写。

6 截取DOCTYPE

DOCTYPE与条件注释相同,都是不需要触发钩子函数的,只需要将匹配到的这一段字符截取掉即可。下面的代码将DOCTYPE这段字符匹配出来后,根据它的length属性来决定要截取多长的字符串:

const doctype = /^<!DOCTYPE [^>]+>/i
const doctypeMatch = html.match(doctype)
if (doctypeMatch) {
html = html.substring(doctypeMatch[0].length)
continue
}

示例如下:

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中的DOCTYPE被成功截取掉了。

7 截取文本

若想分辨在本轮循环中HTML模板是否已经截取到文本,其实很简单,我们甚至不需要使用正则表达式。

在前面的其他标签类型中,我们都会判断剩余HTML模板的第一个字符是否是<,如果是,再进一步确认到底是哪种类型。这是因为以<开头的标签类型太多了,如开始标签、结束标签和注释等。然而文本只有一种,如果HTML模板的第一个字符不是<,那么它一定是文本了。

例如:

我是文本</div>

上面这段HTML模板并不是以<开头的,所以可以断定它是以文本开头的。

那么,如何从模板中将文本解析出来呢?我们只需要找到下一个<在什么位置,这之前的所有字符都属于文本,如图4所示。

学习vue源码(7)手写解析器(中)_html模板_02图4 尖括号前面的字符都属于文本

在代码中可以这样实现:

while (html) {
let text
let textEnd = html.indexOf('<')

// 截取文本
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)
}
}

上面的代码共有三部分逻辑。

第一部分是截取文本,这在前面介绍过了。<之前的所有字符都是文本,直接使用html.substring从模板的最开始位置截取到<之前的位置,就可以将文本截取出来。

第二部分是一个条件:如果在整个模板中都找不到<,那么说明整个模板全是文本。

第三部分是触发钩子函数并将截取出来的文本放到参数中。

关于文本,还有一个特殊情况需要处理:如果<是文本的一部分,该如何处理?

举个例子:

1<2</div>

在上面这样的模板中,如果只截取第一个<前面的字符,最后被截取出来的将只有1,而不能把所有文本都截取出来。

那么,该如何解决这个问题呢?

有一个思路是,如果将<前面的字符截取完之后,剩余的模板不符合任何需要被解析的片段的类型,就说明这个<是文本的一部分。

什么是需要被解析的片段的类型?我们说过HTML解析器是一段一段截取模板的,而被截取的每一段都符合某种类型,这些类型包括开始标签、结束标签和注释等。

说的再具体一点,那就是上面这段代码中的1被截取完之后,剩余模板是下面的样子:

<2 <2符合开始标签的特征么?不符合。

<2符合结束标签的特征么?不符合。

<2符合注释的特征么?不符合。

当剩余的模板什么都不符合时,就说明<属于文本的一部分。

当判断出<是属于文本的一部分后,我们需要做的事情是找到下一个<并将其前面的文本截取出来加到前面截取了一半的文本后面。

这里还用上面的例子,第二个<之前的字符是<2,那么把<2截取出来后,追加到上一次截取出来的1的后面,此时的结果是:

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)
}
}

在代码中,我们通过while来解决这个问题(注意是里面的while)。如果剩余的模板不符合任何被解析的类型,那么重复解析文本,直到剩余模板符合被解析的类型为止。

在上面的代码中,endTag、startTagOpen、comment和conditionalComment都是正则表达式,分别匹配结束标签、开始标签、注释和条件注释。

在Vue.js源码中,截取文本的逻辑和其他的实现思路一致。



举报

相关推荐

0 条评论