版本:v3.3.4
模板AST解析器 parser
在编译器的编译过程中负责将 模板字符串 解析为模板AST,如下图所示:
模板字符串解析是编译器的第一步,如下面的源码所示:
js
// packages/compiler-core/src/compile.ts
export function baseCompile(
template: string | RootNode,
options: CompilerOptions = {}
): CodegenResult {
// 省略部分代码
// 1. 将模板字符串解析为成模板AST
const ast = isString(template) ? baseParse(template, options) : template
// 省略部分代码
// 2. 将 模板AST 转换成 JavaScript AST
transform(
ast,
extend({}, options, {
prefixIdentifiers,
nodeTransforms: [
...nodeTransforms,
...(options.nodeTransforms || []) // user transforms
],
directiveTransforms: extend(
{},
directiveTransforms,
options.directiveTransforms || {} // user transforms
)
})
)
// 3. 将JavaScript AST 转换成渲染函数,generate函数会将渲染函数的代码以字符串的形式返回。
return generate(
ast,
extend({}, options, {
prefixIdentifiers
})
)
}
下面,我们从模板解析器的入口函数 baseParse
入手,来探究解析器的工作方式。在解读解析器的源码之前,我们先来简单了解下状态机这个概念。
解析器的实现原理与状态机
状态机是有限状态自动机的简称,是现实事物运行规则抽象而成的一个数学模型 。所谓 "有限状态",就是指有限个状态,而 "自动机" 则意味着伴随着字符的输入,解析器会自动地在不同状态间迁移。而解析器的本质就是状态机,它会逐个读取字符串,在不同状态之间迁移。
文本模式及其对解析器的影响
文本模式指的是 解析器 在工作时所进入的一些特殊状态,在不同的特殊状态下,解析器对文本的解析行为会有所不同。具体来说,当解析器遇到一些特殊标签时,会切换模式,从而影响其对文本的解析行文。这些特殊的标签是:
<title>
标签、<textarea>
标签,当解析器遇到这两个标签时,会切换到RCDATA
模式;<style>、<xmp>、<iframe>、<noembed>、<noframes>、<noscripts>
等标签,当解析器遇到这些标签时,会切换到RAWTEXT
模式;- 当解析器遇到
<![CDATA[
字符串时,会进入CDATA
模式
什么是 RCDATA
?
在 HTML 中,RCDATA
(Raw Character Data
)是指一些特殊的标记,用于包含纯文本 数据,例如 <textarea>
和 <title>
元素中的文本内容。在浏览器渲染这些元素时,会将其中的文本内容视为纯文本,而不会进行 HTML 标记的解析。
解析器的初始模式是 DATA
模式。对于 Vue.js
的模板 DSL
来说,模板中不允许出现 <script>
标签,因此 Vue.js 模板解析器在遇到 <script>
标签时也会切换到 RAWTEXT
模式。
《Vue.js 设计与实现》一书对于解析器在不同模式下对文本的解析行为作了详细的介绍,在 p409~p412
。
baseParse 函数
baseParse
函数是解析器的入口函数,它会将模板字符串解析为模板AST并将其返回。我们来看看 baseParse
函数做了什么事情。
js
// packages/compiler-core/src/parse.ts
export function baseParse(
content: string, // 模板内容
options: ParserOptions = {} // 接下选项
): RootNode {
// 创建解析器上下文对象
const context = createParserContext(content, options)
// 获取解析过程的 column/line/offset 等游标信息
const start = getCursor(context)
// 创建 模板AST 的根节点
return createRoot(
// 解析子节点,作为 root 根节点的 children 属性
parseChildren(context, TextModes.DATA, []),
// 获取模板解析的内容区域,类似于用户选择的文本的区域
getSelection(context, start)
)
}
在 baseParse
函数中:
1、首先调用了 createParserContext
函数来创建解析器上下文,用来维护模板解析过程中程序的各种状态。
2、接着根据上下文获取解析过程的游标信息,由于还未进行解析,所以游标中的 column、line、offset
属性对应的是 template
的起始值。即 column
的初始值为1,line
的初始值为1,offset
的初始值为 0。
3、最后是调用 createRoot
函数创建 模板AST 的根节点并返回根节点,至此 模板AST 生成,模板字符串解析完成。
创建模板AST根节点
js
// packages/compiler-core/src/ast.ts
export function createRoot(
children: TemplateChildNode[],
loc = locStub
): RootNode {
return {
type: NodeTypes.ROOT,
children,
helpers: new Set(),
components: [],
directives: [],
hoists: [],
imports: [],
cached: 0,
temps: 0,
codegenNode: undefined,
loc
}
}
由上面的源码可以看到,createRoot
函数返回了一个 ROOT
类型的根节点对象,并将经过 parseChildren
解析后得到的子节点作为根节点对象的 children
属性,从而构建出一棵 模板AST
抽象语法树。
parseChildren 的状态迁移过程
parseChildren
函数本质上是一个状态机,该状态机有多少种状态取决于子节点的类型数量。在模板中,元素的子节点可以是以下几种:
- 标签节点,例如
<div>
- 文本插值节点,例如
{{ val }}
- 普通文本节点, 例如:
text
- 注释节点,例如
<!---->
- CDATA 节点,例如
<![CDATA[ xxx ]]>
下面,我们通过一个图来理解 parseChildren
函数在解析模板过程中的状态迁移过程:
我们把上图所展示的状态迁移过程总结如下:
-
当遇到字符
<
时,进入临时状态:- 如果下一个字符匹配正则
/a-z/i
,则认为这是一个标签节点 ,于是调用parseElement
函数完成标签的解析。 - 如果字符串以
<!--
开头,则认为这是一个注释节点 ,于是调用parseComment
函数完成注释节点的解析。 - 如果字符串以
<![DATA[
开头,则认为这是一个CDATA
节点,于是调用parseCDATA
函数完成 CDATA 节点的解析。
- 如果下一个字符匹配正则
-
如果字符串以
{{
开头,则认为这是一个插值节点,于是调用parseInterpolation
函数完成插值节点的解析。 -
其它情况,都作为普通文本,调用
parseText
函数完成文本节点的解析。
理解了 parseChildren
函数的状态迁移过程,我们开始深入分析parseChildren
是如何解析子节点的。
parseChildren 解析子节点
为了便于理解 parseChildren
函数的主要做的事情,我们对函数代码进行精简,只保留主要逻辑,如下代码所示:
js
// packages/compiler-core/src/parse.ts
function parseChildren(
context: ParserContext,
mode: TextModes,
ancestors: ElementNode[]
): TemplateChildNode[] {
// 获取当前节点的父节点
const parent = last(ancestors)
const ns = parent ? parent.ns : Namespaces.HTML
// 存储解析后的节点
const nodes: TemplateChildNode[] = []
// parseChildren 本质上是一个状态机,因此这里开启一个 while 循环使得状态机自动运行
// 当标签未闭合时,解析对应阶段
while (!isEnd(context, mode, ancestors)) {
// 省略处理逻辑
}
// Whitespace handling strategy like v2
// 处理空白字符,提高输出效率
let removedWhitespace = false
if (mode !== TextModes.RAWTEXT && mode !== TextModes.RCDATA) {
// 省略处理逻辑
}
// 移除空白字符,返回解析后的节点数组
return removedWhitespace ? nodes.filter(Boolean) : nodes
}
从上面的代码中可以看到,parseChildren
函数接收三个参数,它们分别是:
context
:解析器上下文,用来维护模板解析过程中程序的各种状态;mode
:文本模式,如DATA、RCDATA、RAWTEXT、CDATA
等;ancestors
:祖先节点数组。ancestors
参数对于判断parseChildren
函数内的while循环
十分重要,它通过模拟一个栈结构,存储解析器在解析过程中的父级节点。
parseChildren
主要做了以下事情:
- 首先会从当前节点的父节点,并确定
html
的命名空间,该命名空间将在模板解析过程中判断解析器是否处于RCDATA
状态。同时定义了一个nodes
数组,用来存储解析后的节点。 - 由于
parseChildren
本质上是一个状态机,因此在parseChildren
里开启了一个while循环
方式的状态机自动运行,即逐个读取字符串,在不同状态之间迁移,对模板进行解析。 - 接着是对模板中的空白字符进行处理。
- 最后是将解析完成的节点返回。
解析过程
模板解析的核心逻辑在while循环体内,我们接下来重点分析这部分的逻辑。下面是 while 循环的源码:
js
// parseChildren 本质上是一个状态机,因此这里开启一个 while 循环使得状态机自动运行
// 当标签未闭合时,解析对应阶段
while (!isEnd(context, mode, ancestors)) {
__TEST__ && assert(context.source.length > 0)
const s = context.source
let node: TemplateChildNode | TemplateChildNode[] | undefined = undefined
// 只有 DATA 模式和 RCDATA 模式才支持插值节点的解析
if (mode === TextModes.DATA || mode === TextModes.RCDATA) {
if (!context.inVPre && startsWith(s, context.options.delimiters[0])) {
// 插值节点的解析
// '{{'
node = parseInterpolation(context, mode)
} else if (mode === TextModes.DATA && s[0] === '<') {
// 这里进入开始标签的解析
// 只有 DATA 模式才支持标签节点的解析
// https://html.spec.whatwg.org/multipage/parsing.html#tag-open-state
if (s.length === 1) {
emitError(context, ErrorCodes.EOF_BEFORE_TAG_NAME, 1)
} else if (s[1] === '!') {
// 注释节点或 CDATA 节点的解析
// https://html.spec.whatwg.org/multipage/parsing.html#markup-declaration-open-state
if (startsWith(s, '<!--')) {
// 以 <!-- 开头,说明是注释节点,解析注释节点
node = parseComment(context)
} else if (startsWith(s, '<!DOCTYPE')) {
// 如果以 '<!DOCTYPE' 开头,忽略 DOCTYPE,当做伪注释解析
// 如果当前节点是 DOCTYPE 节点,则需要忽略该节点,因为 Vue.js 3 的编译器不支持解析 DOCTYPE 节点。
node = parseBogusComment(context)
} else if (startsWith(s, '<![CDATA[')) {
// 如果以 '<![CDATA[' 开头,又在 HTML 环境中,解析 CDATA
if (ns !== Namespaces.HTML) {
node = parseCDATA(context, ancestors)
} else {
emitError(context, ErrorCodes.CDATA_IN_HTML_CONTENT)
node = parseBogusComment(context)
}
} else {
emitError(context, ErrorCodes.INCORRECTLY_OPENED_COMMENT)
node = parseBogusComment(context)
}
} else if (s[1] === '/') {
// 进入结束标签的解析
// https://html.spec.whatwg.org/multipage/parsing.html#end-tag-open-state
if (s.length === 2) {
// 标签名错误,报错
emitError(context, ErrorCodes.EOF_BEFORE_TAG_NAME, 2)
} else if (s[2] === '>') {
// 如果源模板字符串的第三个字符位置是 '>',那么就是自闭合标签,前进三个字符的扫描位置
emitError(context, ErrorCodes.MISSING_END_TAG_NAME, 2)
// 消费字符串
advanceBy(context, 3)
continue
} else if (/[a-z]/i.test(s[2])) {
// 无效的结束标签
emitError(context, ErrorCodes.X_INVALID_END_TAG)
// 解析标签
parseTag(context, TagType.End, parent)
continue
} else {
emitError(
context,
ErrorCodes.INVALID_FIRST_CHARACTER_OF_TAG_NAME,
2
)
node = parseBogusComment(context)
}
} else if (/[a-z]/i.test(s[1])) {
// 标签节点的解析
node = parseElement(context, ancestors)
// 2.x <template> with no directive compat
// 处理不带指令的 <template> 元素
if (
__COMPAT__ &&
isCompatEnabled(
CompilerDeprecationTypes.COMPILER_NATIVE_TEMPLATE,
context
) &&
node &&
node.tag === 'template' &&
!node.props.some(
p =>
p.type === NodeTypes.DIRECTIVE &&
isSpecialTemplateDirective(p.name)
)
) {
__DEV__ &&
warnDeprecation(
CompilerDeprecationTypes.COMPILER_NATIVE_TEMPLATE,
context,
node.loc
)
node = node.children
}
} else if (s[1] === '?') {
// 如果第二个字符是 ? , 则当做为注释解析
emitError(
context,
ErrorCodes.UNEXPECTED_QUESTION_MARK_INSTEAD_OF_TAG_NAME,
1
)
node = parseBogusComment(context)
} else {
// 都不是以上这些情况,则报出第一个字符不是合法标签字符的错误。
emitError(context, ErrorCodes.INVALID_FIRST_CHARACTER_OF_TAG_NAME, 1)
}
}
}
// 如果上面的情况都解析完毕后,没有创建对应的节点,则当作文本来解析
if (!node) {
node = parseText(context, mode)
}
// 如果解析出来的节点是数组,则遍历将其添加进 node 数组中
if (isArray(node)) {
for (let i = 0; i < node.length; i++) {
pushNode(nodes, node[i])
}
} else {
pushNode(nodes, node)
}
}
首先会判断解析器所处的文本模式 ,只有当文本模式为 DATA
模式或 CDATA
模式时才会对模板进行解析。如下代码:
js
if (mode === TextModes.DATA || mode === TextModes.RCDATA) { // 省略解析逻辑 }
第一种情况是对插值节点的处理。
如果当前节点没有使用 v-pre
指令来跳过插值节点的解析,并且当前解析的字符串以 {{
开头,则认为这是一个插值节点,于是调用 parseInterpolation
函数对插值节点进行解析。如下面的代码:
js
if (!context.inVPre && startsWith(s, context.options.delimiters[0])) {
// 插值节点的解析
// '{{'
node = parseInterpolation(context, mode)
}
从上面的代码中我们也可以发现,如果我们不希望使用双大号作为表达式插值,那么我们可以修改编译器的delimiters
选项即可,例如我们使用 ES6
模板字符串作为表达式插值,用法如下:
js
// 将分隔符设置为 ES6 模板字符串风格
app.config.compilerOptions.delimiters = ['${', '}']
接下来判断第一个字符是否是 "<"
,如果是,并且第二个字符是 '!'
,会尝试去解析下面三种节点:
- 注释节点
DOCTYPE
节点CDATA
节点
解析注释节点
如果字符串以 <!--
开头,说明是注释 节点,则调用 parseComment
函数解析注释节点,如下代码所示:
js
if (startsWith(s, '<!--')) {
// 以 <!-- 开头,说明是注释节点,解析注释节点
node = parseComment(context)
}
解析 DOCTYPE节点
如果字符串以 '<!DOCTYPE'
开头,那么忽略 DOCTYPE
,将字符串当做伪注释解析,调用 parseBogusComment
函数完成解析。如下代码所示:
c
else if (startsWith(s, '<!DOCTYPE')) {
// 如果以 '<!DOCTYPE' 开头,忽略 DOCTYPE,当做伪注释解析
// Ignore DOCTYPE by a limitation.
node = parseBogusComment(context)
}
解析 CDATA节点
如果字符串以 '<![CDATA['
开头,并且不在 HTML
环境中,则调用 parseCDATA
函数解析 CDATA
节点。否则当作为注释进行解析。如下代码所示:
js
else if (startsWith(s, '<![CDATA[')) {
// 如果以 '<![CDATA[' 开头,又在 HTML 环境中,解析 CDATA
if (ns !== Namespaces.HTML) {
node = parseCDATA(context, ancestors)
} else {
emitError(context, ErrorCodes.CDATA_IN_HTML_CONTENT)
node = parseBogusComment(context)
}
}
如果第一个字符是 "<"
,并且第二个字符是 '/'
,则会尝试**结束标签(</
)**的解析。
如果只有两个字符串(<>、</
之类的),说明结束标签错误,则会报错。如下代码所示:
js
if (s.length === 2) {
// 标签名错误,报错
emitError(context, ErrorCodes.EOF_BEFORE_TAG_NAME, 2)
}
如果源模板字符串的第三个字符位置是 '>'
,那么就是**自闭合(</>
)**标签,让解析器前进三个字符的扫描位置,跳过"</>"
,如下代码所示:
js
else if (s[2] === '>') {
// 如果源模板字符串的第三个字符位置是 '>',那么就是自闭合标签,前进三个字符的扫描位置
emitError(context, ErrorCodes.MISSING_END_TAG_NAME, 2)
// 消费字符串
advanceBy(context, 3)
continue
}
如果第一个字符是 '<'
,且第二个字符是 '/'
,并且第三个字符是小写英文字符,比如</x
,此时解析结束标签,如下代码所示:
js
else if (/[a-z]/i.test(s[2])) {
// 无效的结束标签
emitError(context, ErrorCodes.X_INVALID_END_TAG)
// 解析结束标签
parseTag(context, TagType.End, parent)
continue
}
如果第一个字符是 "<",并且第二个字符是 小写英文字符 ,则认为这是一个标签节点(<x
),于是调用 parseElement
完成标签的解析。如下代码所示:
js
else if (/[a-z]/i.test(s[1])) {
// 标签节点的解析
node = parseElement(context, ancestors)
// 省略部分代码
}
如果第一个字符是 "<",并且第二个字符是 "?" ,将字符串当做伪注释(<?
)解析,调用 parseBogusComment
函数完成解析。如下代码所示:
js
else if (s[1] === '?') {
// 如果第二个字符是 ? , 则当做为注释解析
emitError(
context,
ErrorCodes.UNEXPECTED_QUESTION_MARK_INSTEAD_OF_TAG_NAME,
1
)
node = parseBogusComment(context)
}
当尝试在 DATA
模式和 CDATA
模式下没有解析出任何node
节点,这时一切内容都将作为文本处理,如下代码所示:
js
// node 不存在,说明处于其它模式,即非 DATA 模式且非RCDATA模式
// 这是一切内容都作为文本处理
if (!node) {
// 解析文本节点
node = parseText(context, mode)
}
最后如果解析处理的节点是数组,遍历将其添加进 node
数组中,如下代码所示:
js
// 如果解析出来的节点是数组,则遍历将其添加进 node 数组中
if (isArray(node)) {
for (let i = 0; i < node.length; i++) {
pushNode(nodes, node[i])
}
} else {
pushNode(nodes, node)
}
上面就是 while 循环体内解析模板字符串的整个过程。
while 循环何时停止
我们知道,parseChildren
函数本质上是一个状态机 ,它会开启一个 while
循环使得状态机自动运行,如下面的代码所示:
js
// packages/compiler-core/src/parse.ts
function parseChildren(
context: ParserContext,
mode: TextModes,
ancestors: ElementNode[]
): TemplateChildNode[] {
// 省略部分代码
// parseChildren 本质上是一个状态机,因此这里开启一个 while 循环使得状态机自动运行
// 当标签未闭合时,解析对应阶段
while (!isEnd(context, mode, ancestors)) {
// 省略处理逻辑
}
// 省略部分代码
}
那么,状态机何时停止呢?换句话说,while 循环应该何时停止运行 呢?这涉及到 isEnd
函数的判断逻辑。我们来看看 isEnd
函数的源码:
js
function isEnd(
context: ParserContext,
mode: TextModes,
ancestors: ElementNode[] // ancestors 参数模拟栈结构,存储解析过程中的父级节点
): boolean {
const s = context.source
switch (mode) {
// 父级节点栈中存在与当前解析到的结束标签同名的节点,就停止状态机,即退出 while 循环
case TextModes.DATA:
if (startsWith(s, '</')) {
// TODO: probably bad performance
for (let i = ancestors.length - 1; i >= 0; --i) {
if (startsWithEndTagOpen(s, ancestors[i].tag)) {
return true
}
}
}
break
// 父级节点栈中存在与当前解析到的结束标签同名的节点,就停止状态机,即退出 while 循环
case TextModes.RCDATA:
case TextModes.RAWTEXT: {
const parent = last(ancestors)
if (parent && startsWithEndTagOpen(s, parent.tag)) {
return true
}
break
}
// 文本模式 为 CDATA 模式时,字符串以 ]]> 开头,返回 true,停止状态机,即退出 while 循环
case TextModes.CDATA:
if (startsWith(s, ']]>')) {
return true
}
break
}
return !s
}
isEnd
函数的第三个参数 ancestors
模拟栈结构,存储解析过程中的父级节点。以下两种情况会退出 while 循环👇:
- 当父级节点栈 中存在与当前解析到的结束标签同名的节点 时,
isEnd
函会返回true
。即意味着此时停止状态机 ,也就是退出while
循环,结束对节点的解析。- 为什么?因为前面提过,编译器在解析
HTML
文档时,使用状态机的方式进行解析。状态机会根据当前的状态和输入的字符,决定下一步要执行的操作。在解析结束标签时,状态机需要判断当前结束标签的名称是否与父级节点栈中存在的节点名称相同。如果相同,则说明当前结束标签与某个父级节点对应,需要结束对该节点的解析。
- 为什么?因为前面提过,编译器在解析
- 如果当前节点的类型为
CDATA
,则需要判断字符串是否以]]>
开头。如果是,则说明当前节点的内容已经结束,需要停止状态机,即退出 while 循环。- 为什么?因为在
HTML
中,]]> 是一个特殊的字符序列,用于表示 CDATA 节点的结束标记。如果在 CDATA 节点中出现了 ]]> 字符序列,则会被解析器误认为是 CDATA 节点的结束标记,从而导致解析错误。因此,在解析 CDATA 节点时,需要特殊处理 ]]> 字符序列,以避免解析错误的发生。
- 为什么?因为在
总结
本文首先对解析器的实现原理作了简单的介绍。解析器本质上就是一个状态机。接着分析了解析器的核心函数 parseChildren
的实现原理以及实现过程。