从案例分析 Vue3 Tokenizer 源码一

Vue3.4+ 模板 AST 生成原理

新旧流程对比

  • Vue3.4 之前 模板字符串 → Scanner 生成完整 Token 数组 → Parser 循环消费 Token → 构建 AST。缺点:需要先把全部 Token 存入数组,长模板会占用额外内存

  • Vue3.4+ 新版 模板字符串 → Tokenizer 逐字符状态机扫描 → 识别文本 / 标签 / 插值 / 属性等单元 → 同步回调即时创建 AST 节点,不再生成完整 Token 数组

核心源码(保留原有代码,补充注释与类型语义)

ts 复制代码
// compile.ts
import { baseParse } from './parser'
import type { CodegenResult, CompilerOptions, RootNode } from './types'

export function baseCompile(
  source: string | RootNode,
  options: CompilerOptions = {},
): CodegenResult {
  // ... 其他编译前置逻辑省略
  const ast = isString(source) ? baseParse(source, resolvedOptions) : source
  // ... transform、generate 后续逻辑省略
}
ts 复制代码
// parser.ts
import Tokenizer from './tokenizer'
import { NodeTypes, RootNode, ElementNode } from './types'
import { getLoc, createRoot } from './utils'

let currentRoot: RootNode | null = null
let currentInput = ''
const stack: ElementNode[] = []

type Callbacks = {
  onText: (start: number, end: number) => void
  // 还包含 onTagOpen / onTagClose / onInterpolation / onAttribute 等回调,这里仅保留示例onText
}

export function baseParse(input: string, options?: ParserOptions): RootNode {
  currentInput = input
  const root = (currentRoot = createRoot([], input))
  // 实例化字符扫描器,传入元素栈和节点生成回调
  const tokenizer = new Tokenizer(stack, {
    onText(start: number, end: number) {
      const parent = stack[0] || currentRoot!
      const content = currentInput.slice(start, end)
      parent.children.push({
        type: NodeTypes.TEXT,
        content,
        loc: getLoc(start, end),
      })
    },
  })
  tokenizer.parse(currentInput)
  currentRoot = null
  return root
}
ts 复制代码
// tokenizer.ts
import { CharCodes, State } from './constants'

export default class Tokenizer {
  private buffer = ''
  private index = 0
  private sectionStart = 0
  private state: State = State.Text

  constructor(
    private readonly stack: ElementNode[],
    private readonly cbs: Callbacks,
  ) {}

  public parse(input: string): void {
    this.buffer = input
    this.index = 0
    this.sectionStart = 0
    this.state = State.Text
    // 逐字符遍历模板字符串
    while (this.index < this.buffer.length) {
      const c = this.buffer.charCodeAt(this.index)
      // 根据当前状态分发处理逻辑
      switch (this.state) {
        case State.Text:
          this.stateText(c)
          break
        // 省略 BeforeTagName / InTagName / InAttr 等其他状态分支
      }
      this.index++
    }
  }

  private stateText(c: number): void {
    // 遇到 < 符号:截断当前文本区间,触发onText回调,切换为标签解析状态
    if (c === CharCodes.Lt) {
      if (this.index > this.sectionStart) {
        this.cbs.onText(this.sectionStart, this.index)
      }
      this.state = State.BeforeTagName
      this.sectionStart = this.index
    }
    // 省略文本状态下插值 {{、换行等其他分支判断
  }
}

示例执行流程:source = "123<div></div>"

  1. baseCompile: 判断 source 是字符串,调用 baseParse

  2. baseParse

    • 初始化根 root 节点,赋值全局currentRoot
    • 创建 Tokenizer 实例,传入元素栈、onText 回调(用来生成 TEXT 节点)
    • 执行 tokenizer.parse(source) 启动逐字符扫描
  3. Tokenizer.parse

    开始逐个字符遍历:

    • 依次读取123,处于State.Text状态
    • 读到<,触发文本截断:调用onText(0,3),把"123"生成 TEXT 节点,挂载到根节点 children
    • 状态切换为State.BeforeTagName,继续解析 div 开始标签、入栈元素节点
    • 后续识别闭合</div>,出栈元素
  4. 扫描全部字符结束,baseParse 清空全局 currentRoot,返回构建完成的 Root AST

