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

情形三:文本及标签(source="<div>hello</div>")

本小节拆解 Vue3 编译器中普通 HTML 标签与内部文本节点的完整解析流程。以单元测试用例 __tests__/parse.spec.ts 441‑468simple div 案例作为切入点,梳理三件核心事情:

  1. 开始标签如何创建节点、压入解析栈
  2. 子文本节点如何挂载到父元素节点
  3. 闭合标签触发时,如何做栈的出栈处理

测试用例预览

输入源码:<div>hello</div>,解析后生成的 AST 结构:

ts 复制代码
// __tests__/parse.spec.ts 441~468行
test('simple div', () => {
      const ast = baseParse('<div>hello</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: [],
        children: [
          {
            type: NodeTypes.TEXT,
            content: 'hello',
            loc: {
              start: { offset: 5, line: 1, column: 6 },
              end: { offset: 10, line: 1, column: 11 },
              source: 'hello',
            },
          },
        ],
        loc: {
          start: { offset: 0, line: 1, column: 1 },
          end: { offset: 16, line: 1, column: 17 },
          source: '<div>hello</div>',
        },
})

整体流程:Tokenizer 状态机逐字符扫描 → 触发各类回调 → Parser 接收回调构建 AST、维护节点栈 stack。 解析初始默认状态:this.state = State.Text

Tokenizer 入口主循环,不停读取字符,根据当前 state 分发不同处理函数,同时记录换行用于行列位置 loc 计算。

ts 复制代码
export default class Tokenizer {    
  public mode: ParseMode = ParseMode.BASE
  // tokenizer.ts 944~1093行    
  public parse(input: string): void {    
    this.buffer = input    
    while (this.index < this.buffer.length) {    
      const c = this.buffer.charCodeAt(this.index)    
      // 单独识别换行符,记录位置,用于loc行列解析(排除实体字符场景)    
      if (c === CharCodes.NewLine && this.state !== State.InEntity) {    
          this.newlines.push(this.index)    
      }    
      // 根据当前解析状态分发处理逻辑    
      switch (this.state) {    
            case State.Text: {    
                this.stateText(c)    
                break    
            }
            case State.InTagName: {
                this.stateInTagName(c)
                break
            }
            case State.InClosingTagName: {
              	this.stateInClosingTagName(c)
              	break
            }
            case State.BeforeTagName: {
                this.stateBeforeTagName(c)
                break
            }
            case State.BeforeClosingTagName: {
                this.stateBeforeClosingTagName(c)
                break
            }

          // 其他状态分支省略(标签、插值、属性等)    
      }    
      this.index++    
    }    
    this.cleanup() // 收尾处理剩余文本片段    
    this.finish()    
  }    
}

步骤 1:State.Text 遇到 <,切换到标签前置状态 BeforeTagName

初始状态为 State.Text,进入 stateText。 当读到字符 <CharCodes.Lt):

  • 如果中间有文本片段,会调用 ontext 回调输出文本;本案例 < 在索引 0,无前置文本,不触发。
  • 将状态切换为 State.BeforeTagName,更新片段起始下标。
ts 复制代码
// tokenizer.ts 334~348行
private stateText(c: number): void {    
  // 遇到标签开始符 <,结束当前文本解析,切换为标签解析状态    
  if (c === CharCodes.Lt) {    
    if (this.index > this.sectionStart) {    
        this.cbs.ontext(this.sectionStart, this.index)    
    }    
    this.state = State.BeforeTagName    
    this.sectionStart = this.index    
  } 
  // 其他情况省略  
}

State.BeforeTagName 是所有标签的总入口,根据<后面第一个字符区分:声明<!、处理指令<?、闭合标签</、普通开始标签。 本案例 < 之后是字母d,满足 isTagStartChar(a‑z/A‑Z);解析模式为默认 ParseMode.BASE,状态切换为 State.InTagName,进入标签名解析阶段。

ts 复制代码
// tokenizer.ts 41~45行
export enum ParseMode {
  BASE, // 解析普通 HTML 片段,非 SFC,按照浏览器 HTML 容错规则解析
  HTML, // 解析完整 HTML 文档
  SFC, //  解析 `.vue` 文件
}
// tokenizer.ts 559~595行
private stateBeforeTagName(c: number): void {
  if (c === CharCodes.ExclamationMark) { // `!` 字符,对应 `<!`,例如 `<!DOCTYPE html>`、注释`<!-- -->`
    this.state = State.BeforeDeclaration
    this.sectionStart = this.index + 1
  } else if (c === CharCodes.Questionmark) { // `?`,对应 `<?xml ...?>` XML 处理指令
    this.state = State.InProcessingInstruction
    this.sectionStart = this.index + 1
  } else if (isTagStartChar(c)) {  // isTagStartChar:工具函数,判断字符是否可以作为标签名首字符 (a-z,A-Z)。代表遇到开始标签
    this.sectionStart = this.index
    // 按解析模式分发标签类型
    if (this.mode === ParseMode.BASE) {
      this.state = State.InTagName
    } else if (this.inSFCRoot) {
      this.state = State.InSFCRootTagName
    } else if (!this.inXML) {
      if (c === 116 /* t */) {
        this.state = State.BeforeSpecialT
      } else {
        this.state = c === 115 /* s */ ? State.BeforeSpecialS : State.InTagName
      }
    } else {
      this.state = State.InTagName
    }
  } else if (c === CharCodes.Slash) { // `/`,对应 `</`,闭合标签
    this.state = State.BeforeClosingTagName
  } else {
    //`<`后面跟的字符不属于合法标签开头,退回文本解析
    this.state = State.Text
    this.stateText(c)
  }
}

