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

作者:单线程_01日期:2026/9/7

情形四:标签属性(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 结果如下:

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 源码四》 是转载文章,点击查看原文


相关推荐


React Router v8 升了之后,我花了两天才把项目跑起来
Flynt2026/8/30

昨天看到 React Router 发了 v8.3.1,想着项目还停在 v7 的某个中间版本,趁周末没排需求,不如先升了试试。 changelog 写得很乐观:"all of them are changes you can make in v7",意思是所有破坏性变更都能提前通过 future flags 适配。我当时觉得挺合理的——我们项目 v7 上了快半年,该有的 flag 应该都开了。 结果 pnpm i react-router@latest 装完一跑,直接炸了三个地方。 升级本身不难


两个 Agent 怎么协作:Host-Worker 模式(第91篇-E77)
leeyi2026/8/22

Part 16 开篇。前面 15 个 Part 讲的是一个 Agent 怎么做安全、怎么加记忆、怎么接知识库——但单个 Agent 的注意力是有限的。一个 Agent 面对"帮我查北京天气、订机票、比价酒店"这种多维度任务,会在上下文里来回跳,工具一多就乱。 这篇回答一个问题:两个 Agent 怎么协作——Host-Worker 模式,主 Agent 分派、子 Agent 执行,注意力该拆给谁。 两个问题串全篇: Eino 的 Host MultiAgent 怎么让 host LLM 把 s


漫话火山 Milvus|为 Agent、RAG 、语义搜索而生,AI 时代的极致性价比之选
火山引擎Agent社区2026/8/8

托管锁定、自建难扩容、性能和成本难以兼顾? 火山 Milvus,开源兼容、可自由迁移;自研算法优化算力开销,支持 Serverless;联动火山大模型生态,适配多模态 RAG 与 Agent,可控、性能、性价比一站式解决。 火山 Milvus,基于开源 Milvus 构建的全托管数据库服务,提供高效的非结构化数据检索能力,适用于多样化 AI 场景,客户无需再关心底层硬件资源,降低使用成本,提高整体效率。


CSS 现代布局终极方案:Grid 完整实战,替代浮动/弹性布局复杂场景
前端人类学2026/7/30

Hi,我是前端人类学! 从“圣杯”到“卡片墙”,Grid 正在重新定义我们对 Web 布局的想象。 如果你还在用 float 清浮动、用 margin 推间距、用 flex 小心翼翼地嵌套多行网格,那今天这篇文章,值得你多看两眼。 CSS Grid 不是来“卷”谁的,它是来解决问题的——那些你以前觉得“凑合能看但维护起来想删库”的布局问题。 文章目录 一、Grid 凭什么说自己是“终极方案”?二、干掉浮动和复杂 Flex:两个核心实战模式2.1 响应式卡片网格:一行代码,无需媒


算力赋能零售与创意新生态:视程空间Pandora,解锁线下场景智能化无限可能
视***间2026/7/22

智慧零售与创意开发正成为驱动消费升级与产业创新的核心引擎。从线下门店的智能导购、客流分析,到沉浸式体验场景的交互设计、创意内容生成,传统模式已难以满足高效、个性化、安全合规的发展需求。视程空间 Pandora 系列边缘算力盒子,凭借 NVIDIA Jetson Orin™ NX/Nano Super 的强劲算力、极致集成设计与全开放生态,成为智慧零售场景落地与创意开发实践的绝佳解决方案,为企业提供安全、高效、可定制的边缘 AI 底座,让零售更智能、让创意更自由。 一、智慧零售:从流量运营到体


基于 OpenClaw 构建医疗健康系统:智能问诊与用药管理的全链路实战
七夜zippoe2026/7/14

摘要:本文基于 OpenClaw v2.4 与 Python 3.11,以"医疗健康系统"为场景,完整演示如何用对话式 AI 框架落地智能问诊、健康监测、用药管理与电子病历四大模块。文章先拆解 OpenClaw 的核心抽象与医疗场景诉求,再用 Mermaid 图与可运行 Python 代码逐模块实现,并给出运行验证、阈值配置与合规边界。面向中高级后端与全栈开发者,无需医学背景即可照着落地;重点讲清"领域建模 + 合规护栏"的工程取舍,而非堆砌大模型。读完可掌握一套可复用的行业应用构建范式。


计算机导论_第4章_笔记
薄荷椰果抹茶2026/7/6

内容提要 程序设计语言翻译系统操作系统工具软件 第1节 程序设计语言翻译系统 1.1 概述 计算机硬件只能识别并执行机器指令,但人们普遍习惯于使用高级程序设计语言或汇编语言来编写程序。为了让计算机能够理解高级程序设计语言或汇编语言并执行用它编写的程序,必须要为它配备一个"翻译",这就是程序设计语言翻译系统。 定义:程序设计语言翻译系统是一类系统软件,它能够将使用某一种源语言编写的程序翻译成为与其等价的使用另一种目标语言编写的程序。 源程序:使用源语言编写的程序目标程序:使用目标语言编写的程序


图解 MongoDB 18|复制集拓扑:Primary、Secondary 和 Arbiter 的分工
十三Tech2026/6/28

存储引擎阶段回答了「数据怎么存、怎么不丢」,但留下一个致命问题:单机宕机了怎么办。一台 mongod 进程挂掉,不管是硬件故障、进程崩溃还是网络隔离,所有读写都会中断。这在生产环境是不可接受的。 MongoDB 解决单点故障的方式是复制集(Replica Set)——把数据复制到多个节点,一台挂了自动切换到另一台。这一阶段(18–23)就围绕复制集展开,从拓扑结构、复制原理、延迟、选举到读写关注,把 MongoDB 的高可用讲透。 先把机制边界说清楚 复制集是一组维护相同数据集的 mongod


百度 C++/PHP 研发一二面:一面扫八股和算法,二面开始逼近 Redis、MySQL 和秒杀设计
TechPioneer_lp2026/6/19

这篇百度 C++/PHP 研发面经很适合作为“后端基础到系统设计过渡”的样本来看。 它的一面还比较像常规校招: 算法 OS 计网 HTTP Redis 静态 / 动态链接 C++ 基础 但二面明显开始往: 智能指针 TCP 窗口和拥塞 Redis 各种底层结构 MySQL 索引、隔离级别、锁 秒杀系统和高并发设计 去推进了。 校招大礼包获取:入口 可能是至今最全,最好,最实用的校招大礼包,减少信息差,预期漫步无敌的刷提,不如有的放矢,针对性的准备,这


从单机到分布式:用 Go + Eino + DeepSeek V4 构建生产级 Code Review Agent
银河技术2026/6/11

从单机到分布式:用 Go + Eino + DeepSeek V4 构建生产级 Code Review Agent 不是把大模型接到 GitHub Webhook 上,就叫生产级 Code Review Agent。真正决定系统上限的,是任务编排、规则前置、上下文治理、并发隔离与可观测性。 引言:为什么团队越来越需要“生产级” Code Review Agent 在小团队里,Code Review 通常是一个“人盯人”的流程:开发者提 PR,Reviewer 看 diff,提几点

首页编辑器站点地图

本站内容在 CC BY-SA 4.0 协议下发布

Copyright © 2026 聚合阅读