source="123<div></div>"时,执行逻辑如下:

  1. Tokenizer:通过 new Tokenizer 创建 tokenizer 对象,注册回调函数
  2. baseCompile: 会调用 baseParse 传入 source 字符
  3. baseParse :会把 source 字符赋值给全局变量 currentInput ,通过 createRoot 函数创造根节点 root 并赋值给全局变量 currentRoot,调用实例化的 tokenizer.parse ,最后返回根节点 root
  4. tokenizer.parse:逐字符扫描模板,根据状态调用回调,例如 onText 回调会将 TEXT 节点挂载到根节点

分场景理解 Tokenizer 源码

基于 Vue 源码 __tests__/parse.spec.ts 纯文本场景测试用例,完整拆解 Tokenizer 对普通文本的解析逻辑、loc 位置信息生成规则、换行/多余空格处理原理,全程保留核心源码,按「执行流程+核心原理+案例佐证」的逻辑梳理,清晰还原 Vue 模板文本解析底层机制。

情形一:仅文本字符(source="\n some text")

测试场景:模板源码仅含换行、空格与普通文本,无标签、无插值语法,是最基础的模板解析场景。

ts 复制代码
describe('Text', () => {
    test('simple text', () => {
      const ast = baseParse('\n   some text')
      const text = ast.children[0] as TextNode

      expect(text).toStrictEqual({
        type: NodeTypes.TEXT,
        content: ' some text',
        loc: {
          start: { offset: 1, line: 1, column: 0 },
          end: { offset: 13, line: 2, column: 13 },
          source: '\n   some text',
        },
      })
})

核心疑问:

  • baseParse 如何逐段解析纯文本字符、生成文本 AST 节点?
  • AST 节点中 loc 行列、偏移量、源码区间如何精准计算?
  • 模板中的换行符、首尾/连续多余空格如何预处理与压缩?

整体执行链路:baseParse 入口流程

所有模板解析均从 baseParse 函数启动,核心流程:创建根 AST 节点 → Tokenizer 逐字符分词解析 → 空白字符规范化处理 → 回填根节点信息并返回 AST。

ts 复制代码
// baseParse 核心源码
let currentRoot;
export function baseParse(input: string, options?: ParserOptions): RootNode {    
  // parser.ts 1073~1076行    
  const root = (currentRoot = createRoot([], input)) // 绑定全局根节点变量    
  tokenizer.parse(currentInput) // 启动分词器逐字符解析模板    
  root.loc = getLoc(0, input.length) // 根节点绑定全局位置信息    
  root.children = condenseWhitespace(root.children) // 规范化空白字符    
  currentRoot = null // 清空全局临时变量    
  return root    
}

分步执行逻辑

  1. 创建根节点:调用 createRoot 生成模板根 AST 节点,初始化基础属性;
  2. 分词解析:执行 tokenizer.parse,逐字符遍历模板字符串,识别文本、标签、插值等语法;
  3. 空白处理:通过 condenseWhitespace 压缩、清理冗余换行与空格;
  4. 收尾返回:绑定根节点位置信息,清空全局变量,输出完整 AST。

createRoot 根节点创建源码:根节点为整棵 AST 的顶层节点,存储模板原始源码、子节点、编译辅助变量等全局信息。

ts 复制代码
// ast.ts 603~622行
export function createRoot(    
  children: TemplateChildNode[],    
  source = '',    
): RootNode {    
  return {    
    type: NodeTypes.ROOT,    
    source,    
    children,    
    helpers: new Set(),    
    components: [],    
    directives: [],    
    hoists: [],    
    imports: [],    
    cached: [],    
    temps: 0,    
    codegenNode: undefined,    
    loc: locStub,    
  }    
}

Tokenizer 文本分词核心逻辑

Tokenizer 分词器默认初始状态为 State.Text(文本状态),绝大多数普通字符都会走文本解析逻辑,仅特殊触发字符会切换解析状态。

分词主循环源码:逐字符遍历模板,单独捕获换行符位置,用于后续loc 行列计算,默认进入文本解析分支。

ts 复制代码
export default class Tokenizer {    
  // tokenizer.ts 944~955行    
  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    
          }    
          // 其他状态分支省略(标签、插值、属性等)    
      }    
      this.index++    
    }    
    this.cleanup() // 收尾处理剩余文本片段    
    this.finish()    
  }    
}

