从案例分析 Vue3 Tokenizer 源码二

从案例分析 Vue3 Tokenizer 源码一:仅文本

情形二:文本含插值(source="some {{ foo + bar }} text")

本小节核心验证文本节点与插值节点的解析逻辑。插值表达式已有独立测试用例,源码分析阶段可将普通文本与插值场景合并梳理,我们先看对应的测试用例,验证解析后生成的 AST 结构是否符合预期。

ts 复制代码
// __tests__/parse.spec.ts 64~87行
test('text with interpolation', () => {
      const ast = baseParse('some {{ foo + bar }} text')
      const text1 = ast.children[0] as TextNode
      const text2 = ast.children[2] as TextNode
      const text3 = ast.children[1] as TextNode

      expect(text1).toStrictEqual({
        type: NodeTypes.TEXT,
        content: 'some ',
        loc: {
          start: { offset: 0, line: 1, column: 1 },
          end: { offset: 5, line: 1, column: 6 },
          source: 'some ',
        },
      })
      expect(text2).toStrictEqual({
        type: NodeTypes.TEXT,
        content: ' text',
        loc: {
          start: { offset: 20, line: 1, column: 21 },
          end: { offset: 25, line: 1, column: 26 },
          source: ' text',
        },
      })
    
      expect(text3).toStrictEqual({
        type: NodeTypes.INTERPOLATION,
        content:{
            "type": NodeTypes.SIMPLE_EXPRESSION,
            "loc": {
                "start": {"column": 9,"line": 1,"offset": 8},
                "end": {"column": 18,"line": 1,"offset": 17},
                "source": "foo + bar"
            },
            "content": "foo + bar",
            "isStatic": false,
            "constType": 0
        }
        loc: {
          start: { offset: 5, line: 1, column: 6 },
          end: { offset: 20, line: 1, column: 21 },
          source: "{{ foo + bar }}",
        },
      })
})

baseParser.ts 的整体解析流程已在情形一中介绍,下面重点分析 tokenizer.parse普通文本 + 插值的流式处理逻辑。

文本与插值解析一共设计 4 种状态:State.TextState.InterpolationOpenState.InterpolationState.InterpolationClose,解析初始默认状态为 this.state = State.Text。Tokenizer 入口核心代码如下:

ts 复制代码
export default class Tokenizer {    
  // 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.InterpolationOpen: {
                this.stateInterpolationOpen(c)
                break
            }
            case State.Interpolation: {
                this.stateInterpolation(c)
                break
            }
            case State.InterpolationClose: {
                this.stateInterpolationClose(c)
                break
            }
          // 其他状态分支省略(标签、插值、属性等)    
      }    
      this.index++    
    }    
    this.cleanup() // 收尾处理剩余文本片段    
    this.finish()    
  }    
}

针对示例源码 some {``{ foo + bar }} text: 在 index < 5 阶段,解析始终处于 State.Text 普通文本状态;直到读取到 { 字符时,状态切换为 State.InterpolationOpen,对应逻辑在 stateText 中实现:

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)    
  }    
  // 普通文本字符:无任何逻辑,Tokenizer.parse 继续遍历    
}

