情形四:标签属性(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 结果如下:
1// __tests__/parse.spec.ts 1068~1172行 2test('multiple attributes', () => { 3 const ast = baseParse('<div id=a class="c" inert style=\'\'></div>') 4 const element = ast.children[0] as ElementNode 5 6 expect(element).toStrictEqual({ 7 type: NodeTypes.ELEMENT, 8 ns: Namespaces.HTML, 9 tag: 'div', 10 tagType: ElementTypes.ELEMENT, 11 codegenNode: undefined, 12 props: [ 13 // 1. 无引号属性:id=a 14 { 15 type: NodeTypes.ATTRIBUTE, 16 name: 'id', 17 nameLoc: { 18 start: { offset: 5, line: 1, column: 6 }, 19 end: { offset: 7, line: 1, column: 8 }, 20 source: 'id', 21 }, 22 value: { 23 type: NodeTypes.TEXT, 24 content: 'a', 25 loc: { 26 start: { offset: 8, line: 1, column: 9 }, 27 end: { offset: 9, line: 1, column: 10 }, 28 source: 'a', 29 }, 30 }, 31 loc: { 32 start: { offset: 5, line: 1, column: 6 }, 33 end: { offset: 9, line: 1, column: 10 }, 34 source: 'id=a', 35 }, 36 }, 37 // 2. 双引号属性:class="c" 38 { 39 type: NodeTypes.ATTRIBUTE, 40 name: 'class', 41 nameLoc: { 42 start: { offset: 10, line: 1, column: 11 }, 43 end: { offset: 15, line: 1, column: 16 }, 44 source: 'class', 45 }, 46 value: { 47 type: NodeTypes.TEXT, 48 content: 'c', 49 loc: { 50 start: { offset: 16, line: 1, column: 17 }, 51 end: { offset: 19, line: 1, column: 20 }, 52 source: '"c"', 53 }, 54 }, 55 loc: { 56 start: { offset: 10, line: 1, column: 11 }, 57 end: { offset: 19, line: 1, column: 20 }, 58 source: 'class="c"', 59 }, 60 }, 61 // 3. 布尔属性:inert(无属性值) 62 { 63 type: NodeTypes.ATTRIBUTE, 64 name: 'inert', 65 nameLoc: { 66 start: { offset: 20, line: 1, column: 21 }, 67 end: { offset: 25, line: 1, column: 26 }, 68 source: 'inert', 69 }, 70 value: undefined, 71 loc: { 72 start: { offset: 20, line: 1, column: 21 }, 73 end: { offset: 25, line: 1, column: 26 }, 74 source: 'inert', 75 }, 76 }, 77 // 4. 单引号空值属性:style='' 78 { 79 type: NodeTypes.ATTRIBUTE, 80 name: 'style', 81 nameLoc: { 82 start: { offset: 26, line: 1, column: 27 }, 83 end: { offset: 31, line: 1, column: 32 }, 84 source: 'style', 85 }, 86 value: { 87 type: NodeTypes.TEXT, 88 content: '', 89 loc: { 90 start: { offset: 32, line: 1, column: 33 }, 91 end: { offset: 34, line: 1, column: 35 }, 92 source: "''", 93 }, 94 }, 95 loc: { 96 start: { offset: 26, line: 1, column: 27 }, 97 end: { offset: 34, line: 1, column: 35 }, 98 source: "style=''", 99 }, 100 }, 101 ], 102 103 children: [], 104 loc: { 105 start: { offset: 0, line: 1, column: 1 }, 106 end: { offset: 41, line: 1, column: 42 }, 107 source: '<div id=a class="c" inert style=\'\'></div>', 108 }, 109 }) 110}) 111
整个多属性标签解析过程,Tokenizer 会依次切换 6 种解析状态,完成标签名、属性名、属性值的逐段解析,状态流转顺序如下:
步骤一:标签名解析,进入属性解析预备状态
解析到 <div (div 后空格)时,触发标签名解析结束逻辑。空格属于标签结束符,会调用 handleTagName 方法,保存当前 div 标签节点,同时将解析状态从 InTagName 切换为 BeforeAttrName,准备解析后续属性。
1// tokenizer.ts 596~600行 2// 处理标签名解析状态:判断当前字符是否为标签结束标识(/、>、空白字符) 3private stateInTagName(c: number): void { 4 if (isEndOfTagSection(c)) { // "/" ">" 和空白字符均为标签名结束标识 5 this.handleTagName(c) // 结束标签名解析,执行后续收尾逻辑 6 } 7} 8 9// tokenizer.ts 610~615行 10// 标签名解析收尾核心方法:保存标签节点、切换解析状态 11private handleTagName(c: number) { 12 // 触发标签名回调,为parser全局变量currentOpenTag赋值,缓存当前解析的标签节点 13 this.cbs.onopentagname(this.sectionStart, this.index) 14 this.sectionStart = -1 // 重置段落起始位置 15 this.state = State.BeforeAttrName // 切换为「属性解析预备状态」 16 this.stateBeforeAttrName(c) // 执行预备状态对应的解析逻辑 17} 18
本次案例中,执行 stateBeforeAttrName 时当前字符为空格,不匹配任何属性解析条件,直接回到 tokenizer.parse 主循环,继续读取下一个字符 i(inert 属性首字符)。
步骤二:属性名解析,进入属性名读取状态
<div i 主循环读取到非空格字符(属性首字符)时,触发属性解析初始化逻辑。当前字符不属于 Vue 特殊指令标识(v-、.、:、@、#),判定为普通 HTML 属性,状态切换为 InAttrName,开始逐字符读取属性名。
1// tokenizer.ts 648~678行 2private stateBeforeAttrName(c: number): void { 3 if (c === CharCodes.Gt) { 4 // 代码省略... 5 } else if (c === CharCodes.Slash) { 6 // 代码省略... 7 } else if (c === CharCodes.Lt && this.peek() === CharCodes.Slash) { 8 // 代码省略... 9 } else if (!isWhitespace(c)) { 10 if ((__DEV__ || !__BROWSER__) && c === CharCodes.Eq) { 11 this.cbs.onerr( 12 ErrorCodes.UNEXPECTED_EQUALS_SIGN_BEFORE_ATTRIBUTE_NAME, 13 this.index, 14 ) 15 } 16 this.handleAttrStart(c) 17 } 18} 19// tokenizer.ts 679~696行 20private handleAttrStart(c: number) { 21 if (c === CharCodes.LowerV && this.peek() === CharCodes.Dash) { // 匹配 v- 指令 22 this.state = State.InDirName 23 this.sectionStart = this.index 24 } else if ( // 匹配 . : @ # 指令前缀 25 c === CharCodes.Dot || 26 c === CharCodes.Colon || 27 c === CharCodes.At || 28 c === CharCodes.Number 29 ) { 30 this.cbs.ondirname(this.index, this.index + 1) 31 this.state = State.InDirArg 32 this.sectionStart = this.index + 1 33 } else { // 无特殊前缀:判定为普通HTML属性,进入属性名解析状态 34 this.state = State.InAttrName 35 this.sectionStart = this.index 36 } 37} 38
步骤三:属性名结束,预备解析属性值
<div id= 当解析到字符 = 时,标志当前属性名读取完成。stateInAttrName 触发onattribname 回调,初始化当前属性 AST 节点,随后执行 handleAttrNameEnd 切换状态,预备解析属性值。
1// tokenizer.ts 708~723行 2private stateInAttrName(c: number): void { 3 if (c === CharCodes.Eq || isEndOfTagSection(c)) { // “=” 或者 "/" ">" 和空白字符 4 this.cbs.onattribname(this.sectionStart, this.index) 5 this.handleAttrNameEnd(c) 6 } 7 // 代码省略... 8} 9
onattribname回调逻辑(parser.ts):为全局 currentProp 赋值,创建属性节点基础结构,记录属性名及位置信息。
1// parser.ts 190~199行 2onattribname(start, end) { 3 // plain attribute 4 currentProp = { 5 type: NodeTypes.ATTRIBUTE, 6 name: getSlice(start, end), 7 nameLoc: getLoc(start, end), 8 value: undefined, 9 loc: getLoc(start), 10 } 11}, 12
状态切换逻辑:属性名解析完成后,切换为 AfterAttrName 状态,根据当前字符 =,最终进入 BeforeAttrValue 属性值预备状态。
1// tokenizer.ts 773~778行 2// 属性名解析收尾:切换状态、触发结束回调 3private handleAttrNameEnd(c: number): void { 4 this.sectionStart = this.index 5 this.state = State.AfterAttrName 6 this.cbs.onattribnameend(this.index) // 指令专属收尾回调(普通属性无影响) 7 this.stateAfterAttrName(c) 8} 9// tokenizer.ts 779~791行 10// 属性名结束后状态判断:区分有值、无值、多属性场景 11private stateAfterAttrName(c: number): void { 12 if (c === CharCodes.Eq) { // 存在=号,说明后续有属性值 13 this.state = State.BeforeAttrValue 14 } else if (c === CharCodes.Slash || c === CharCodes.Gt) { // "/" 或者 ">" 标签结束,布尔属性无值 15 this.cbs.onattribend(QuoteType.NoValue, this.sectionStart) 16 this.sectionStart = -1 17 this.state = State.BeforeAttrName 18 this.stateBeforeAttrName(c) 19 } else if (!isWhitespace(c)) { // 无=号直接跟新属性,布尔属性场景 20 this.cbs.onattribend(QuoteType.NoValue, this.sectionStart) 21 this.handleAttrStart(c) 22 } 23} 24
步骤四:属性值解析,生成完整属性 AST 节点
<div id=a c=a 时,进入 BeforeAttrValue 状态后,Tokenizer 会根据属性值的包裹符号自动区分三种解析模式:双引号(Dq)、单引号(Sq)、无引号(Nq),本次案例覆盖全部三种场景。
1// tokenizer.ts 792~804行 2// 属性值预备状态:根据首字符区分属性值包裹类型 3private stateBeforeAttrValue(c: number): void { 4 if (c === CharCodes.DoubleQuote) { // " 双引号包裹属性值 5 this.state = State.InAttrValueDq 6 this.sectionStart = this.index + 1 7 } else if (c === CharCodes.SingleQuote) { // ' 单引号包裹属性值 8 this.state = State.InAttrValueSq 9 this.sectionStart = this.index + 1 10 } else if (!isWhitespace(c)) { // 非空格 无引号包裹属性值 11 this.sectionStart = this.index 12 this.state = State.InAttrValueNq 13 this.stateInAttrValueNoQuotes(c) 14 } 15} 16
以 id=a 无引号属性为例:读取到属性值 a 后,遇到空格(多属性分隔符),触发属性值解析结束,执行 onattribdata 截取属性值内容,再通过 onattribend 完成属性节点收尾、赋值、入栈。
1// tokenizer.ts 824~845行 2// 无引号属性值解析核心逻辑 3private stateInAttrValueNoQuotes(c: number): void { 4 if (isWhitespace(c) || c === CharCodes.Gt) { // 遇到空白字符或标签闭合符,判定属性值解析结束 5 this.cbs.onattribdata(this.sectionStart, this.index) 6 this.sectionStart = -1 7 this.cbs.onattribend(QuoteType.Unquoted, this.index) // 结束当前属性解析 8 this.state = State.BeforeAttrName // 切换回属性预备状态,解析下一个属性 9 this.stateBeforeAttrName(c) 10 } 11 // 其他分支代码省略... 12} 13
Parser 回调收尾逻辑:统一处理属性值赋值、位置修正、空格压缩、异常校验,最终将完整属性节点推入元素的 props 数组。
1// parser.ts 284~288行 2// 接收属性值内容,拼接并记录属性值位置 3onattribdata(start, end) { 4 currentAttrValue += getSlice(start, end) 5 if (currentAttrStartIndex < 0) currentAttrStartIndex = start 6 currentAttrEndIndex = end 7}, 8// parser.ts 312~410行 9// 属性解析最终收尾:完善节点、校验、入栈 10onattribend(quote, end) { 11 if (currentOpenTag && currentProp) { 12 // finalize end pos 13 setLocEnd(currentProp.loc, end) 14 15 if (quote !== QuoteType.NoValue) { // 非布尔属性(存在属性值) 16 if (__BROWSER__ && currentAttrValue.includes('&')) { 17 currentAttrValue = currentOptions.decodeEntities!( 18 currentAttrValue, 19 true, 20 ) 21 } 22 23 if (currentProp.type === NodeTypes.ATTRIBUTE) { 24 // 对class属性特殊处理:压缩首尾及中间多余空格 25 if (currentProp!.name === 'class') { 26 currentAttrValue = condense(currentAttrValue).trim() 27 } 28 29 // 无引号属性为空时,抛出缺失属性值异常 30 if (quote === QuoteType.Unquoted && !currentAttrValue) { 31 emitError(ErrorCodes.MISSING_ATTRIBUTE_VALUE, end) 32 } 33 // 为属性节点赋值文本内容及位置信息 34 currentProp!.value = { 35 type: NodeTypes.TEXT, 36 content: currentAttrValue, 37 loc: 38 quote === QuoteType.Unquoted 39 ? getLoc(currentAttrStartIndex, currentAttrEndIndex) 40 : getLoc(currentAttrStartIndex - 1, currentAttrEndIndex + 1), 41 } 42 // 其他状况代码省略 43 } 44 } 45 // 非pre指令属性,正常推入标签属性数组 46 if ( 47 currentProp.type !== NodeTypes.DIRECTIVE || 48 currentProp.name !== 'pre' 49 ) { 50 currentOpenTag.props.push(currentProp) 51 } 52 } 53 // 重置全局临时变量,准备解析下一个属性 54 currentAttrValue = '' 55 currentAttrStartIndex = currentAttrEndIndex = -1 56}, 57
多属性循环解析逻辑
单个属性解析完成后,状态会重置为 BeforeAttrName,自动开启下一个属性的解析循环,依次完成 class(双引号)、inert(布尔无值)、style(空值)的解析,最终将所有属性节点统一存入元素 AST 的 props 数组,形成完整的标签属性 AST 结构。
《从案例分析 Vue3 Tokenizer+Parser 源码四》 是转载文章,点击查看原文。