文本状态处理 stateText:仅对特殊字符切换状态,普通文本字符直接留存,不做额外处理。触发状态切换的特殊字符仅有3类:标签左尖括号、实体符、插值起始符,其余所有字符均判定为普通文本。

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    
  } 
  // 浏览器环境外,遇到实体符&,进入实体解析状态
  else if (!__BROWSER__ && c === CharCodes.Amp) {    
    this.startEntity()    
  } 
  // 非v-pre环境下,遇到插值起始符,进入插值解析状态
  else if (!this.inVPre && c === this.delimiterOpen[0]) {    
    this.state = State.InterpolationOpen    
    this.delimiterIndex = 0    
    this.stateInterpolationOpen(c)    
  }    
  // 普通文本字符:无任何逻辑,继续遍历    
}

文本片段收尾 cleanup:分词主循环结束后,调用 cleanup 处理最后一段未收尾的文本片段,触发 ontext 回调,生成文本节点。

ts 复制代码
// tokenizer.ts 1098~1117行
private cleanup() {    
  if (this.sectionStart !== this.index) {    
    // 文本状态、CDATA状态下,收尾文本片段
    if (    
      this.state === State.Text ||    
      (this.state === State.InRCDATA && this.sequenceIndex === 0)    
    ) {    
      this.cbs.ontext(this.sectionStart, this.index)    
      this.sectionStart = this.index    
    } 
    // 属性值状态收尾逻辑(省略)
    else if (    
      this.state === State.InAttrValueDq ||    
      this.state === State.InAttrValueSq ||    
      this.state === State.InAttrValueNq    
    ) {    
      this.cbs.onattribdata(this.sectionStart, this.index)    
      this.sectionStart = this.index    
    }    
  }    
}

ontext 回调:生成文本 AST 节点。分词器初始化时绑定 ontext 回调,接收文本片段的起止索引,最终生成/合并文本节点,挂载到根节点 children 数组。

ts 复制代码
// parser.ts 100~105行
const tokenizer = new Tokenizer(stack, {    
  ontext(start, end) {    
    onText(getSlice(start, end), start, end)    
  },    
})

// parser.ts 594~614行
function onText(content: string, start: number, end: number) {    
  // 无嵌套节点时,文本挂载到根节点
  const parent = stack[0] || currentRoot    
  const lastNode = parent.children[parent.children.length - 1]    
  // 连续文本节点:合并内容、更新结束位置
  if (lastNode && lastNode.type === NodeTypes.TEXT) {    
    lastNode.content += content    
    setLocEnd(lastNode.loc, end)    
  } 
  // 全新文本节点:创建节点、生成loc位置信息并推入子节点数组
  else {    
    parent.children.push({    
      type: NodeTypes.TEXT,    
      content,    
      loc: getLoc(start, end),    
    })    
  }    
}

loc 位置信息生成机制

loc 是 Vue 模板编译报错精准定位的核心,包含:起止行列号、字符偏移量、对应源码片段,通过 getLoc + getPos 双层方法计算生成。getLoc 统一封装位置信息