在上述代码中,当 isTagStartChar 结果为 true 时,根据 this.mode this.inSFCRoot this.inXML 三个属性来切换状态,三者的关系如下:

场景 mode inSFCRoot inXML 行为
解析普通 HTML 片段 BASE false false 普通标签解析
解析完整 HTML 文档 HTML false false 普通标签解析
.vue 文件,顶层还没进入 template SFC true false 解析 SFC 根标签<template>/<script>/<style>
.vue 文件,template 标签内部 SFC false false 普通 HTML,可以识别<script>等特殊标签
.vue,<template lang="xml">内部 SFC false true XML 严格模式,无特殊标签逻辑

步骤 2:InTagName 解析标签名,触发 onopentagname

isEndOfTagSection 判断:遇到空白、/>,代表标签名结束。 读到 > 时,调用 handleTagName,触发回调 onopentagname。 Parser 的 onopentagname 会构建临时标签对象 currentOpenTag,此时节点还没有真正加入 AST。

ts 复制代码
// tokenizer.ts 151~159行
export function isWhitespace(c: number): boolean {
  return (
    c === CharCodes.Space || // " "
    c === CharCodes.NewLine || // "\n"
    c === CharCodes.Tab || // "\t"
    c === CharCodes.FormFeed || // "\f"
    c === CharCodes.CarriageReturn // "\r"
  )
}
// tokenizer.ts 161~163行
function isEndOfTagSection(c: number): boolean {
    //                      "/"                   ">"
    return c === CharCodes.Slash || c === CharCodes.Gt || isWhitespace(c)
}
// tokenizer.ts 596~615行
private stateInTagName(c: number): void {
    if (isEndOfTagSection(c)) {
      this.handleTagName(c)
    }
}
private handleTagName(c: number) {
    this.cbs.onopentagname(this.sectionStart, this.index)
    this.sectionStart = -1
    this.state = State.BeforeAttrName
    this.stateBeforeAttrName(c)
}

// parser.ts 139~151行
onopentagname(start, end) {
    const name = getSlice(start, end)
    currentOpenTag = {
      type: NodeTypes.ELEMENT,
      tag: name,
      ns: currentOptions.getNamespace(name, stack[0], currentOptions.ns),
      tagType: ElementTypes.ELEMENT, // will be refined on tag close
      props: [],
      children: [],
      loc: getLoc(start - 1, end),
      codegenNode: undefined,
    }
},

步骤 3:BeforeAttrName 遇到 >,onopentagend 完成开标签处理

本示例没有属性,stateBeforeAttrName 读到 >,触发 onopentagend 回调,状态切回 State.Text

ts 复制代码
// tokenizer.ts 648~656行
private stateBeforeAttrName(c: number): void {
    if (c === CharCodes.Gt) { // ">" 字符
      this.cbs.onopentagend(this.index)
      if (this.inRCDATA) {
        this.state = State.InRCDATA
      } else {
        this.state = State.Text
      }
      this.sectionStart = this.index + 1
    } 
    // ...其他代码省略
}

onopentagend 调用 endOpenTag,核心两件事:

  1. addNode:把 currentOpenTag 添加到父节点(此时根)的 children;
  2. 非 void 标签,将节点 unshift 压入解析栈 stack,后续子节点就挂载到栈顶元素;void 标签不压栈。
ts 复制代码
// parser.ts 153~155行
onopentagend(end) {
    endOpenTag(end)
},

// parser.ts 912~914行
function addNode(node: TemplateChildNode) {
  ;(stack[0] || currentRoot).children.push(node)
}
    