切换至 State.InterpolationOpen 后,会先重置 this.delimiterIndex = 0,该变量用于插值分隔符匹配计数。状态切换后会直接调用 stateInterpolationOpen ,而不是等待下一轮循环,目的是立刻校验完整的插值起始分隔符 {``{

ts 复制代码
// tokenizer.ts 354~373行
private stateInterpolationOpen(c: number): void {
  if (c === this.delimiterOpen[this.delimiterIndex]) {
    // 完整匹配 {{ 分隔符
    if (this.delimiterIndex === this.delimiterOpen.length - 1) {
      const start = this.index + 1 - this.delimiterOpen.length
      // 先提交前置普通文本
      if (start > this.sectionStart) {
        this.cbs.ontext(this.sectionStart, start)
      }
      // 切换状态,开始收集插值内容
      this.state = State.Interpolation
      this.sectionStart = start
    } else {
      this.delimiterIndex++
    }
  } else if (this.inRCDATA) {
    // RCDATA 环境匹配失败,回退原始文本模式
    this.state = State.InRCDATA
    this.stateInRCDATA(c)
  } else {
    // 匹配失败,回退普通文本模式
    this.state = State.Text
    this.stateText(c)
  }
}

{``{ 分隔符完整匹配流程梳理:

js 复制代码
//  初始化:
	this.delimiterIndex = 0
//  ⬇
//  进入stateInterpolationOpen,匹配到第一个分隔符`{`:
	this.delimiterIndex === this.delimiterOpen.length - 1  // this.delimiterOpen.length = 2
		? this.state = State.Interpolation // 匹配到第二个分隔符`{`,状态切换为 State.Interpolation
		: this.delimiterIndex++

补充:this.delimiterOpen 使用 Uint8Array 存储而非普通数组。 Uint8Array 属于 TypedArray,底层是连续的 ArrayBuffer 二进制内存,仅可存储 0~255 单字节整数,刚好对应 ASCII 字符码;数组长度固定,读写性能更高,非常适合字符码流式解析这类底层场景。

当成功匹配 {``{ 后,会通过 this.cbs.ontext(this.sectionStart, start) 将插值之前的普通文本生成文本节点,并入根节点的 children 数组,该回调的基础逻辑已在情形一中说明。 this.sectionStart 初始值为 0,用来标记当前正在收集的连续文本 / 节点区间起始下标,和不断自增的 index 配合截取源码区间,是 Vue3.4 + 流式 Tokenizer(不预存完整 token、边扫描边回调)的核心位置标记。提交文本节点后,更新 this.sectionStart = start,准备收集插值内部表达式。

ts 复制代码
// tokenizer.ts 375~381行
private stateInterpolation(c: number): void {
  if (c === this.delimiterClose[0]) {
    this.state = State.InterpolationClose
    this.delimiterIndex = 0
    this.stateInterpolationClose(c)
  }
}

State.Interpolation 阶段只监听插值结束分隔符第一个 },一旦命中就切换为 State.InterpolationClose,重置计数并立即执行 stateInterpolationClose,逻辑和前面 InterpolationOpen 一致。

ts 复制代码
// tokenizer.ts 383~400行
private stateInterpolationClose(c: number) {
  if (c === this.delimiterClose[this.delimiterIndex]) {
    // 完整匹配 }},插值解析完成
    if (this.delimiterIndex === this.delimiterClose.length - 1) {
      this.cbs.oninterpolation(this.sectionStart, this.index + 1)
      // 根据环境切换后续状态
      this.state = this.inRCDATA ? State.InRCDATA : State.Text
      this.sectionStart = this.index + 1
    } else {
      this.delimiterIndex++
    }
  } else {
    // 匹配失败,回退插值内容解析状态
    this.state = State.Interpolation
    this.stateInterpolation(c)
  }
}

直到完整匹配 }},代表当前插值解析结束,触发 oninterpolation 回调来生成插值 AST 节点。

ts 复制代码
// parser.ts 111~137行
oninterpolation(start, end) {
    // v-pre:不解析插值,直接当做普通文本处理
    if (inVPre) {
        return onText(getSlice(start, end), start, end)
    }
    // 剥离前后 {{ }},裁剪首尾空白
    let innerStart = start + tokenizer.delimiterOpen.length
    let innerEnd = end - tokenizer.delimiterClose.length
    while (isWhitespace(currentInput.charCodeAt(innerStart))) innerStart++
    while (isWhitespace(currentInput.charCodeAt(innerEnd - 1))) innerEnd--
    let exp = getSlice(innerStart, innerEnd)
    // 表达式内部HTML实体解码
    if (exp.includes('&')) {
        exp = __BROWSER__ ? currentOptions.decodeEntities!(exp, false) : decodeHTML(exp)
    }
    // 创建插值AST节点
    addNode({
      type: NodeTypes.INTERPOLATION,
      content: createExp(exp, false, getLoc(innerStart, innerEnd)),
      loc: getLoc(start, end),
    })
},
// parser.ts 912~914行
function addNode(node: TemplateChildNode) {
  ;(stack[0] || currentRoot).children.push(node)
}

oninterpolation 回调会自动完成几项工作:v-pre 降级处理、剔除插值表达式首尾空白、HTML 实体解码,最后生成 INTERPOLATION 节点并推入根节点的 children。

插值节点内部的表达式节点由 createExp 创建:

ts 复制代码
// parser.ts 971~1010行
function createExp(
  content: SimpleExpressionNode['content'], // 原始表达式字符串,比如 "msg + 1"
  isStatic: SimpleExpressionNode['isStatic'] = false, // 是否静态文本(不需要执行)
  loc: SourceLocation, // 在模板源码中的位置信息(行列、offset,报错用)
  constType: ConstantTypes = ConstantTypes.NOT_CONSTANT, // 是否常量
  parseMode = ExpParseMode.Normal, // 表达式解析模式 Normal / Statements / Params
){
  const exp = createSimpleExpression(content, isStatic, loc, constType)
  // 判断是否需要走 Babel 解析生成 exp.ast
  if (
    !__BROWSER__ && // **非浏览器端编译**(打包时预编译,不是浏览器实时 compile;浏览器里不引入 babel,不做静态分析)
    !isStatic && // 不是静态字符串,是可执行 JS 表达式
    currentOptions.prefixIdentifiers && // 开启标识符前缀模式(module 模式,需要自动补`_ctx.xxx`)
    parseMode !== ExpParseMode.Skip && // 非跳过解析模式
    content.trim() // 表达式非空字符串
  ) {
	// 代码省略
  }
  return exp
}
// ast.ts 697~710行
export function createSimpleExpression(
  content: SimpleExpressionNode['content'],
  isStatic: SimpleExpressionNode['isStatic'] = false,
  loc: SourceLocation = locStub,
  constType: ConstantTypes = ConstantTypes.NOT_CONSTANT,
): SimpleExpressionNode {
  return {
    type: NodeTypes.SIMPLE_EXPRESSION,
    loc,
    content,
    isStatic,
    constType: isStatic ? ConstantTypes.CAN_STRINGIFY : constType,
  }
}

createExp 用于生成 SimpleExpressionNode 模板表达式 AST 节点。仅在非浏览器构建、开启 prefixIdentifiers(Module 模式,不使用 with)的场景下 ,才会借助 Babel 将模板 JS 表达式进一步解析成原生 JS AST(exp.ast),用于静态分析和自动补全_ctx.xxx标识符前缀;浏览器端实时编译会直接跳过 Babel 解析,不会生成 exp.ast 属性。

创建为插值节点后,最后会调用 this.cleanup() 收尾处理剩余文本片段并将文本节点插入到 root.children

相关推荐
单线程_0120 分钟前
从案例分析 Vue3 Tokenizer 源码二
前端·javascript·vue.js
山甫aa34 分钟前
【从零开始的 Web 后端学习】文件上传一篇搞定(MultipartFile + 本地存储 + 阿里云 OSS 保姆级教程)
前端·学习·阿里云
IT毕设实战小研34 分钟前
基于安卓的考研资讯系统设计与实现
android·大数据·vue.js·爬虫·python·考研·课程设计
风骏时光牛马38 分钟前
AIRAGDebug智能检索链路异常定位与调试分析
前端
zzzzzz3101 小时前
36K stars 之外:如何阅读 react-bits 这样的动画交互式 React 项目
javascript·react.js·动效
ljt27249606611 小时前
Flutter笔记--envied
前端·笔记·flutter
Flynt2 小时前
React Router v8 升了之后,我花了两天才把项目跑起来
前端·typescript·preact
泯泷8 小时前
手搓JSVMM第 8 篇:函数调用:参数、返回值与调用帧
前端·javascript·前端框架
Sherotree9 小时前
Google Antigravity——Agent 被提到主界面
前端·ide·后端·edge浏览器