ts 复制代码
// parser.ts 916~924行
function getLoc(start: number, end?: number): SourceLocation {    
  return {    
    start: tokenizer.getPos(start), // 起始位置(行、列、偏移)    
    end: end == null ? end : tokenizer.getPos(end), // 结束位置    
    source: end == null ? end : getSlice(start, end), // 对应源码片段    
  }    
}

// 截取对应索引区间的源码字符串
function getSlice(start: number, end: number) {    
  return currentInput.slice(start, end)    
}

getPos 索引转行列位置核心算法:通过预存储的 this.newlines(所有换行符的索引数组),将字符串一维偏移索引,转换为编辑器二维行列位置。

优化策略:换行符数组长度超100时用二分查找,否则倒序遍历,保证解析性能。

ts 复制代码
// tokenizer.ts 296~328行
public getPos(index: number): Position {
    // 默认值:如果前面没有任何换行,就是第1行,列默认 index+1
    let line = 1
    let column = index + 1
    const length = this.newlines.length // this.newlines 换行符字符位置数组
    let j = -1 // -1 代表 index 前面没有换行符

    // 换行数组比较长,用二分查找优化
    if (length > 100) {
      let l = -1
      let r = length
      while (l + 1 < r) {
        const m = (l + r) >>> 1 // 无符号右移,等价 Math.floor((l+r)/2)
        // 如果当前换行位置 < index → 目标在右侧,更新左边界
        this.newlines[m] < index ? (l = m) : (r = m)
      }
      j = l // 循环结束 l 就是最后一个满足 newlines[l] < index 的下标
    } else {
      // 数组很短,直接倒序遍历,找到第一个小于 index 的换行
      for (let i = length - 1; i >= 0; i--) {
        if (index > this.newlines[i]) {
          j = i
          break
        }
      }
    }

    // 找到了前面存在换行符
    if (j >= 0) {
      line = j + 2 // newlines[j] 是第 j+1 行末尾的换行,所以当前是 j+2 行
      column = index - this.newlines[j] // 当前索引距离上一个换行的距离(列从1开始?这里注意!)
    }

    return {
      column,
      line,
      offset: index,
    }
}

算法案例实操

测试模板:source = a

bc

def,格式化后等效:a\nbc\ndef,换行符索引数组:this.newlines = [1,4]

  • index=0(字符a):无前置换行,j=-1 → line=1、column=1、offset=0
  • index=1(换行符\n):无小于1的换行,j=-1 → line=1、column=2、offset=1(换行符归属上一行末尾)
  • index=2(字符b):前置最近换行索引1,j=0 → line=2、column=1、offset=2
  • index=4(第二个\n):前置最近换行索引1,j=0 → line=2、column=3、offset=4
  • index=5(字符d):前置最近换行索引4,j=1 → line=3、column=1、offset=5

空白字符规范化处理(换行/多余空格)

分词完成后,baseParse 最终执行 condenseWhitespace,对文本节点的换行、空格、Tab 做统一规范化处理,是 Vue 模板空白优雅渲染的核心。

核心配置规则

  • 默认模式:whitespace: 'condense'(压缩模式)
  • 特殊模式:whitespace: 'preserve'(保留所有空白,不执行压缩逻辑)
  • <pre> 标签内:仅统一换行符 \r\n\n,所有空白原样保留

空白压缩完整源码