// parser.ts 573~592行
function endOpenTag(end: number) {
  // 代码省略...

  // 将当前已经解析完成的openTag节点,添加到AST父节点的子节点列表中
  addNode(currentOpenTag!)

  // 代码省略...

  // 判断:该标签是void空标签(HTML void标签:br、img、input等,无需闭合)
  if (currentOptions.isVoidTag(tag)) {
    // void标签直接执行关闭标签逻辑,不压入栈,不需要后续</xxx>闭合
    onCloseTag(currentOpenTag!, end)
  } else {
    // 普通非void标签:把标签压入栈头部,维护嵌套层级,后续遇到闭合标签出栈
    stack.unshift(currentOpenTag!)

    // 如果是 SVG / MathML 命名空间,通知分词器切换为XML解析模式
    // SVG/MathML遵循XML语法,和普通HTML容错解析规则不一样,打开inXML开关
    if (ns === Namespaces.SVG || ns === Namespaces.MATH_ML) {
      tokenizer.inXML = true
    }
  }

  // 清空当前正在构建的标签对象,准备解析下一个节点
  currentOpenTag = null
}

至此:<div> 解析完成,stack[0] = div 元素节点,后续解析到的子节点都会挂载到 div。状态回到 State.Text

步骤 4:解析文本 hello,挂载到 div 的 children

Tokenizer 继续循环,处于 State.Text,读取普通字符 h e l l o。 直到再次读到 <</div> 的小于号),此时 sectionStart ~ index 是文本范围,触发 ontext 回调。

ts 复制代码
// tokenizer.ts 334~348行
private stateText(c: number): void {    
  // 遇到标签开始符 <,结束当前文本解析,切换为标签解析状态    
  if (c === CharCodes.Lt) {    
    if (this.index > this.sectionStart) {    
        this.cbs.ontext(this.sectionStart, this.index)    
    }    
    this.state = State.BeforeTagName    
    this.sectionStart = this.index    
  } 
 
  // 普通文本字符:无任何逻辑,继续遍历    
}

Parser 的 onText:取栈顶 stack[0] 作为父节点(就是 div),把 TEXT 节点 push 进父节点 children。

ts 复制代码
// parser.ts 594~614行
function onText(content: string, start: number, end: number) {    
  // 有嵌套节点 div ,文本挂载到 div 上
  const parent = stack[0] || currentRoot    
  const lastNode = parent.children[parent.children.length - 1]    
  // 代码省略 ...
    parent.children.push({    
      type: NodeTypes.TEXT,    
      content,    
      loc: getLoc(start, end),    
    })    
  }    
}

此时 AST:div 的 children 中已经存在 {type:TEXT, content:'hello'}。触发<后状态再次切回 State.BeforeTagName,准备解析闭合标签。

步骤 5:解析闭合标签 </div>,栈弹出

State.BeforeTagName 读到 /,切换状态到 State.BeforeClosingTagName

ts 复制代码
// tokenizer.ts 559~595行
private stateBeforeTagName(c: number): void {
  if(){ 
      // 代码省略...
  } else if (c === CharCodes.Slash) { // `/`,对应 `</`,闭合标签
    this.state = State.BeforeClosingTagName
  } else {
    //`<`后面跟的字符不属于合法标签开头,退回文本解析
    this.state = State.Text
    this.stateText(c)
  }
}

State.BeforeClosingTagName 读到标签名字母d,切换为 State.InClosingTagName

ts 复制代码
// tokenizer.ts 144~149行
function isTagStartChar(c: number): boolean {
  return (
    (c >= CharCodes.LowerA && c <= CharCodes.LowerZ) ||
    (c >= CharCodes.UpperA && c <= CharCodes.UpperZ)
  )
}

// tokenizer.ts 616~632行
private stateBeforeClosingTagName(c: number): void {
    if (isWhitespace(c)) {
      // Ignore
    } else if (c === CharCodes.Gt) {
      if (__DEV__ || !__BROWSER__) {
        this.cbs.onerr(ErrorCodes.MISSING_END_TAG_NAME, this.index)
      }
      this.state = State.Text
      // Ignore
      this.sectionStart = this.index + 1
    } else {
      this.state = isTagStartChar(c) 
        ? State.InClosingTagName
        : State.InSpecialComment
      this.sectionStart = this.index
    }
}

State.InClosingTagName 读到 >,触发 onclosetag 回调,进入 State.AfterClosingTagName

ts 复制代码
// tokenizer.ts 633~640行
private stateInClosingTagName(c: number): void {
    if (c === CharCodes.Gt || isWhitespace(c)) {
      this.cbs.onclosetag(this.sectionStart, this.index)
      this.sectionStart = -1
      this.state = State.AfterClosingTagName
      this.stateAfterClosingTagName(c)
    }
}

// tokenizer.ts 641~647行
private stateAfterClosingTagName(c: number): void {
    // Skip everything until ">"
    if (c === CharCodes.Gt) {
      this.state = State.Text
      this.sectionStart = this.index + 1
    }
}

