「这是我参与2022首次更文挑战的第7天,活动详情查看:2022首次更文挑战」
前言
我们前面分析了vue的编译入口,算是个开端。从今天开始我们进入到编译的主流程中分析其实现逻辑,实际编译主要分为三步,其中第一步就是生成AST。在源码中,生成AST是从解析template模板开始的,所以我们今天从parseHTML开始。
parse
我们还是从三部曲的入口开始讲起比较好,在compiler/parser/index
const ast = parse(template.trim(), options)
在parse中可以分为两个方面来分析
-
调用
parseHTML解析template -
parseHTML在解析的过程中调用parse中的钩子函数生成AST
parseHTML(template, {
warn,
expectHTML: options.expectHTML,
// ...
start (tag, attrs, unary, start, end) {
// 解析开始标签调用的钩子
},
end (tag, start, end) {
// 解析结束标签调用的钩子
},
chars (text: string, start: number, end: number) {
// 解析文本节点调用的钩子
},
comment (text: string, start, end) {
// 解析注释节点调用的钩子
}
})
parseHTML
我们前面分析了parseHTML的入口,和其配置options来源,现在我们开始分析其实现,了解初步解析tempalte的实现。
我们从实例出发,假设有以下代码片段
<div class="dd" @click="xxx" :class="1" style="color: red;">
hello
<p>Dom</p>
</div>
进到html-parser.js文件,我们可以先看看有以下正则定义
// 属性
const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/
// 动态属性 例如指令/bind/事件
const dynamicArgAttribute = /^\s*((?:v-[\w-]+:|@|:|#)\[[^=]+?\][^\s"'<>\/=]*)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/
// 标签名
const ncname = `[a-zA-Z_][\\-\\.0-9_a-zA-Z${unicodeRegExp.source}]*`
const qnameCapture = `((?:${ncname}\\:)?${ncname})`
// 开始标签
const startTagOpen = new RegExp(`^<${qnameCapture}`)
// 开始标签结束
const startTagClose = /^\s*(\/?)>/
// 结束标签
const endTag = new RegExp(`^<\\/${qnameCapture}[^>]*>`)
// 文档节点
const doctype = /^<!DOCTYPE [^>]+>/i
// 注释节点
// #7298: escape - to avoid being passed as HTML comment when inlined in page
const comment = /^<!\--/
// 条件节点
const conditionalComment = /^<!\[/
其实我们本篇文章的目的就是在于弄清解析的原理,在这里其实已经可以看到个大概了,在compile中即是借助这些强大的正则表达式去匹配字符串来完成解析的,我们下面再继续看看到底是如何做的。
export function parseHTML (html, options) {
// 1
const stack = []
// 2
let index = 0
let last, lastTag
// 3
while (html) {
last = html
// Make sure we're not in a plaintext content element like script/style
if (!lastTag || !isPlainTextElement(lastTag)) {
let textEnd = html.indexOf('<')
if (textEnd === 0) {
// Comment:
// ...
// 4
// End tag:
const endTagMatch = html.match(endTag)
if (endTagMatch) {
const curIndex = index
advance(endTagMatch[0].length)
parseEndTag(endTagMatch[1], curIndex, index)
continue
}
// 5
// Start tag:
const startTagMatch = parseStartTag()
if (startTagMatch) {
handleStartTag(startTagMatch)
if (shouldIgnoreFirstNewline(startTagMatch.tagName, html)) {
advance(1)
}
continue
}
}
// 6
let text, rest, next
if (textEnd >= 0) {
// ...
text = html.substring(0, textEnd)
}
if (textEnd < 0) {
text = html
}
if (text) {
advance(text.length)
}
// 7
if (options.chars && text) {
options.chars(text, index - text.length, index)
}
} else {
// ...
}
// ...
}
// Clean up any remaining tags
parseEndTag()
}
// 用于不断移除已匹配数据
// 类似前进
function advance (n) {
index += n
html = html.substring(n)
}
我将代码简化剩下以上的关键部分,去掉了一些标签的解析,例如注释标签,条件显示标签及有内容的script和style等。接下来通过剩余的代码,我们来理解解析template的实现逻辑即达到目的。
-
定义了stack数组,用于存放我们解析到的标签数据。实际是作为个节点栈来使用。
-
变量index用于存放当前匹配位置,last存放剩余字符串,lastTag存放上次匹配标签
-
while循环,在这我们可以发现,template的解析实际是通过不断地匹配当前字符串
html头部得到的。在匹配头部后,截取头部匹配数据进行相关处理。然后再不断地裁剪html得到未匹配的字符串。 -
匹配结束标签如
div>进行处理。 -
匹配为开始标签如
<div class="dd" @click="xxx" :class="1" style="color: red;"进行处理。 -
匹配文本如
hello进行处理 -
调用
options.chars也就是在parse函数中传入的钩子对文本内容进一步解析。
通过我们前面的分析,其实可以明白
parseHTML中的工作是对html进行初步解析,其实现是通过不断地使用正则匹配html的头部字符串并对其分类处理,然后再移除匹配数据继续后面的匹配。
开始标签
我们上面主要分析了如何匹配,但是没有进行匹配后的分析,我们先看看对于匹配开始标签后的处理。
function parseStartTag () {
const start = html.match(startTagOpen)
if (start) {
// 1
const match = {
tagName: start[1],
attrs: [],
start: index
}
// 2
advance(start[0].length)
// 3
let end, attr
while (!(end = html.match(startTagClose)) && (attr = html.match(dynamicArgAttribute) || html.match(attribute))) {
attr.start = index
advance(attr[0].length)
attr.end = index
match.attrs.push(attr)
}
// ..
}
}
parseStartTag的逻辑比较简单,主要是以下几步
-
匹配到开始标签,为其创建标签对象用于存放数据
-
匹配后通过
advance将起点前进 -
将匹配标签对应的节点属性,不断提取键值信息放在
match.attrs中
在调用parseStartTag得到基本的节点数据后,还会调用handleStartTag对其进行进一步处理
function handleStartTag (match) {
const tagName = match.tagName
const unarySlash = match.unarySlash
const unary = isUnaryTag(tagName) || !!unarySlash
// 1
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] || ''
attrs[i] = {
name: args[1],
value: decodeAttr(value, shouldDecodeNewlines)
}
}
// 2
if (!unary) {
stack.push({ tag: tagName, lowerCasedTag: tagName.toLowerCase(), attrs: attrs, start: match.start, end: match.end })
lastTag = tagName
}
// 3
if (options.start) {
options.start(tagName, attrs, unary, match.start, match.end)
}
}
handleStartTag主要逻辑可以分为以下几步
-
处理匹配的
attrs,提取键值数据 -
往我们开始定义的stack栈中存入节点数据
-
调用
parse中的钩子对数据进行进一步的解析生成AST
处理结束标签
function parseEndTag (tagName, start, end) {
let pos, lowerCasedTagName
if (start == null) start = index
if (end == null) end = index
if (tagName) {
// 1
lowerCasedTagName = tagName.toLowerCase()
for (pos = stack.length - 1; pos >= 0; pos--) {
if (stack[pos].lowerCasedTag === lowerCasedTagName) {
break
}
}
} else {
pos = 0
}
if (pos >= 0) {
for (let i = stack.length - 1; i >= pos; i--) {
if (options.end) {
// 2
options.end(stack[i].tag, start, end)
}
}
stack.length = pos
lastTag = pos && stack[pos - 1].tag
}
}
处理结束标签相对而言会比较简单
-
通过tagName在stack寻找对应的开始标签
-
在stack中寻找到开始标签后,获取其tag并调用结束标签匹配钩子
处理文本节点
对文本的解析就更加简单了,因为我们在这不会去处理文本插值的处理只是单纯获取开发者的文本例如{{text}}。所以就是简单的匹配,匹配前进,再调用对应的钩子函数进一步解析。
let text, rest, next
if (textEnd >= 0) {
text = html.substring(0, textEnd)
}
if (text) {
advance(text.length)
}
if (options.chars && text) {
options.chars(text, index - text.length, index)
}
结语
相信通过本篇文章的学习,我们可以弄清楚模板的处理解析是如何完成的,也就是parseHTML函数的主要实现逻辑。后面我们继续分析parse的另一部分,也就是我们在初步解析中调用的钩子实现。了解是如何通过那些钩子函数去最终生成AST的。