ts 复制代码
// parser.ts 837~883行
function condenseWhitespace(nodes: TemplateChildNode[]): TemplateChildNode[] {    
  const shouldCondense = currentOptions.whitespace !== 'preserve'    
  let removedWhitespace = false    
  for (let i = 0; i < nodes.length; i++) {    
    const node = nodes[i]    
    // 仅处理文本节点,元素、注释节点直接跳过
    if (node.type === NodeTypes.TEXT) {    
      // 非pre标签内,执行空白压缩逻辑
      if (!inPre) {    
        // 场景1:文本节点为【纯空白】(仅空格/换行/Tab)
        if (isAllWhitespace(node.content)) {    
          const prev = nodes[i - 1] && nodes[i - 1].type    
          const next = nodes[i + 1] && nodes[i + 1].type    
          // 满足以下条件直接删除纯空白节点:
          // 1. 首尾空白节点  2. 注释间/注释与元素间空白  3. 元素间带换行的空白
          if (    
            !prev ||    
            !next ||    
            (shouldCondense &&    
              ((prev === NodeTypes.COMMENT &&    
                (next === NodeTypes.COMMENT || next === NodeTypes.ELEMENT)) ||    
                (prev === NodeTypes.ELEMENT &&    
                  (next === NodeTypes.COMMENT ||    
                    (next === NodeTypes.ELEMENT &&    
                      hasNewlineChar(node.content))))))    
          ) {    
            removedWhitespace = true    
            nodes[i] = null as any    
          } 
          // 元素间无换行的空白:保留单个空格
          else {    
            node.content = ' '    
          }    
        } 
        // 场景2:非纯空白文本,压缩内部连续空白为单个空格
        else if (shouldCondense) {    
          node.content = condense(node.content)    
        }    
      } 
      // pre标签内:仅统一换行符,不压缩空白
      else {    
        node.content = node.content.replace(windowsNewlineRE, '\n')    
      }    
    }    
  }    
  // 过滤被置空的空白节点
  return removedWhitespace ? nodes.filter(Boolean) : nodes    
}

空白处理核心规则总结

  1. 纯空白文本节点(仅空格/换行/Tab)

    • 模板首尾空白、注释与元素间空白、元素间带换行的空白:直接删除

    • 元素间无换行的空白:保留1个空格

  2. 含有效内容的文本节点:内部所有连续空格、换行、Tab:统一压缩为单个空格

  3. <pre> 标签内部

    • 不执行任何空白压缩,保留原始排版

    • 仅将 Windows 换行 \r\n 统一转为 \n,统一跨平台渲染一致性

全文流程总结

  1. 分词阶段:Tokenizer 默认文本状态遍历字符,仅特殊语法切换状态,普通文本全部留存,结束后生成原始文本节点;
  2. 位置计算:通过换行符索引数组+二分/遍历算法,将一维字符偏移转为二维行列位置,精准生成 loc 信息;
  3. 空白优化:通过 condenseWhitespace 清理冗余换行、压缩连续空格,兼顾渲染美观性与浏览器、SSR 一致性。
相关推荐
BigTopOne1 小时前
【WebRtc】-ICE Candidate 与 STUN/TURN 原理详解
前端
l1258651 小时前
# LangGraph Memory机制深度解析:短期记忆与长期记忆的工程实践
前端·人工智能·python·langchain·bootstrap
码视野2 小时前
基于 Spring Boot + Vue3 的【大学英语四六级 (CET-4/6) 作文智能评分与句式润色系统】设计与实现(含PRD/三端高保真源码/大屏)
java·前端·人工智能·spring boot·后端·vue3
大模型码小白2 小时前
AI 对话流性能调优:万级消息的虚拟滚动落地
java·大数据·前端·javascript·人工智能·算法·机器学习
m0_547486662 小时前
《JavaScript核心原理 》全套PPT课件2026
开发语言·javascript·ecmascript
xm_xm_xm_13 小时前
react17版本以前类组件常用操作
前端·javascript·react.js
ly76894 小时前
Web 性能优化实战:从 Core Web Vitals 到工程化性能治理
前端·性能优化
冬奇Lab4 小时前
一天一个开源项目(第200篇):next-forge - 生产级 Next.js SaaS 启动模板
前端·前端框架·开源
Profile排查笔记4 小时前
指纹浏览器怎么设置 IP?代理配置、检测与排错流程
前端·人工智能·后端·自动化