Parser onclosetag 回调:处理栈的弹出逻辑

  • 取出闭合标签名;void 标签直接跳过;
  • 从栈顶向下遍历,寻找匹配的开始标签;
  • 如果匹配位置 i>0,说明中间有标签未闭合,抛出缺失结束标签错误;
  • 将栈顶直到匹配项全部 shift 弹出,调用 onCloseTag
  • 找不到匹配标签则抛出无效闭合标签错误。
ts 复制代码
// parser.ts 157~179行
onclosetag(start, end) {
    // 截取 </xxx> 中间的标签名字符串,例如 </div> → "div"
    const name = getSlice(start, end)

    // void标签(br/img/input) 不允许出现闭合标签 </br>,直接跳过处理
    if (!currentOptions.isVoidTag(name)) {
      let found = false // 标记:栈中是否匹配到对应的开启标签

      // 从栈顶(最内层标签)开始向后遍历标签栈,寻找匹配的开启标签
      for (let i = 0; i < stack.length; i++) {
        const e = stack[i]
        // 不区分大小写匹配标签名(HTML标签大小写不敏感)
        if (e.tag.toLowerCase() === name.toLowerCase()) {
          found = true

          // i > 0:匹配到的标签**不是栈顶**,说明中间有标签没有闭合,存在标签嵌套错乱
          // 例:<div><span></div>  遇到</div>,栈:[span, div],i=1,i>0,span未闭合
          if (i > 0) {
            // 报错 X_MISSING_END_TAG:存在缺失结束标签的元素,传入未闭合标签的起始位置
            emitError(ErrorCodes.X_MISSING_END_TAG, stack[0].loc.start.offset)
          }

          // j从0循环到i:把从栈顶直到匹配标签为止的所有元素全部弹出
          // 例子 stack=[span, div],i=1;j=0弹出span;j=1弹出div
          for (let j = 0; j <= i; j++) {
            // shift弹出栈头部,!非空断言,状态机保证一定有值
            const el = stack.shift()!
            // j < i 为true:代表这个元素是被强制提前关闭,本身没有遇到自己的</xxx>
            onCloseTag(el, end, j < i)
          }
          break // 找到匹配标签,跳出外层for循环
        }
      }

      // 遍历完整栈,没有找到任何匹配的开启标签
      // 例:源码直接写 </div>,前面没有 <div>
      if (!found) {
        // X_INVALID_END_TAG:无效闭合标签;backTrack回溯找到 < 的位置用于报错定位
        emitError(ErrorCodes.X_INVALID_END_TAG, backTrack(start, CharCodes.Lt))
      }
    }
},

本例:stack = [div],i=0,直接 shift 弹出 div;解析栈清空,整个<div>hello</div>解析完成。

整体流程总结

  1. State.Text → 遇到<BeforeTagName →识别普通标签名→InTagName
  2. 遇到>触发onopentagname+onopentagend,构建 element 节点,addNode 加入父节点,节点压入 stack;状态切回 Text
  3. 读取中间文本,遇到下一个<触发ontext,文本节点挂载到栈顶父元素;
  4. <后遇到/进入闭合标签流程,经过BeforeClosingTagNameInClosingTagName
  5. 遇到>触发onclosetag,从解析栈弹出对应元素,完成整个标签解析。

关键要点回顾

  • stack 栈:维护 DOM 嵌套层级,栈顶就是当前正在解析的父节点
  • 回调驱动:Tokenizer 只管字符扫描、状态流转;AST 节点创建、挂载、栈操作全部由 Parser 的回调完成;
  • void 标签(br/img/input)解析完开标签直接关闭,不会压入 stack,不需要闭合标签。
相关推荐
顶点多余40 分钟前
那些在算法中适合巩固的知识点---1
java·前端·算法
Setsuna_F_Seiei1 小时前
前端转型 Agent 开发 03 之 Agent Tools - 给 Agent 装上手脚
前端·agent·ai编程
jay神1 小时前
【计算机毕业设计】基于SpringBoot的程序教学辅助系统
java·前端·vue.js·spring boot·后端·毕业设计·课程设计
小磊哥er2 小时前
深入解构Claude Code - 第 8 篇 · 数据放哪、钱怎么算
javascript·ai编程
kyriewen4 小时前
我装了30多个Skill,给AI安排了8个岗位
前端·javascript·ai编程
风骏时光牛马4 小时前
大模型落地实践中的技术选型思路与方案对比
前端
IT_陈寒4 小时前
Vite打包时的静态资源坑,我帮你踩过了
前端·人工智能·后端
默_笙6 小时前
🔥 让 AI 帮我写完整个 Next.js 博客:框架就是 AI 的"上下文 buff"
前端·javascript
牧艺6 小时前
登录页还在用渐变?我一口气做了 5 个能摸的背景动效:吹蒲公英、滴墨染水、点熔岩闷裂
前端·canvas·交互设计