情形四:标签属性(source="<div id=a class="c" inert style=\'\'></div>")
本小节将深度拆解 Vue3 编译器中 普通 HTML 标签多属性完整解析流程 ,覆盖无引号属性、双引号属性、布尔属性、空值属性四种常见属性场景。本文以 Vue3 源码单元测试用例 __tests__/parse.spec.ts 1068‑1172 的 multiple attributes 案例为核心,逐行梳理标签从词法分析(tokenizer)到语法分析(parser)的状态流转、回调执行、AST 节点生成全流程。
测试用例与最终 AST 结果
本次解析的源码字符串为:<div id=a class="c" inert style=''></div>,包含四种典型属性场景:
- id=a:无引号普通属性
- class="c":双引号包裹普通属性
- inert:布尔属性(无属性值)
- style='':单引号空值属性
执行 baseParse 解析后,生成完整 Element 元素 AST 节点,完整测试代码及 AST 结果如下:
ts
// __tests__/parse.spec.ts 1068~1172行
test('multiple attributes', () => {
const ast = baseParse('<div id=a class="c" inert style=\'\'></div>')
const element = ast.children[0] as ElementNode
expect(element).toStrictEqual({
type: NodeTypes.ELEMENT,
ns: Namespaces.HTML,
tag: 'div',
tagType: ElementTypes.ELEMENT,
codegenNode: undefined,
props: [
// 1. 无引号属性:id=a
{
type: NodeTypes.ATTRIBUTE,
name: 'id',
nameLoc: {
start: { offset: 5, line: 1, column: 6 },
end: { offset: 7, line: 1, column: 8 },
source: 'id',
},
value: {
type: NodeTypes.TEXT,
content: 'a',
loc: {
start: { offset: 8, line: 1, column: 9 },
end: { offset: 9, line: 1, column: 10 },
source: 'a',
},
},
loc: {
start: { offset: 5, line: 1, column: 6 },
end: { offset: 9, line: 1, column: 10 },
source: 'id=a',
},
},
// 2. 双引号属性:class="c"
{
type: NodeTypes.ATTRIBUTE,
name: 'class',
nameLoc: {
start: { offset: 10, line: 1, column: 11 },
end: { offset: 15, line: 1, column: 16 },
source: 'class',
},
value: {
type: NodeTypes.TEXT,
content: 'c',
loc: {
start: { offset: 16, line: 1, column: 17 },
end: { offset: 19, line: 1, column: 20 },
source: '"c"',
},
},
loc: {
start: { offset: 10, line: 1, column: 11 },
end: { offset: 19, line: 1, column: 20 },
source: 'class="c"',
},
},
// 3. 布尔属性:inert(无属性值)
{
type: NodeTypes.ATTRIBUTE,
name: 'inert',
nameLoc: {
start: { offset: 20, line: 1, column: 21 },
end: { offset: 25, line: 1, column: 26 },
source: 'inert',
},
value: undefined,
loc: {
start: { offset: 20, line: 1, column: 21 },
end: { offset: 25, line: 1, column: 26 },
source: 'inert',
},
},
// 4. 单引号空值属性:style=''
{
type: NodeTypes.ATTRIBUTE,
name: 'style',
nameLoc: {
start: { offset: 26, line: 1, column: 27 },
end: { offset: 31, line: 1, column: 32 },
source: 'style',
},
value: {
type: NodeTypes.TEXT,
content: '',
loc: {
start: { offset: 32, line: 1, column: 33 },
end: { offset: 34, line: 1, column: 35 },
source: "''",
},
},
loc: {
start: { offset: 26, line: 1, column: 27 },
end: { offset: 34, line: 1, column: 35 },
source: "style=''",
},
},
],
children: [],
loc: {
start: { offset: 0, line: 1, column: 1 },
end: { offset: 41, line: 1, column: 42 },
source: '<div id=a class="c" inert style=\'\'></div>',
},
})
})
整个多属性标签解析过程,Tokenizer 会依次切换 6 种解析状态,完成标签名、属性名、属性值的逐段解析,状态流转顺序如下:

步骤一:标签名解析,进入属性解析预备状态
解析到 <div (div 后空格)时,触发标签名解析结束逻辑。空格属于标签结束符,会调用 handleTagName 方法,保存当前 div 标签节点,同时将解析状态从 InTagName 切换为 BeforeAttrName,准备解析后续属性。
ts
// tokenizer.ts 596~600行
// 处理标签名解析状态:判断当前字符是否为标签结束标识(/、>、空白字符)
private stateInTagName(c: number): void {
if (isEndOfTagSection(c)) { // "/" ">" 和空白字符均为标签名结束标识
this.handleTagName(c) // 结束标签名解析,执行后续收尾逻辑
}
}
// tokenizer.ts 610~615行
// 标签名解析收尾核心方法:保存标签节点、切换解析状态
private handleTagName(c: number) {
// 触发标签名回调,为parser全局变量currentOpenTag赋值,缓存当前解析的标签节点
this.cbs.onopentagname(this.sectionStart, this.index)
this.sectionStart = -1 // 重置段落起始位置
this.state = State.BeforeAttrName // 切换为「属性解析预备状态」
this.stateBeforeAttrName(c) // 执行预备状态对应的解析逻辑
}
本次案例中,执行 stateBeforeAttrName 时当前字符为空格,不匹配任何属性解析条件,直接回到 tokenizer.parse 主循环,继续读取下一个字符 i(inert 属性首字符)。
步骤二:属性名解析,进入属性名读取状态
<div i 主循环读取到非空格字符(属性首字符)时,触发属性解析初始化逻辑。当前字符不属于 Vue 特殊指令标识(v-、.、:、@、#),判定为普通 HTML 属性 ,状态切换为 InAttrName,开始逐字符读取属性名。
ts
// tokenizer.ts 648~678行
private stateBeforeAttrName(c: number): void {
if (c === CharCodes.Gt) {
// 代码省略...
} else if (c === CharCodes.Slash) {
// 代码省略...
} else if (c === CharCodes.Lt && this.peek() === CharCodes.Slash) {
// 代码省略...
} else if (!isWhitespace(c)) {
if ((__DEV__ || !__BROWSER__) && c === CharCodes.Eq) {
this.cbs.onerr(
ErrorCodes.UNEXPECTED_EQUALS_SIGN_BEFORE_ATTRIBUTE_NAME,
this.index,
)
}
this.handleAttrStart(c)
}
}
// tokenizer.ts 679~696行
private handleAttrStart(c: number) {
if (c === CharCodes.LowerV && this.peek() === CharCodes.Dash) { // 匹配 v- 指令
this.state = State.InDirName
this.sectionStart = this.index
} else if ( // 匹配 . : @ # 指令前缀
c === CharCodes.Dot ||
c === CharCodes.Colon ||
c === CharCodes.At ||
c === CharCodes.Number
) {
this.cbs.ondirname(this.index, this.index + 1)
this.state = State.InDirArg
this.sectionStart = this.index + 1
} else { // 无特殊前缀:判定为普通HTML属性,进入属性名解析状态
this.state = State.InAttrName
this.sectionStart = this.index
}
}
步骤三:属性名结束,预备解析属性值
<div id= 当解析到字符 = 时,标志当前属性名读取完成。stateInAttrName 触发onattribname 回调,初始化当前属性 AST 节点,随后执行 handleAttrNameEnd 切换状态,预备解析属性值。
ts
// tokenizer.ts 708~723行
private stateInAttrName(c: number): void {
if (c === CharCodes.Eq || isEndOfTagSection(c)) { // "=" 或者 "/" ">" 和空白字符
this.cbs.onattribname(this.sectionStart, this.index)
this.handleAttrNameEnd(c)
}
// 代码省略...
}
onattribname回调逻辑(parser.ts):为全局 currentProp 赋值,创建属性节点基础结构,记录属性名及位置信息。
ts
// parser.ts 190~199行
onattribname(start, end) {
// plain attribute
currentProp = {
type: NodeTypes.ATTRIBUTE,
name: getSlice(start, end),
nameLoc: getLoc(start, end),
value: undefined,
loc: getLoc(start),
}
},
状态切换逻辑:属性名解析完成后,切换为 AfterAttrName 状态,根据当前字符 =,最终进入 BeforeAttrValue 属性值预备状态。
ts
// tokenizer.ts 773~778行
// 属性名解析收尾:切换状态、触发结束回调
private handleAttrNameEnd(c: number): void {
this.sectionStart = this.index
this.state = State.AfterAttrName
this.cbs.onattribnameend(this.index) // 指令专属收尾回调(普通属性无影响)
this.stateAfterAttrName(c)
}
// tokenizer.ts 779~791行
// 属性名结束后状态判断:区分有值、无值、多属性场景
private stateAfterAttrName(c: number): void {
if (c === CharCodes.Eq) { // 存在=号,说明后续有属性值
this.state = State.BeforeAttrValue
} else if (c === CharCodes.Slash || c === CharCodes.Gt) { // "/" 或者 ">" 标签结束,布尔属性无值
this.cbs.onattribend(QuoteType.NoValue, this.sectionStart)
this.sectionStart = -1
this.state = State.BeforeAttrName
this.stateBeforeAttrName(c)
} else if (!isWhitespace(c)) { // 无=号直接跟新属性,布尔属性场景
this.cbs.onattribend(QuoteType.NoValue, this.sectionStart)
this.handleAttrStart(c)
}
}
步骤四:属性值解析,生成完整属性 AST 节点
<div id=a c=a 时,进入 BeforeAttrValue 状态后,Tokenizer 会根据属性值的包裹符号自动区分三种解析模式:双引号(Dq)、单引号(Sq)、无引号(Nq),本次案例覆盖全部三种场景。
ts
// tokenizer.ts 792~804行
// 属性值预备状态:根据首字符区分属性值包裹类型
private stateBeforeAttrValue(c: number): void {
if (c === CharCodes.DoubleQuote) { // " 双引号包裹属性值
this.state = State.InAttrValueDq
this.sectionStart = this.index + 1
} else if (c === CharCodes.SingleQuote) { // ' 单引号包裹属性值
this.state = State.InAttrValueSq
this.sectionStart = this.index + 1
} else if (!isWhitespace(c)) { // 非空格 无引号包裹属性值
this.sectionStart = this.index
this.state = State.InAttrValueNq
this.stateInAttrValueNoQuotes(c)
}
}
以 id=a 无引号属性为例:读取到属性值 a 后,遇到空格(多属性分隔符),触发属性值解析结束,执行 onattribdata 截取属性值内容,再通过 onattribend 完成属性节点收尾、赋值、入栈。
ts
// tokenizer.ts 824~845行
// 无引号属性值解析核心逻辑
private stateInAttrValueNoQuotes(c: number): void {
if (isWhitespace(c) || c === CharCodes.Gt) { // 遇到空白字符或标签闭合符,判定属性值解析结束
this.cbs.onattribdata(this.sectionStart, this.index)
this.sectionStart = -1
this.cbs.onattribend(QuoteType.Unquoted, this.index) // 结束当前属性解析
this.state = State.BeforeAttrName // 切换回属性预备状态,解析下一个属性
this.stateBeforeAttrName(c)
}
// 其他分支代码省略...
}
Parser 回调收尾逻辑:统一处理属性值赋值、位置修正、空格压缩、异常校验,最终将完整属性节点推入元素的 props 数组。
ts
// parser.ts 284~288行
// 接收属性值内容,拼接并记录属性值位置
onattribdata(start, end) {
currentAttrValue += getSlice(start, end)
if (currentAttrStartIndex < 0) currentAttrStartIndex = start
currentAttrEndIndex = end
},
// parser.ts 312~410行
// 属性解析最终收尾:完善节点、校验、入栈
onattribend(quote, end) {
if (currentOpenTag && currentProp) {
// finalize end pos
setLocEnd(currentProp.loc, end)
if (quote !== QuoteType.NoValue) { // 非布尔属性(存在属性值)
if (__BROWSER__ && currentAttrValue.includes('&')) {
currentAttrValue = currentOptions.decodeEntities!(
currentAttrValue,
true,
)
}
if (currentProp.type === NodeTypes.ATTRIBUTE) {
// 对class属性特殊处理:压缩首尾及中间多余空格
if (currentProp!.name === 'class') {
currentAttrValue = condense(currentAttrValue).trim()
}
// 无引号属性为空时,抛出缺失属性值异常
if (quote === QuoteType.Unquoted && !currentAttrValue) {
emitError(ErrorCodes.MISSING_ATTRIBUTE_VALUE, end)
}
// 为属性节点赋值文本内容及位置信息
currentProp!.value = {
type: NodeTypes.TEXT,
content: currentAttrValue,
loc:
quote === QuoteType.Unquoted
? getLoc(currentAttrStartIndex, currentAttrEndIndex)
: getLoc(currentAttrStartIndex - 1, currentAttrEndIndex + 1),
}
// 其他状况代码省略
}
}
// 非pre指令属性,正常推入标签属性数组
if (
currentProp.type !== NodeTypes.DIRECTIVE ||
currentProp.name !== 'pre'
) {
currentOpenTag.props.push(currentProp)
}
}
// 重置全局临时变量,准备解析下一个属性
currentAttrValue = ''
currentAttrStartIndex = currentAttrEndIndex = -1
},
多属性循环解析逻辑
单个属性解析完成后,状态会重置为 BeforeAttrName,自动开启下一个属性的解析循环,依次完成 class(双引号)、inert(布尔无值)、style(空值)的解析,最终将所有属性节点统一存入元素 AST 的 props 数组,形成完整的标签属性 AST 结构。