模板在《Vue3 源码解读之模板AST 解析器(一) 》一文中,我们介绍了解析器的实现原理与状态机有关,并介绍了解析器的核心处理函数 parseChildren 的解析过程。在本文中,我们将详细介绍解析器解析过程中是如何完成不同节点的解析。
parseElement 解析标签节点
解析器一开始处于 DATA 模式。开始执行解析解析后,解析器遇到的第一个字符为 <,并且第二个字符能够匹配正则表达式 /a-z/i,那么解析器会进入标签节点状态,并调用 parseElement 函数进行解析,源码如下所示:
// packages/compiler-core/src/parse.ts
function parseElement(
context: ParserContext,
ancestors: ElementNode[]
): ElementNode | undefined {
__TEST__ && assert(/^<[a-z]/i.test(context.source))
// Start tag.
// 1. 解析开始标签
const wasInPre = context.inPre
const wasInVPre = context.inVPre
// 获取父级节点栈中的栈顶元素,即当前解析节点的父节点
const parent = last(ancestors)
// 调用 parseTag 解析开始标签
const element = parseTag(context, TagType.Start, parent)
const isPreBoundary = context.inPre && !wasInPre
const isVPreBoundary = context.inVPre && !wasInVPre
if (element.isSelfClosing || context.options.isVoidTag(element.tag)) {
// #4030 自闭和标签 <br />
if (isPreBoundary) {
context.inPre = false
}
if (isVPreBoundary) {
context.inVPre = false
}
return element
}
// Children.
// 2. 递归地调用 parseChildren 解析子节点
// 将解析处理的标签节点压入父级节点栈
ancestors.push(element)
// 获取正确的文本模式
const mode = context.options.getTextMode(element, parent)
// 解析子节点
const children = parseChildren(context, mode, ancestors)
// 解析完当前标签节点后,需要弹出父节点栈中的栈顶元素,即与当前解析的同名的标签
ancestors.pop()
// Vue.js 2.x 中的 inline-template 选项适配
// 如果组件的选项中包含 inline-template 选项,则将其值作为组件的模板;否则,将组件的 template 选项作为模板。
if (__COMPAT__) {
const inlineTemplateProp = element.props.find(
p => p.type === NodeTypes.ATTRIBUTE && p.name === 'inline-template'
) as AttributeNode
if (
inlineTemplateProp &&
checkCompatEnabled(
CompilerDeprecationTypes.COMPILER_INLINE_TEMPLATE,
context,
inlineTemplateProp.loc
)
) {
const loc = getSelection(context, element.loc.end)
inlineTemplateProp.value = {
type: NodeTypes.TEXT,
content: loc.source,
loc
}
}
}
element.children = children
// End tag.
// 3. 解析结束标签
if (startsWithEndTagOpen(context.source, element.tag)) {
parseTag(context, TagType.End, parent)
} else {
// 报错,缺少闭合标签
emitError(context, ErrorCodes.X_MISSING_END_TAG, 0, element.loc.start)
if (context.source.length === 0 && element.tag.toLowerCase() === 'script') {
const first = children[0]
if (first && startsWith(first.loc.source, '<!--')) {
emitError(context, ErrorCodes.EOF_IN_SCRIPT_HTML_COMMENT_LIKE_TEXT)
}
}
}
// 获取标签位置对象
element.loc = getSelection(context, element.loc.start)
if (isPreBoundary) {
context.inPre = false
}
if (isVPreBoundary) {
context.inVPre = false
}
return element
}
可以看到,parseElement 主要做了三件事:解析开始标签,解析子节点,解析结束标签。我们结合下面的一段模板来对源码进行解析。
const template = `<div>+--<p>Text1</p>+--<p>Text2</p>+</div>`
需要注意的是,在解析模板时,不能忽略空白字符的处理。这些空白字符包括:换行符 (\n)、回车符 (\r)、空格 (' ')、制表符 (\t) 以及换页符 (\f)。假设我们使用加号 (+) 代表换行符,用减号 (-) 代表空格字符,如上面的模板所示。
接下来,我们以这段模板来解读 parseElement 解析标签节点的过程,如下图所示:
parseTag 解析开始标签
// Start tag.
// 1. 解析开始标签
const wasInPre = context.inPre
const wasInVPre = context.inVPre
// 获取父级节点栈中的栈顶元素,即当前解析节点的父节点
const parent = last(ancestors)
// 调用 parseTag 解析开始标签
const element = parseTag(context, TagType.Start, parent)
如上面的代码所示,parseTag 函数的第二个参数传入 TagType.Start ,表示作为开始标签进行处理。在解析开始标签时,会同时解析标签上的属性和指令。因此,在 parseTag 解析函数执行完毕后,会消费字符串的中的内容 <div>,处理后的模板内容将变为:
const template = `+--<p>Text1</p>+--<p>Text2</p>+</div>`
递归地调用 parseChildren 函数解析子节点
// Children.
// 递归地调用 parseChildren 解析子节点
// 将解析处理的标签节点压入父级节点栈
ancestors.push(element)
// 获取正确的文本模式
const mode = context.options.getTextMode(element, parent)
// 解析子节点
const children = parseChildren(context, mode, ancestors)
// 解析完当前标签节点后,需要弹出父节点栈中的栈顶元素,即与当前解析的同名的标签
ancestors.pop()
经过 parseTag 函数解析完开始标签后,会得到一个标签节点,在调用 parseChildren 解析子节点前,需要根据这个标签节点的类型切换到正确的文本模式,然后再递归地调用 parseChildren 解析子节点。在这个过程中,parseChildren 函数会消费字符串的内容:+--<p>Text1</p>+--<p>Text2</p>+。处理后的模板内容将变为:
const template = `</div>`
parseTag 处理结束标签
经过 parseChildren 函数处理后,模板内容只剩下一个结束标签了,因此,只需要调用 parseTag 解析函数来消费它即可。代码如下所示:
// End tag.
// 解析结束标签
if (startsWithEndTagOpen(context.source, element.tag)) {
parseTag(context, TagType.End, parent)
} else {
// 报错,缺少闭合标签
emitError(context, ErrorCodes.X_MISSING_END_TAG, 0, element.loc.start)
if (context.source.length === 0 && element.tag.toLowerCase() === 'script') {
const first = children[0]
if (first && startsWith(first.loc.source, '<!--')) {
emitError(context, ErrorCodes.EOF_IN_SCRIPT_HTML_COMMENT_LIKE_TEXT)
}
}
}
可以看到,在处理结束标签时,parseTag 函数的第二个参数传入 TagType.End ,表示作为结束标签进行处理。在解析结束标签时,直接消费模板内容,不会有任何内容返回。如果解析失败,则会报错。
在 parseElement 函数解析标签节点的过程中,调用了 parseTag 来解析开始标签和结束标签,接下来,我们来看看 parseTag 是如何解析标签的。
parseTag 解析开始&结束标签
parseTag 函数的源码如下所示:
// packages/compiler-core/src/parse.ts
/**
* 解析标签,编译器使用 parseTag 函数来解析标签。该函数接受两个参数:type 表示标签的类 * 型,可以是 StartTag 或 EndTag;options 表示解析* 选项,包括 isNative、isVoid、isCustomElement 等
*/
function parseTag(
context: ParserContext,
type: TagType.Start,
parent: ElementNode | undefined
): ElementNode
function parseTag(
context: ParserContext,
type: TagType.End,
parent: ElementNode | undefined
): void
function parseTag(
context: ParserContext,
type: TagType,
parent: ElementNode | undefined
): ElementNode | undefined {
__TEST__ && assert(/^</?[a-z]/i.test(context.source))
__TEST__ &&
assert(
type === (startsWith(context.source, '</') ? TagType.End : TagType.Start)
)
// 开始标签
const start = getCursor(context)
// 匹配开始标签
const match = /^</?([a-z][^\t\r\n\f />]*)/i.exec(context.source)!
// 正则表达式的第一个捕获组就是标签名称
const tag = match[1]
const ns = context.options.getNamespace(tag, parent)
// 消费正则表达式匹配的全部内容,例如 <div 这段内容
advanceBy(context, match[0].length)
// 消费标签中无用的空白字符
advanceSpaces(context)
// 保存当前的状态,以便在需要重新解析属性时可以恢复到当前状态
// 因为在解析属性时,有些属性可能会使用 v-pre 指令来跳过编译器的解析过程。如果某个属性中包含了 v-pre 指令,则需要将该属性的解析过程跳过,直接将属性值作为字符串插入到生成的代码中。在重新解析属性时,需要恢复到当前状态,并重新解析所有属性
const cursor = getCursor(context)
const currentSource = context.source
// <pre> 标签用于表示预格式化文本,其中的文本会保留所有的空格和换行符。在解析 HTML 文档时,编译器需要对 <pre> 标签进行特殊处理,以保留其中的空格和换行符。
if (context.options.isPreTag(tag)) {
context.inPre = true
}
// Attributes.
// 解析属性
let props = parseAttributes(context, type)
// check v-pre
if (
type === TagType.Start &&
!context.inVPre &&
props.some(p => p.type === NodeTypes.DIRECTIVE && p.name === 'pre')
) {
context.inVPre = true
// 将当前状态和游标位置合并
extend(context, cursor)
context.source = currentSource
// 重新解析元素的属性,并过滤掉 v-pre 指令本身。这是因为在 v-pre 块中,所有的指令都会被跳过,包括 v-pre 指令本身。
props = parseAttributes(context, type).filter(p => p.name !== 'v-pre')
}
// Tag close.
// 解析结束标签
// 用于判断是否是自闭合标签
let isSelfClosing = false
if (context.source.length === 0) {
emitError(context, ErrorCodes.EOF_IN_TAG)
} else {
// 在消费匹配的内容后,如果字符串以 /> 开头,则说明这是一个自闭合标签
isSelfClosing = startsWith(context.source, '/>')
if (type === TagType.End && isSelfClosing) {
emitError(context, ErrorCodes.END_TAG_WITH_TRAILING_SOLIDUS)
}
// 如果是自闭合标签,则消费 /> ,否则消费 >
advanceBy(context, isSelfClosing ? 2 : 1)
}
// parseTag 函数解析的是结束标签,消费完结束标签后,不返回任何内容
if (type === TagType.End) {
return
}
// v2.x 版本适配
if (
__COMPAT__ &&
__DEV__ &&
isCompatEnabled(
CompilerDeprecationTypes.COMPILER_V_IF_V_FOR_PRECEDENCE,
context
)
) {
let hasIf = false
let hasFor = false
for (let i = 0; i < props.length; i++) {
const p = props[i]
if (p.type === NodeTypes.DIRECTIVE) {
if (p.name === 'if') {
hasIf = true
} else if (p.name === 'for') {
hasFor = true
}
}
if (hasIf && hasFor) {
warnDeprecation(
CompilerDeprecationTypes.COMPILER_V_IF_V_FOR_PRECEDENCE,
context,
getSelection(context, start)
)
break
}
}
}
let tagType = ElementTypes.ELEMENT
if (!context.inVPre) {
// 标签为 插槽
if (tag === 'slot') {
tagType = ElementTypes.SLOT
} else if (tag === 'template') {
//标签为template
if (
props.some(
p =>
p.type === NodeTypes.DIRECTIVE && isSpecialTemplateDirective(p.name)
)
) {
tagType = ElementTypes.TEMPLATE
}
} else if (isComponent(tag, props, context)) {
// 标签类型为 组件
tagType = ElementTypes.COMPONENT
}
}
// parseTag 函数解析的是开始标签,则返回标签元素
return {
type: NodeTypes.ELEMENT,
ns,
tag,
tagType,
props,
isSelfClosing,
children: [],
loc: getSelection(context, start),
codegenNode: undefined // to be created during transform phase
}
}
函数类型定义
我们先来看看 parseTag 函数的两个函数类型定义,如下代码所示:
// packages/compiler-core/src/parse.ts
function parseTag(
context: ParserContext,
type: TagType.Start,
parent: ElementNode | undefined
): ElementNode
function parseTag(
context: ParserContext,
type: TagType.End,
parent: ElementNode | undefined
): void
可以看到,源码中为 parseTag 函数提供了两个函数类型定义。在 TypeScript 语言中,为同一个函数提供多个函数类型定义来进行函数重载,当函数在调用的时候会进行正确的类型检查。第一个函数类型定义的第二个参数 type 接收的类型是 TagType.Start,返回值类型是ElementNode类型,可以看出这个函数类型定义用于parseTag 函数解析开始标签的情况。第二个函数类型定义的第二个参数 type 接收的类型是 TagType.End,返回值类型是 void,即没有返回值,可以看出这个函数类型定义用于 parseTag 函数解析结束标签的情况。
解析标签开始部分
// Tag open.
const start = getCursor(context)
// 匹配开始标签
const match = /^</?([a-z][^\t\r\n\f />]*)/i.exec(context.source)!
// 正则表达式的第一个捕获组就是标签名称
const tag = match[1]
const ns = context.options.getNamespace(tag, parent)
// 消费正则表达式匹配的全部内容,例如 <div 这段内容
advanceBy(context, match[0].length)
// 消费标签中无用的空白字符
advanceSpaces(context)
// 保存当前的状态,以便在需要重新解析属性时可以恢复到当前状态
const cursor = getCursor(context)
const currentSource = context.source
// check <pre> tag
if (context.options.isPreTag(tag)) {
context.inPre = true
}
// Attributes.
// 解析属性
let props = parseAttributes(context, type)
// check v-pre
if (
type === TagType.Start &&
!context.inVPre &&
props.some(p => p.type === NodeTypes.DIRECTIVE && p.name === 'pre')
) {
context.inVPre = true
// 将当前状态和游标位置合并
extend(context, cursor)
context.source = currentSource
// 重新解析元素的属性,并过滤掉 v-pre 指令本身。这是因为在 v-pre 块中,所有的指令都会被跳过,包括 v-pre 指令本身。
props = parseAttributes(context, type).filter(p => p.name !== 'v-pre')
}
parseTag 在解析开始标签时,首先通过正则表达式来匹配出开始标签,源码中给出的正则表达式用于匹配开始和结束标签,我们将其进行拆分,用于匹配开始标签的正则表达式为:/^<([a-z][^\t\r\n\f />]*)/i,用于匹配结束标签的正则表达式为:/^</([a-z][^\t\r\n\f />]*)/i 。我们来看看用于匹配开始标签的正则表达式的含义。
我们通过几个例子来理解这个正则表达式:
- 对于字符串
'<div>',会匹配出字符串'<div',剩余'>' - 对于字符串
'<div />',会匹配出字符串'<div',剩余'/>' - 对于字符串
'<div---->',其中减号(-) 代表空白符,会匹配出字符串'<div',剩余'---->'
在匹配出开始标签后,会调用 advanceBy 函数来消费正则表达式匹配的全部内容,如 '<div' 这段内容。由于标签中可能存在无用的空白符,例如<div---->,因此我们需要调用 advanceSpaces 函数来消费空白字符。消费完正则表达式匹配的全部内容后,还需要解析开始标签上的属性和指令,因此调用 parseAttributes 函数来解析属性和指令。
解析标签结束部分
// Tag close.
// 解析结束标签
// 用于判断是否是自闭合标签
let isSelfClosing = false
if (context.source.length === 0) {
emitError(context, ErrorCodes.EOF_IN_TAG)
} else {
// 在消费匹配的内容后,如果字符串以 /> 开头,则说明这是一个自闭合标签
isSelfClosing = startsWith(context.source, '/>')
if (type === TagType.End && isSelfClosing) {
emitError(context, ErrorCodes.END_TAG_WITH_TRAILING_SOLIDUS)
}
// 如果是自闭合标签,则消费 /> ,否则消费 >
advanceBy(context, isSelfClosing ? 2 : 1)
}
// parseTag 函数解析的是结束标签,消费完结束标签后,不返回任何内容
if (type === TagType.End) {
return
}
在消费完由正则匹配的内容后,检查剩余模板内容是否以字符串 /> 开头,如果是,则说明当前解析的是一个自闭合标签,此时将 isSelfClosing 设置为 true。然后判断标签是否自闭合,如果是,则调用 advanceBy 函数消费内容 />,否则只需要消费内容 > 即可。
返回标签元素
return {
type: NodeTypes.ELEMENT,
ns,
tag,
tagType,
props,
isSelfClosing,
children: [],
loc: getSelection(context, start),
codegenNode: undefined // to be created during transform phase
}
当 parseTag 函数用于解析开始标签时,则会返回一个标签元素,如上面的代码所示。
parseAttributes 循环解析属性
parseTag 函数在解析开始标签的同时,还会解析开始标签上的属性和指令,解析属性和指令调用的是 parseAttributes 函数。其源码如下:
// packages/compiler-core/src/parse.ts
function parseAttributes(
context: ParserContext,
type: TagType
): (AttributeNode | DirectiveNode)[] {
// 用来存储解析过程中产生的属性节点和指令节点
const props = []
// 属性名称集合,set数据结构可去重
const attributeNames = new Set<string>()
// 开启 while 循环,不断地消费模板内部,直至遇到标签的 "结束部分" 为止
while (
context.source.length > 0 &&
!startsWith(context.source, '>') &&
!startsWith(context.source, '/>')
) {
// 如果遇到的字符是 / ,说明已经解析到结束标签的结束的结束部分,此时应该退出属性的解析
if (startsWith(context.source, '/')) {
emitError(context, ErrorCodes.UNEXPECTED_SOLIDUS_IN_TAG)
// 消费 / 字符
advanceBy(context, 1)
// 消费空白字符
advanceSpaces(context)
continue
}
// 解析的是结束标签,结束标签上没有属性或指令,报错
if (type === TagType.End) {
emitError(context, ErrorCodes.END_TAG_WITH_ATTRIBUTES)
}
// 解析属性节点和指令节点
const attr = parseAttribute(context, attributeNames)
// Trim whitespace between class
// https://github.com/vuejs/core/issues/4251
// 移除 class 属性的值中的空白符
if (
attr.type === NodeTypes.ATTRIBUTE &&
attr.value &&
attr.name === 'class'
) {
attr.value.content = attr.value.content.replace(/\s+/g, ' ').trim()
}
if (type === TagType.Start) {
props.push(attr)
}
// 非空白字符、非字符 /、 非字符>
if (/^[^\t\r\n\f />]/.test(context.source)) {
emitError(context, ErrorCodes.MISSING_WHITESPACE_BETWEEN_ATTRIBUTES)
}
// 消费空白字符
advanceSpaces(context)
}
return props
}
如源码中所示,在 parseChildren 函数中开启了一个 while 循环来断地消费模板内部,直至遇到标签的 "结束部分" 为止。实际上,parseAttributes 函数解析模板内容的过程,就是不断地解析属性名称、等于号、属性值的过程。如下图所示:
parseAttributes 函数会从左到右的顺序不断地消费字符串。
parseAttribute 解析属性
属性名称、等于号、属性值的解析过程在 parseAttribute 函数中,我们来看看这个函数。完整源码如下:
// packages/compiler-core/src/parse.ts
function parseAttribute(
context: ParserContext,
nameSet: Set<string>
): AttributeNode | DirectiveNode {
__TEST__ && assert(/^[^\t\r\n\f />]/.test(context.source))
// Name.
const start = getCursor(context)
// 匹配属性名称
const match = /^[^\t\r\n\f />][^\t\r\n\f />=]*/.exec(context.source)!
// /^[^\t\r\n\f />][^\t\r\n\f />=]*/.exec('v-on:click="doThis"') 属性名称为 v-on:click
// /^[^\t\r\n\f />][^\t\r\n\f />=]*/.exec(':src="imageSrc"') 属性名称为 :src
// /^[^\t\r\n\f />][^\t\r\n\f />=]*/.exec('@click="doThis"') 属性名称为 @click
// 得到属性名称
const name = match[0]
if (nameSet.has(name)) {
emitError(context, ErrorCodes.DUPLICATE_ATTRIBUTE)
}
// 将属性名称添加到属性名集合中
nameSet.add(name)
// 如果指令名称的第一个字符是等于号(=),说明指令名称是错误的
if (name[0] === '=') {
emitError(context, ErrorCodes.UNEXPECTED_EQUALS_SIGN_BEFORE_ATTRIBUTE_NAME)
}
{
const pattern = /["'<]/g
let m: RegExpExecArray | null
while ((m = pattern.exec(name))) {
emitError(
context,
ErrorCodes.UNEXPECTED_CHARACTER_IN_ATTRIBUTE_NAME,
m.index
)
}
}
// 消费属性名
advanceBy(context, name.length)
// Value
// 用于存储属性值
let value: AttributeValue = undefined
// 消费属性名称与等于号之间的空白符
if (/^[\t\r\n\f ]*=/.test(context.source)) {
// 消费属性名称与等号之间的空白符
advanceSpaces(context)
// 消费等于号
advanceBy(context, 1)
// 消费等号与属性值之间的空白符
advanceSpaces(context)
// 解析属性值,会判断属性值是否被引号(' 或 “) 引用
value = parseAttributeValue(context)
// 属性值不存在,报错
if (!value) {
emitError(context, ErrorCodes.MISSING_ATTRIBUTE_VALUE)
}
}
const loc = getSelection(context, start)
// 处理指令名称
if (!context.inVPre && /^(v-[A-Za-z0-9-]|:|.|@|#)/.test(name)) {
// 匹配指令名称(v-xxx)
const match =
/(?:^v-([a-z0-9-]+))?(?:(?::|^.|^@|^#)([[^]]+]|[^.]+))?(.+)?$/i.exec(
name
)!
// 解析修饰符,如 v-model.number="age",其中通过.number 的方式为 v-model 添加修饰符
let isPropShorthand = startsWith(name, '.')
// 获取指令名称
let dirName =
match[1] ||
// 使用 v-bind 绑定属性的情况,如 <img v-bind:src="imageSrc">,简写:<img :src="imageSrc">
(isPropShorthand || startsWith(name, ':')
? 'bind' // v-bind 指令
: startsWith(name, '@') // 事件绑定,有两种方式: v-on 和 缩写方式,如 v-on:click 简写为:@click
? 'on' // v-on 指令
: 'slot') // v-slot 指令
let arg: ExpressionNode | undefined
if (match[2]) {
const isSlot = dirName === 'slot'
const startOffset = name.lastIndexOf(match[2])
const loc = getSelection(
context,
getNewPosition(context, start, startOffset),
getNewPosition(
context,
start,
startOffset + match[2].length + ((isSlot && match[3]) || '').length
)
)
// 匹配指令的这则 match 的捕获组的第三个元素为属性名称
let content = match[2]
let isStatic = true
// 绑定的是动态属性
// 例如:
// <!-- 动态 attribute 名缩写 (2.6.0+) -->
// <button :[key]="value"></button>
if (content.startsWith('[')) {
isStatic = false
if (!content.endsWith(']')) {
emitError(
context,
ErrorCodes.X_MISSING_DYNAMIC_DIRECTIVE_ARGUMENT_END
)
content = content.slice(1)
} else {
// 解析出绑定的动态属性名称
content = content.slice(1, content.length - 1)
}
} else if (isSlot) {
// #1241 special case for v-slot: vuetify relies extensively on slot
// names containing dots. v-slot doesn't have any modifiers and Vue 2.x
// supports such usage so we are keeping it consistent with 2.x.
// 将当前插槽名称的后缀(即 v-slot 指令的参数)添加到 content 变量中
content += match[3] || ''
}
arg = {
type: NodeTypes.SIMPLE_EXPRESSION,
content,
isStatic,
constType: isStatic
? ConstantTypes.CAN_STRINGIFY
: ConstantTypes.NOT_CONSTANT,
loc
}
}
// 属性值被引号引用
if (value && value.isQuoted) {
const valueLoc = value.loc
valueLoc.start.offset++
valueLoc.start.column++
valueLoc.end = advancePositionWithClone(valueLoc.start, value.content)
valueLoc.source = valueLoc.source.slice(1, -1)
}
const modifiers = match[3] ? match[3].slice(1).split('.') : []
if (isPropShorthand) modifiers.push('prop')
//
// 2.x版本的适配:v-bind:foo.sync -> v-model:foo
if (__COMPAT__ && dirName === 'bind' && arg) {
if (
modifiers.includes('sync') &&
checkCompatEnabled(
CompilerDeprecationTypes.COMPILER_V_BIND_SYNC,
context,
loc,
arg.loc.source
)
) {
dirName = 'model'
modifiers.splice(modifiers.indexOf('sync'), 1)
}
if (__DEV__ && modifiers.includes('prop')) {
checkCompatEnabled(
CompilerDeprecationTypes.COMPILER_V_BIND_PROP,
context,
loc
)
}
}
// 返回指令名称
return {
type: NodeTypes.DIRECTIVE,
name: dirName,
exp: value && {
type: NodeTypes.SIMPLE_EXPRESSION,
content: value.content,
isStatic: false,
// Treat as non-constant by default. This can be potentially set to
// other values by `transformExpression` to make it eligible for hoisting.
// 表示节点不是常量节点
constType: ConstantTypes.NOT_CONSTANT,
loc: value.loc
},
arg,
modifiers,
loc
}
}
// 判断指令的名称是否合法
if (!context.inVPre && startsWith(name, 'v-')) {
emitError(context, ErrorCodes.X_MISSING_DIRECTIVE_NAME)
}
// 返回属性对象
return {
type: NodeTypes.ATTRIBUTE,
name, // 属性名称
// 属性值
value: value && {
type: NodeTypes.TEXT,
content: value.content,
loc: value.loc
},
loc
}
}
解析属性名称
在 parseAttribute 函数中,首先使用一个正则表达式来匹配属性名称,我们来看看这个正则是如何工作的。如下图的正则表达式:
如上图所示,我们将这个正则表达式分为A、B两个部分来看:
- 部分 A 用于匹配一个位置,这个位置不能是空白字符,也不能是字符
/或字符>,并且字符串要以该位置开头; - 部分 B 则用于匹配 0 个或多个位置,这些位置不能是空白字符,也不能是字符
/、>、=。注意,这些位置不允许出现等于号(=)字符,这就实现了只匹配等于号之前的内容,即属性名称。
我们通过几个例子来理解这个正则表达式:
- 对于指令
v-on:click="doThis",匹配出的属性名称为v-on:click - 对于指令
:src="imageSrc",匹配出的属性名称为:src - 对于指令
@click="doThis,匹配出的属性名称为@click
消费空白符
经过正则表达式解析出属性名称并消费属性名称后,由于属性名称后面可能存在空白字符,因此,还需要消费属性名称后面可能存在空白字符。如下面这段模板中,属性名称和等于号之间存在空白符:
id = "foo" v-show="display"
消费属性名称后面的空白字符的源码如下面所示:
// 消费属性名称与等于号之间的空白符
if (/^[\t\r\n\f ]*=/.test(context.source)) {
// 消费属性名称与等号之间的空白符
advanceSpaces(context)
// 消费等于号
advanceBy(context, 1)
// 消费等号与属性值之间的空白符
advanceSpaces(context)
// 解析属性值,会判断属性值是否被引号(' 或 “) 引用
value = parseAttributeValue(context)
// 属性值不存在,报错
if (!value) {
emitError(context, ErrorCodes.MISSING_ATTRIBUTE_VALUE)
}
}
可以看到,源码中通过一个正则匹配出属性名称后面可能存在空白字符,然后调用 advanceSpaces 函数消费属性名称与等号之间的空白符,接着调用 advanceBy 函数消费等于号。由于等于号与属性值之间也可能存空白字符,因此还需要调用 advanceSpaces 函数将空白字符消费掉。消费完这些空白字符后,接下来调用了 parseAttributeValue 函数来解析属性值。
解析指令名称
如果解析处理的属性是指令,则提取出正确的指令名称,如指令 v-bind 提取出的指令名称为 bind,然后将指令名称返回。源码如下面所示:
// 处理指令名称
if (!context.inVPre && /^(v-[A-Za-z0-9-]|:|.|@|#)/.test(name)) {
// 匹配指令名称
const match =
/(?:^v-([a-z0-9-]+))?(?:(?::|^.|^@|^#)([[^]]+]|[^.]+))?(.+)?$/i.exec(
name
)!
// 解析修饰符,如 v-model.number="age",其中通过.number 的方式为 v-model 添加修饰符
let isPropShorthand = startsWith(name, '.')
// 获取指令名称
let dirName =
match[1] ||
// 使用 v-vind 绑定属性的情况,如 <img v-bind:src="imageSrc">,简写:<img :src="imageSrc">
(isPropShorthand || startsWith(name, ':')
? 'bind' // v-bind 指令
: startsWith(name, '@') // 事件绑定,有两种方式: v-on 和 缩写方式,如 v-on:click 简写为:@click
? 'on' // v-on 指令
: 'slot') // v-slot 指令
let arg: ExpressionNode | undefined
if (match[2]) {
const isSlot = dirName === 'slot'
const startOffset = name.lastIndexOf(match[2])
const loc = getSelection(
context,
getNewPosition(context, start, startOffset),
getNewPosition(
context,
start,
startOffset + match[2].length + ((isSlot && match[3]) || '').length
)
)
// 匹配指令的这则 match 的捕获组的第三个元素为属性名称
let content = match[2]
let isStatic = true
// 绑定的是动态属性
// 例如:
// <!-- 动态 attribute 名缩写 (2.6.0+) -->
// <button :[key]="value"></button>
if (content.startsWith('[')) {
isStatic = false
if (!content.endsWith(']')) {
emitError(
context,
ErrorCodes.X_MISSING_DYNAMIC_DIRECTIVE_ARGUMENT_END
)
content = content.slice(1)
} else {
// 解析出绑定的动态属性名称
content = content.slice(1, content.length - 1)
}
} else if (isSlot) {
// 判断当前插槽名称是否包含了 . 字符。如果包含了 . 字符,则说明当前插槽名称是一个包含命名空间的名称,需要将 . 字符保留下来。这是因为在一些 UI 库中,插槽名称会包含 . 字符,例如 Vuetify 库就会使用 . 字符来分隔插槽名称的命名空间。
content += match[3] || ''
}
arg = {
type: NodeTypes.SIMPLE_EXPRESSION,
content,
isStatic,
constType: isStatic
? ConstantTypes.CAN_STRINGIFY
: ConstantTypes.NOT_CONSTANT,
loc
}
}
// 属性值被引号引用
if (value && value.isQuoted) {
const valueLoc = value.loc
valueLoc.start.offset++
valueLoc.start.column++
valueLoc.end = advancePositionWithClone(valueLoc.start, value.content)
valueLoc.source = valueLoc.source.slice(1, -1)
}
const modifiers = match[3] ? match[3].slice(1).split('.') : []
if (isPropShorthand) modifiers.push('prop')
// 2.x版本的适配:v-bind:foo.sync -> v-model:foo
if (__COMPAT__ && dirName === 'bind' && arg) {
if (
modifiers.includes('sync') &&
checkCompatEnabled(
CompilerDeprecationTypes.COMPILER_V_BIND_SYNC,
context,
loc,
arg.loc.source
)
) {
dirName = 'model'
modifiers.splice(modifiers.indexOf('sync'), 1)
}
if (__DEV__ && modifiers.includes('prop')) {
checkCompatEnabled(
CompilerDeprecationTypes.COMPILER_V_BIND_PROP,
context,
loc
)
}
}
// 返回指令名称
return {
type: NodeTypes.DIRECTIVE,
name: dirName,
exp: value && {
type: NodeTypes.SIMPLE_EXPRESSION,
content: value.content,
isStatic: false,
// Treat as non-constant by default. This can be potentially set to
// other values by `transformExpression` to make it eligible for hoisting.
constType: ConstantTypes.NOT_CONSTANT,
loc: value.loc
},
arg,
modifiers,
loc
}
}
返回属性对象
如果解析出来的属性不是指令,那么返回该属性和属性值。源码如下面所示:
// 返回属性对象
return {
type: NodeTypes.ATTRIBUTE,
name, // 属性名称
// 属性值
value: value && {
type: NodeTypes.TEXT,
content: value.content,
loc: value.loc
},
loc
}
parseAttributeValue 解析属性值
在上面我们介绍到,在消费完属性名称与等于号之间的空白符、等于号以及等于号与属性值之间的空白字符后,会调用 parseAttributeValue 函数来解析属性值。接下来,我们来看看 parseAttributeValue 函数,其源码如下面所示:
function parseAttributeValue(context: ParserContext): AttributeValue {
const start = getCursor(context)
let content: string
// 获取当前模板内容的第一个字符
const quote = context.source[0]
// 判断属性值是否被引号引用
const isQuoted = quote === `"` || quote === `'`
if (isQuoted) {
// Quoted value.
// 属性值被引号引用,消费引号
advanceBy(context, 1)
// 获取下一个引号的索引
const endIndex = context.source.indexOf(quote)
// 没有下一个引号,模板内容作为文本解析
if (endIndex === -1) {
content = parseTextData(
context,
context.source.length,
TextModes.ATTRIBUTE_VALUE
)
} else {
// 解析属性值
content = parseTextData(context, endIndex, TextModes.ATTRIBUTE_VALUE)
// 消费引号
advanceBy(context, 1)
}
} else {
// 代码运行到这里,说明属性值没有被引号引用
// Unquoted
// 下一个空白符之前的内容全部作为属性值
const match = /^[^\t\r\n\f >]+/.exec(context.source)
if (!match) {
return undefined
}
const unexpectedChars = /["'<=`]/g
let m: RegExpExecArray | null
while ((m = unexpectedChars.exec(match[0]))) {
emitError(
context,
ErrorCodes.UNEXPECTED_CHARACTER_IN_UNQUOTED_ATTRIBUTE_VALUE,
m.index
)
}
// 解析属性值
// 通过 match[0] 获取属性值
content = parseTextData(context, match[0].length, TextModes.ATTRIBUTE_VALUE)
}
// 返回解析后属性值
return { content, isQuoted, loc: getSelection(context, start) }
}
对于属性值的解析分为两部分,第一部分是被引号包裹的属性值的解析,第二部分是没有被引号包裹的属性值的解析。
解析被引号包裹的属性值
if (isQuoted) {
// Quoted value.
// 属性值被引号引用,消费引号
advanceBy(context, 1)
// 获取下一个引号的索引
const endIndex = context.source.indexOf(quote)
// 没有下一个引号,模板内容作为文本解析
if (endIndex === -1) {
content = parseTextData(
context,
context.source.length,
TextModes.ATTRIBUTE_VALUE
)
} else {
// 解析属性值
content = parseTextData(context, endIndex, TextModes.ATTRIBUTE_VALUE)
// 消费引号
advanceBy(context, 1)
}
}
被引号包裹的属性值有两种情况,一种是被双引号包裹,另一种是被单引号包裹。如果属性值被引号包裹,消费掉第一个引号后,下一个引号之前的内容将被解析为属性值,此时调用 parseTextData 函数来解析属性值。
解析没有被引号包裹的属性值
else {
// 代码运行到这里,说明属性值没有被引号引用
// Unquoted
// 下一个空白符之前的内容全部作为属性值
const match = /^[^\t\r\n\f >]+/.exec(context.source)
if (!match) {
return undefined
}
const unexpectedChars = /["'<=`]/g
let m: RegExpExecArray | null
while ((m = unexpectedChars.exec(match[0]))) {
emitError(
context,
ErrorCodes.UNEXPECTED_CHARACTER_IN_UNQUOTED_ATTRIBUTE_VALUE,
m.index
)
}
// 解析属性值
// 通过 match[0] 获取属性值
content = parseTextData(context, match[0].length, TextModes.ATTRIBUTE_VALUE)
}
如果属性值没有被引号包裹,则通过一个正则来提取属性值。用于匹配属性值的正则表达式如下图所示:
该正则表达式从字符串的开始位置进行匹配,并且会匹配一个或多个非空白字符、非字符 > 。换句话说,该正则表达式会一直对字符串进行匹配,直到遇到空白字符或字符 > 为止,这就实现了属性值的提取。提取出属性值后,同样调用 parseTextData 函数来解析属性值。
parseTextData 解析文本内容
// packages/compiler-core/src/parse.ts
/**
* 在解析文本时,编译器需要将其中的 HTML 实体转换为对应的字符,以便在生成的代码中正确地表示文本内容。
*/
function parseTextData(
context: ParserContext,
length: number,
mode: TextModes
): string {
// 获取文本内容
const rawText = context.source.slice(0, length)
// 消费文本内容
advanceBy(context, length)
// 如果文本模式为 RAWTEXT 或 CDATA 或文本内容中不包含 &,则直接返回属性值
if (
mode === TextModes.RAWTEXT ||
mode === TextModes.CDATA ||
!rawText.includes('&')
) {
return rawText
} else {
// DATA or RCDATA containing "&"". Entity decoding required.
// 对文本内容中的HTML实体进行解码
return context.options.decodeEntities(
rawText,
mode === TextModes.ATTRIBUTE_VALUE
)
}
}
可以看到,parseTextData 函数的实现十分简单。首先获取需要解析的文本内容,如果当前的文本模式为 RAWTEXT 或 CDATA 或文本内容中不包含 &,则文本内容不需要做额外处理,将其直接返回。如果当前文本模式为 DATA 或 RCDATA ,并且文本内容包含 & 字符,则需要对文本内容中的HTML实体进行解码。如上面所示的代码中,调用了转换上下文选项中的 decodeEntities 方法来解码HTML实体。
decodeHtml 解析HTML实体
调用转换上下文选项中的 decodeEntities 方法,实际上调用的是 decodeHtml 方法,如下代码所示:
export const parserOptions: ParserOptions = {
// 省略部分代码
decodeEntities: __BROWSER__ ? decodeHtmlBrowser : decodeHtml,
// 省略部分代码
}
在进入 decodeHtml 源码的解读之前,我们先来了解一下什么是HTML实体。
HTML 实体
HTML 实体是一段以字符 & 开始的文本内容。实体用来描述 HTML 中的保留字符和一些难以通过普通键盘输入的字符,以及一些不可见的字符。例如,在 HTML 中,字符 < 具有特殊含义,如果希望以普通文本的方式来显示字符 > ,需要通过实体来表达,如下代码所示:
<div>A<B</div>
其中字符串 <就是一个 HTML 实体,用来表示字符 < 。
HTML 实体总是以字符 & 开头,以字符 ; 结尾。WHATWC 规范中明确规定,如果不为实体加分号,将会产生解析错误。但考虑到历史原因 (互联网上存在大量省略分号的情况),现代浏览器都能够解析早期规范中定义的那些可以省略分号的 HTML 实体。
HTML 实体有两类,一类叫作 命名字符引用,也叫命名实体,这类实体具有特定的名称,例如 < 。还有一类字符引用没有特定的名称,只能用数字表示,这类实体叫做数字字符引用。
数字字符引用以字符串 &# 开头,例如<。实际上,< 对应的也是 < 。数字字符引用既可以用十进制来表示,也可以用十六进制来表示。当使用十六进制表示实体时,需要以字符串 &#x 开头。
decodeHtml 源码
了解完了HTML实体,我们来看看 decodeHtml 的源码实现。代码如下所示:
// packages/compiler-dom/src/decodeHtml.ts
export const decodeHtml: ParserOptions['decodeEntities'] = (
rawText, // 要被解码的文本内容
asAttr // 布尔值,代表文本内容是否作为属性值
) => {
let offset = 0
const end = rawText.length
// 经过解码后的的文本内容将作为返回值被返回
let decodedText = ''
//advance 函数用于消费指定长度的文本
function advance(length: number) {
offset += length
rawText = rawText.slice(length)
}
// 消费字符串,直到处理完毕为止
while (offset < end) {
// 用于匹配字符引用的开始部分,如果匹配成功,那么 head[0] 的值将有三种可能:
// 1. head[0] === '&',这说明该字符引用是命名字符引用
// 2. head[0] === '&#',这说明该字符引用是用十进制表示的数字字符引用
// 3. head[0] === '&#x',这说明该字符串应用是用十六进制表示的数字字符引用
const head = /&(?:#x?)?/i.exec(rawText)
// 如果没有匹配,说明已经没有需要解码的内容了
if (!head || offset + head.index >= end) {
// 计算剩余内容的长度
const remaining = end - offset
// 将剩余内容加到 decodedText 上
decodedText += rawText.slice(0, remaining)
// 消费剩余内容
advance(remaining)
break
}
// Advance to the "&".
// head.index 为匹配的字符 & 在 rawText 中的位置索引
decodedText += rawText.slice(0, head.index)
// 消费字符 & 之前的内容
advance(head.index)
// 如果满足条件,则说明是命名字符引用,否则为数字字符引用
if (head[0] === '&') {
// Named character reference.
// 命名字符引用
let name = ''
let value: string | undefined = undefined
// 数字 & 的下一个字符必须是 ASCII 字母或数字,这样才是合法的命名字符引用
if (/[0-9a-z]/i.test(rawText[1])) {
// 根据引用表计算实体名称的最大长度
if (!maxCRNameLength) {
maxCRNameLength = Object.keys(namedCharacterReferences).reduce(
(max, name) => Math.max(max, name.length),
0
)
}
// 从最大长度开始对文本内容进行截取,并试图去引用表中找到对应的项
for (let length = maxCRNameLength; !value && length > 0; --length) {
// 截取字符 & 到最大长度之间的字符作为实体名称
name = rawText.slice(1, 1 + length)
// 使用实体名称去索引表中查找对应项的值
value = (namedCharacterReferences as Record<string, string>)[name]
}
// 如果找到了对应项的值,说明解码成功
if (value) {
// 检查实体名称的最后一个匹配字符是否是分号
const semi = name.endsWith(';')
// 如果解码的文本作为属性值,最后一个匹配的字符不是分号,
// 并且最后一个匹配字符的下一个字符是等于号 (=)、ASCII 字母 或 数字,
// 由于历史原因,将字符 & 和 实体名称 name 作为普通文本
if (
asAttr &&
!semi &&
/[=a-z0-9]/i.test(rawText[name.length + 1] || '') //
) {
decodedText += '&' + name
advance(1 + name.length)
} else {
// 其它情况下,正常使用解码后的内容拼接到 decodedText 上
decodedText += value
advance(1 + name.length)
}
} else {
// 如果没有找到对应的值,说明解码失败
decodedText += '&' + name
advance(1 + name.length)
}
} else {
// 如果字符 & 的下一个字符不是 ASCII 字母或数字,则将字符 & 作为普通文本
decodedText += '&'
advance(1)
}
} else {
// Numeric character reference.
// 数组字符引用
// 判断是以十进制表示还是以十六进制表示
const hex = head[0] === '&#x'
// 根据不同进制表示法,选用不同正则
const pattern = hex ? /^&#x([0-9a-f]+);?/i : /^&#([0-9]+);?/
// 最终,body[1] 的值就是 Unicode 码点
const body = pattern.exec(rawText)
if (!body) {
// 如果没有匹配,则不进行解码操作,只把 head[0] 追加到 decodedText 上并消费
decodedText += head[0]
advance(head[0].length)
} else {
// https://html.spec.whatwg.org/multipage/parsing.html#numeric-character-reference-end-state
// 根据对应的进制,将码点字符串转换为数字
let cp = Number.parseInt(body[1], hex ? 16 : 10)
// 检查码点的合法性
if (cp === 0) {
// 如果码点值为 0x00,替换为 0xfffd
cp = 0xfffd
} else if (cp > 0x10ffff) {
// 如果码点值超过 Unicode 的最大值,替换为 0xfffd
cp = 0xfffd
} else if (cp >= 0xd800 && cp <= 0xdfff) {
// 如果码点值处于 surrogate pair 范围内,替换为 0xfffd
cp = 0xfffd
} else if ((cp >= 0xfdd0 && cp <= 0xfdef) || (cp & 0xfffe) === 0xfffe) {
// 如果码点值处于 noncharacter 范围内,则什么都不做,交给平台处理
// noop
} else if (
// 控制字符集的范围是: [0x01, 0x1f] 加上 [0x7f, 0x9f]
// 去掉 ASCII 空白符: 0x09(TAB)、0x0A(LF)、0x0C(FF)
// 0x0D(CR) 虽然也是 ASCII 空白符,但需要包含
(cp >= 0x01 && cp <= 0x08) ||
cp === 0x0b ||
(cp >= 0x0d && cp <= 0x1f) ||
(cp >= 0x7f && cp <= 0x9f)
) {
// 在 CCR_REPLACEMENTS 表中查找替换码点,如果找不到,则使用原码点
cp = CCR_REPLACEMENTS[cp] || cp
}
// 解码后追加到 decodedText 上
decodedText += String.fromCodePoint(cp)
// 消费整个数字字符引用的内容
advance(body[0].length)
}
}
}
return decodedText
}
可以看到,解码HTML实体的过程,就是不断消费字符串的过程。由于HTML实体分为命名字符引用和数字字符引用,因此 decodeHtml 函数分别对这两种实体的解码做了不同的处理。
解码命名字符引用
// 如果满足条件,则说明是命名字符引用,否则为数字字符引用
if (head[0] === '&') {
// Named character reference.
// 命名字符引用
let name = ''
let value: string | undefined = undefined
// 数字 & 的下一个字符必须是 ASCII 字母或数字,这样才是合法的命名字符引用
if (/[0-9a-z]/i.test(rawText[1])) {
// 根据引用表计算实体名称的最大长度
if (!maxCRNameLength) {
maxCRNameLength = Object.keys(namedCharacterReferences).reduce(
(max, name) => Math.max(max, name.length),
0
)
}
// 从最大长度开始对文本内容进行截取,并试图去引用表中找到对应的项
for (let length = maxCRNameLength; !value && length > 0; --length) {
// 截取字符 & 到最大长度之间的字符作为实体名称
name = rawText.slice(1, 1 + length)
// 使用实体名称去索引表中查找对应项的值
value = (namedCharacterReferences as Record<string, string>)[name]
}
// 如果找到了对应项的值,说明解码成功
if (value) {
// 检查实体名称的最后一个匹配字符是否是分号
const semi = name.endsWith(';')
// 如果解码的文本作为属性值,最后一个匹配的字符不是分号,
// 并且最后一个匹配字符的下一个字符是等于号 (=)、ASCII 字母 或 数字,
// 由于历史原因,将字符 & 和 实体名称 name 作为普通文本
if (
asAttr &&
!semi &&
/[=a-z0-9]/i.test(rawText[name.length + 1] || '') //
) {
decodedText += '&' + name
advance(1 + name.length)
} else {
// 其它情况下,正常使用解码后的内容拼接到 decodedText 上
decodedText += value
advance(1 + name.length)
}
} else {
// 如果没有找到对应的值,说明解码失败
decodedText += '&' + name
advance(1 + name.length)
}
} else {
// 如果字符 & 的下一个字符不是 ASCII 字母或数字,则将字符 & 作为普通文本
decodedText += '&'
advance(1)
}
}
在解码命名字符引用时,解析出来的实体名称后,如果最后一个匹配的字符不是分号,并且最后一个匹配字符的下一个字符是等于号 (=)、ASCII 字母 或 数字,由于历史原因,将字符 & 和 实体名称 name 作为普通文本。例如如下的HTML文本:
<a href="foo.com?a=1<=2">foo.com?a=1<=2</a>
否则对解析出来的实体名称进行解码,即从实体名称去命名字符索引表中查找对应项的值,然后将解码后的内容拼接到 decodedText 上。在解码时对于字符引用中非分号,处理规则如下:
- 当存在分号时:执行完整匹配
- 当省略分号时,执行最短匹配
为了实现上面的处理规则,Vue.js 3 中精心设计了命名字符索引表。如下面的代码所示 (只取了其中一部分的内容):
const namedCharacterReferences = {
"gt": ">",
"gt;": ">",
"lt": "<",
"lt;": "<",
"ltcc;": "⪦",
"amp": "&",
}
如上面的 namedCharacterReferences 对象所示,相同字符对应的实体会有多个,即带分号的版本和不带分号的版本,例如 "gt" 和 "gt;"。另外一些实体则只有带分号的版本,因为这些实体不允许省略分号。
解码数字字符引用
数字字符引用的格式是:前缀 + Unicode 码点。由于数字字符引用的前缀可以是以十进制表示 (&#) ,也可以是以十六进制表示 (&#x) ,因此源码使用下面的代码来完成码点的提取:
// 判断是以十进制表示还是以十六进制表示
const hex = head[0] === '&#x'
// 根据不同进制表示法,选用不同正则
const pattern = hex ? /^&#x([0-9a-f]+);?/i : /^&#([0-9]+);?/
// 最终,body[1] 的值就是 Unicode 码点
const body = pattern.exec(rawText)
提取出码点后,需要对码点的值进行合法性检查,如下代码所示:
// 根据对应的进制,将码点字符串转换为数字
let cp = Number.parseInt(body[1], hex ? 16 : 10)
// 检查码点的合法性
if (cp === 0) {
// 如果码点值为 0x00,替换为 0xfffd
cp = 0xfffd
} else if (cp > 0x10ffff) {
// 如果码点值超过 Unicode 的最大值,替换为 0xfffd
cp = 0xfffd
} else if (cp >= 0xd800 && cp <= 0xdfff) {
// 如果码点值处于 surrogate pair 范围内,替换为 0xfffd
cp = 0xfffd
} else if ((cp >= 0xfdd0 && cp <= 0xfdef) || (cp & 0xfffe) === 0xfffe) {
// 如果码点值处于 noncharacter 范围内,则什么都不做,交给平台处理
// noop
} else if (
// 控制字符集的范围是: [0x01, 0x1f] 加上 [0x7f, 0x9f]
// 去掉 ASCII 空白符: 0x09(TAB)、0x0A(LF)、0x0C(FF)
// 0x0D(CR) 虽然也是 ASCII 空白符,但需要包含
(cp >= 0x01 && cp <= 0x08) ||
cp === 0x0b ||
(cp >= 0x0d && cp <= 0x1f) ||
(cp >= 0x7f && cp <= 0x9f)
) {
// 在 CCR_REPLACEMENTS 表中查找替换码点,如果找不到,则使用原码点
cp = CCR_REPLACEMENTS[cp] || cp
}
最后,调用 String.fromCodePoint函数将码点解码为对应的字符,然后将解码后的字符添加到 decodedText,完成数字字符引用的解码。如下代码所示:
// 解码后追加到 decodedText 上
decodedText += String.fromCodePoint(cp)
// 消费整个数字字符引用的内容
advance(body[0].length)
parseText 解析文本节点
我们知道,解析器的本质是一个状态机。当状态机处于 “状态 1” 时,如果读取模板的第一个字符既不是字符 < ,也不是插值定界符 {{ ,因此状态机会进入 “状态 6”,即调用 parseText 函数来处理文本内容。此时解析器会在模板中寻找下一个 < 字符或插值定界符 {{ 的位置索引,即为索引 I 。然后,解析器会从模板的头部到索引 I 的位置截取内容,这段截取出来的字符串将作为文本节点的内容。解析来,我们来看看 parseText 函数。
// packages/compiler-core/src/parse.ts
function parseText(context: ParserContext, mode: TextModes): TextNode {
__TEST__ && assert(context.source.length > 0)
// 文本模式为 CDATA,下一个定界符是 ]]>
// 文本模式为 DATA、RAWTEXT 等模式时,下一个字符是 < 或者是插值定界符 {{
const endTokens =
mode === TextModes.CDATA ? [']]>'] : ['<', context.options.delimiters[0]]
// endIndex 为文本内容的结尾索引,默认将整个模板剩余内容都作为文本内容
let endIndex = context.source.length
for (let i = 0; i < endTokens.length; i++) {
// 寻找字符 < 、]]> 与定界符{{ 的索引位置
const index = context.source.indexOf(endTokens[i], 1)
// 取 index 和当前 endIndex 中较小的一个作为新的结尾索引
if (index !== -1 && endIndex > index) {
endIndex = index
}
}
__TEST__ && assert(endIndex > 0)
const start = getCursor(context)
// 从模板的头部到索引 endIndex 的位置截取内容,然后解析这段截取出来的内容
const content = parseTextData(context, endIndex, mode)
// 返回解析后的文本节点
return {
type: NodeTypes.TEXT,
content,
loc: getSelection(context, start)
}
}
如上面的代码所示。当文本模式为 CDATA 时,结束符为 ]]> 。当文本模式为非 CDATA 模式时,下一个字符是 < 或者插值定界符是 {{ 。由于结束符 ]]>、 字符 < 与定界符 {{ 的出现顺序是未知的,所以我们需要取三者中较小的一个作为文本截取的终点。有了截取终点后,解析器会从模板的头部到索引 endIndex 的位置截取内容,然后调用 parseTextData 函数来解析这段截取出来的内容。parseText 函数在上文已经详细介绍过,请查看 parseTextData 解析文本内容 小节。
parseInterpolation 解析插值节点
默认情况下,插值以字符串 {{ 开头,并以字符串 }} 结尾。通常会将这两个特殊的字符串称为定界符。定界符中间的内容可以是任意合法的 JavaScript 表达式。
解析器在遇到文本插值的起始定界符 {{ 时,会进入 “插值状态6”,并调用 parseInterpolation 函数来解析插值内容,如下图所示:
解析器在解析插值时,只需要将文本插值的开始定界符与结束定界符之间的内容提取出来,作为 JavaScript 表达式即可。parseInterpolation 函数源码如下:
// packages/compiler-core/src/parse.ts
function parseInterpolation(
context: ParserContext,
mode: TextModes
): InterpolationNode | undefined {
// 插值节点的定界符 {{ 和 }}
// 开始定界符 {{
// 结束定界符 }}
const [open, close] = context.options.delimiters
__TEST__ && assert(startsWith(context.source, open))
// 找到结束定界符的位置索引
const closeIndex = context.source.indexOf(close, open.length)
// 没有找到结束定界符,说明 插值缺少结束定界符,报错
if (closeIndex === -1) {
emitError(context, ErrorCodes.X_MISSING_INTERPOLATION_END)
return undefined
}
const start = getCursor(context)
// 消费开始定界符
advanceBy(context, open.length)
const innerStart = getCursor(context)
const innerEnd = getCursor(context)
// 插值表达式内容的长度
const rawContentLength = closeIndex - open.length
// 截取开始定界符与结束定界符之间的内容作为插值表达式
const rawContent = context.source.slice(0, rawContentLength)
// 调用 parseTextData 对插值表达式进行解析
const preTrimContent = parseTextData(context, rawContentLength, mode)
// 去除表达式内容两端的空格
const content = preTrimContent.trim()
const startOffset = preTrimContent.indexOf(content)
if (startOffset > 0) {
advancePositionWithMutation(innerStart, rawContent, startOffset)
}
const endOffset =
rawContentLength - (preTrimContent.length - content.length - startOffset)
//
advancePositionWithMutation(innerEnd, rawContent, endOffset)
// 消费表达式的内容
advanceBy(context, close.length)
// 返回类型为 Interpolation 的节点,代表插值节点
return {
type: NodeTypes.INTERPOLATION,
// 插值节点的content是一个类型为 Expression 的表达式节点
content: {
type: NodeTypes.SIMPLE_EXPRESSION,
isStatic: false,
// Set `isConstant` to false by default and will decide in transformExpression
constType: ConstantTypes.NOT_CONSTANT,
content,
loc: getSelection(context, innerStart, innerEnd)
},
loc: getSelection(context, start)
}
}
可以看到,将插值表达式的内容提取出来后,调用 parseTextData 函数对插值表达式里可能存在的HTML实体进行解码。最后返回一个类型为 Interpolation 的节点,代表插值节点,其中插值节点的content是一个类型为 Expression 的表达式节点。
parseComment 解析注释节点
注释以字符串 <!-- 开头,并以字符串 --> 结尾。我们同样将这两个特殊的字符串称为定界符。定界符中间的内容就是注释内容。
解析器在遇到注释的起始定界符 <!-- 时,会进入 “注释状态4”,并调用 parseComment 函数来解析注释,如下图所示:
解析器在解析注释时,只需要将注释的开始定界符与结束定界符之间的内容提取出来,作为 注释内容即可。parseComment 函数源码如下:
// packages/compiler-core/src/parse.ts
function parseComment(context: ParserContext): CommentNode {
__TEST__ && assert(startsWith(context.source, '<!--'))
const start = getCursor(context)
let content: string
// Regular comment.
// 注释结束部分的匹配正则
const match = /--(!)?>/.exec(context.source)
if (!match) {
// 默认截取模板内容中 <!-- 字符后面的所有内容作为注释内容
content = context.source.slice(4)
advanceBy(context, context.source.length)
emitError(context, ErrorCodes.EOF_IN_COMMENT)
} else {
if (match.index <= 3) {
emitError(context, ErrorCodes.ABRUPT_CLOSING_OF_EMPTY_COMMENT)
}
if (match[1]) {
emitError(context, ErrorCodes.INCORRECTLY_CLOSED_COMMENT)
}
// 获取注释的内容
content = context.source.slice(4, match.index)
// Advancing with reporting nested comments.
const s = context.source.slice(0, match.index)
let prevIndex = 1,
nestedIndex = 0
while ((nestedIndex = s.indexOf('<!--', prevIndex)) !== -1) {
advanceBy(context, nestedIndex - prevIndex + 1)
if (nestedIndex + 4 < s.length) {
emitError(context, ErrorCodes.NESTED_COMMENT)
}
prevIndex = nestedIndex + 1
}
advanceBy(context, match.index + match[0].length - prevIndex + 1)
}
// 返回类型为 Comment 的节点
return {
type: NodeTypes.COMMENT,
content,
loc: getSelection(context, start)
}
}
可以看到,将注释内容提取出来后,直接将其作为 Comment 类型的节点。
总结
本文详细介绍了解析器在解析模板过程中对标签节点、注释节点、插值节点、文本节点等节点的解析过程。
在调用 parseElement 函数解析标签节点时,会调用 parseTag 函数来解析开始标签和结束标签,对于开始标签和结束标签之间的内容,则递归调用 parseChildren 函数来解析子节点。
在调用 parseTag 解析标签时,除了将标签名称解析出来,还会调用 parseAttributes 函数来不断地解析标签上的属性、指令等。
在调用 parseInterpolation 函数解析插值时,只需要将文本插值的开始定界符与结束定界符之间的内容提取出来,作为 JavaScript 表达式即可。在调用 parseComment 函数解析注释时,也是相似的思路。
所以说,无论是解析文本节点、插值节点还是解析标签的属性值,最终都会调用 decodeHtml 函数来解码文本内容中可能存在的HTML实体。