从案例分析 Vue3 Tokenizer+Parser 源码四

情形四:标签属性(source="<div id=a class="c" inert style=\'\'></div>")

本小节将深度拆解 Vue3 编译器中 普通 HTML 标签多属性完整解析流程 ,覆盖无引号属性、双引号属性、布尔属性、空值属性四种常见属性场景。本文以 Vue3 源码单元测试用例 __tests__/parse.spec.ts 1068‑1172multiple 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 结构。

相关推荐
1 小时前
Python 图片处理:裁剪缩放至300×200,格式JPG/PNG/GIF,控制大小≤100KB
linux·前端·python
_阿南_8 小时前
项目中新增给AI制定的代码规范
前端·程序员
名字还没想好☜9 小时前
React 实现暗黑模式切换:localStorage 持久化、SSR 首屏闪烁与跟随系统主题
前端·javascript·react.js·ecmascript·react·next.js
自动化监测Learner10 小时前
主流 Web 端地图引擎对比:选型指南与优劣分析
javascript
console.log('npc')10 小时前
Git 冲突与 AI 协助指南
前端·人工智能·git·大模型
爱学堂IT分享11 小时前
Cesium可视化系统实战课程-Cesium教程学习
前端
糖墨夕12 小时前
理解大语言模型:Agent 的“大脑”
前端·agent
Rain的Java大神之路12 小时前
JavaWeb开发如何解决跨域问题
java·前端·后端·nginx·web安全·面试·运维开发
cpolar技术支持12 小时前
浏览器也能跑本地 AI:用 Transformers.js + WebGPU 做一个最小推理 Demo,cpolar 给同事远程体验
前端·ai·cpolar·webgpu·transformers.js