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

情形五:动态属性(source='<div :class="{ active: isActive }"></div>')

本节以 Vue3 class 动态绑定为案例,深度拆解编译器中 v-bind 指令的完整解析链路,从词法分析(tokenizer)到语法分析(parser),逐行还原标签解析的状态切换、回调触发与 AST 节点生成全过程。

执行基础解析代码,得到最终 AST 结构:

js 复制代码
const ast = baseParse('<div :class="{ active: isActive }"></div>')
ast.children[0].props=[{
    "type": 7,
    "name": "bind",
    "rawName": ":class",
    "exp": {
        "type": 4,
        "loc": {
            "start": {
                "column": 14,
                "line": 1,
                "offset": 13
            },
            "end": {
                "column": 34,
                "line": 1,
                "offset": 33
            },
            "source": "{ active: isActive }"
        },
        "content": "{ active: isActive }",
        "isStatic": false,
        "constType": 0
    },
    "arg": {
        "type": 4,
        "loc": {
            "start": {
                "column": 7,
                "line": 1,
                "offset": 6
            },
            "end": {
                "column": 12,
                "line": 1,
                "offset": 11
            },
            "source": "class"
        },
        "content": "class",
        "isStatic": true,
        "constType": 3
    },
    "modifiers": [],
    "loc": {
        "start": {
            "column": 6,
            "line": 1,
            "offset": 5
        },
        "end": {
            "column": 35,
            "line": 1,
            "offset": 34
        },
        "source": ":class=\"{ active: isActive }\""
    }
}]

在整条指令解析流程中,Tokenizer 会依次切换 6 种解析状态,完成标签名、属性名、属性值分段读取。状态流转顺序:

graph LR InTagName --> BeforeAttrName --> InDirArg --> AfterAttrName --> BeforeAttrValue --> InAttrValueDq/Sq/Nq --> BeforeAttrName

步骤一:标签名解析,进入指令解析预备状态

当解析到 <divdiv 后面紧跟空格),标签名解析阶段结束。空格属于标签结束分隔符,触发 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) // 执行预备状态对应的解析逻辑
}

本次案例中,handleTagName 执行 this.stateBeforeAttrName 时当前字符为空格,不匹配任何属性解析条件,直接回到 tokenizer.parse 主循环,继续读取下一个字符 :v-bind 的缩写)

步骤二:属性名解析,进入指令参数读取状态

源码片段 <div :,主循环读取到非空白字符,启动属性解析初始化。字符 : 属于 Vue 指令简写标识(v-/./:/@/#),判定为指令,调用 ondirname 回调,解析状态切换为 InDirArg

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
    }
}

ondirname 回调用来提取指令名、区分是普通属性还是指令节点,同时处理 v-pre 特殊逻辑

ts 复制代码
// parser.ts 201~246行
ondirname(start, end) {
  // 截取 [start, end] 区间原始字符串,拿到指令原始名称文本
  const raw = getSlice(start, end)

  // 对简写语法做映射:. / : / @ / # 映射成对应的指令内部name
  const name =
    raw === '.' || raw === ':'
      ? 'bind'          // .prop 、 :xxx  → v-bind
      : raw === '@'
        ? 'on'           // @xxx → v-on
        : raw === '#'
          ? 'slot'       // #xxx → v-slot
          : raw.slice(2) // v-xxx 这种完整写法,去掉前两位 "v-",取后面作为指令名

  // 非v-pre环境,指令名称为空,抛出【缺少指令名】编译错误
  if (!inVPre && name === '') {
    emitError(ErrorCodes.X_MISSING_DIRECTIVE_NAME, start)
  }

  // 如果当前处于v-pre 或者解析出来的指令名为空:
  // 不解析为指令,降级成【普通HTML属性节点 ATTRIBUTE】
  if (inVPre || name === '') {
    currentProp = {
      type: NodeTypes.ATTRIBUTE, // 属性节点类型
      name: raw,                 // 属性原始名称
      nameLoc: getLoc(start, end),// 属性name源码位置
      value: undefined,
      loc: getLoc(start),        // 整个属性源码位置
    }
  } else {
    // 正常指令,创建 DIRECTIVE 指令AST节点
    currentProp = {
      type: NodeTypes.DIRECTIVE, // 指令节点类型
      name,                      // 解析后的指令名(bind/on/slot/xxx)
      rawName: raw,              // 原始前缀字符串(@/#/:/v-xxx)
      exp: undefined,            // 指令表达式(后续解析赋值)
      arg: undefined,            // 指令参数,如 v-on:[arg] 的arg
      modifiers: raw === '.' ? [createSimpleExpression('prop')] : [], // .prop 自带prop修饰符
      loc: getLoc(start),
    }

    // 特殊处理 v-pre 指令
    if (name === 'pre') {
      inVPre = tokenizer.inVPre = true // 打开v-pre标记:v-pre内所有内容不再编译指令
      currentVPreBoundary = currentOpenTag // 记录v-pre所在的开始标签作为边界

      // 【重点】v-pre 会把当前标签**前面已经解析出来的所有指令全部转成普通属性**
      // v-pre 的含义:该标签本身及其内部,不编译任何vue指令
      const props = currentOpenTag!.props
      for (let i = 0; i < props.length; i++) {
        if (props[i].type === NodeTypes.DIRECTIVE) {
          props[i] = dirToAttr(props[i] as DirectiveNode)
        }
      }
    }
  }
},

步骤三:读取指令参数,切换至属性名解析结束状态

源码片段 <div :c,主循环持续读取字符 cstateInDirArg 不做额外处理,直到读到 <div :class= 中的等号 =。 触发 ondirarg 回调,调用 handleAttrNameEnd,将状态切换为 State.AfterAttrName;随后执行 onattribnameend 回调,进入 stateAfterAttrName,状态更新为 State.BeforeAttrValue,回到主循环继续解析。

ts 复制代码
// tokenizer.ts 738~749行
private stateInDirArg(c: number): void {
  // 情况1:遇到 = 号 或者标签属性段结束(空格、>、/等结束符)
  if (c === CharCodes.Eq || isEndOfTagSection(c)) {
    // 触发回调 ondirarg,把 [sectionStart, index] 区间文本作为指令参数
    this.cbs.ondirarg(this.sectionStart, this.index)
    // 处理属性名称解析结束,切换到属性值解析状态
    this.handleAttrNameEnd(c)
  } else if (c === CharCodes.LeftSquare) {
    // 情况2:字符是 [ 左方括号 → 动态参数 v-on:[xxx]
    // 切换状态到 InDirDynamicArg,专门解析动态参数内部内容
    this.state = State.InDirDynamicArg
  } else if (c === CharCodes.Dot) {
    // 情况3:字符是 . 点号,代表指令修饰符开始(如 @click.stop)
    // 先把当前已经读到的参数文本通过ondirarg回调提交
    this.cbs.ondirarg(this.sectionStart, this.index)
    // 切换状态为 InDirModifier,进入修饰符解析阶段
    this.state = State.InDirModifier
    // 更新sectionStart,把起点移动到 . 的下一个字符,准备读取修饰符名称
    this.sectionStart = this.index + 1
  }
}

// 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
    } 
    // 其他代码省略...
}

ondirarg:截取指令参数字符串,处理 v-pre 降级逻辑,构建指令节点上的 arg 属性,区分静态参数与方括号动态参数。本例中 arg 为静态参数 class

ts 复制代码
// parser.ts 248~263行
ondirarg(start, end) {
  // start===end 代表参数为空,直接退出,不做处理
  if (start === end) return

  // 截取源码区间 [start, end],拿到原始参数字符串
  const arg = getSlice(start, end)

  // 分支1:处于v-pre作用域,并且当前属性不是v-pre本身
  // v-pre下所有指令全部降级为普通HTML属性,不再解析指令arg
  if (inVPre && !isVPre(currentProp!)) {
    // 把arg文本直接拼接进普通属性的name(当成普通字符串拼接)
    ;(currentProp as AttributeNode).name += arg
    // 更新属性nameLoc源码位置的结束下标,同步到end
    setLocEnd((currentProp as AttributeNode).nameLoc, end)
  } else {
    // 分支2:正常编译环境,构建指令参数表达式节点
    // 判断是否静态参数:首字符不是 [ 就是静态;以 [ 开头代表动态参数 [xxx]
    const isStatic = arg[0] !== `[`

    // 给指令节点的 arg 属性,创建表达式AST节点
    ;(currentProp as DirectiveNode).arg = createExp(
      // 静态:直接用arg;动态参数:切掉前后 [],取出括号内部内容
      isStatic ? arg : arg.slice(1, -1),
      isStatic,                    // 标记是否为静态表达式
      getLoc(start, end),         // arg对应的源码位置信息
      // 静态参数标记 CAN_STRINGIFY(可常量字符串化)
      // 动态参数标记 NOT_CONSTANT(运行时求值,不是常量)
      isStatic ? ConstantTypes.CAN_STRINGIFY : ConstantTypes.NOT_CONSTANT,
    )
  }
},

onattribnameend:属性名称读取完毕后触发,普通 HTML 属性与指令属性都会走到该回调。核心逻辑两点:给指令节点补全 rawName;检测当前标签重复属性,重复则抛出编译错误。本例执行后,currentProp.rawName = ":class"

ts 复制代码
// parser.ts 296~310行
onattribnameend(end) {
  // 取出当前属性节点loc的起始偏移,即属性名称开始位置
  const start = currentProp!.loc.start.offset
  // 截取 [start, end] 源码片段,拿到完整属性名称原始字符串
  const name = getSlice(start, end)

  // 如果当前节点是指令节点 DIRECTIVE
  if (currentProp!.type === NodeTypes.DIRECTIVE) {
    // 赋值 rawName:指令的原始完整名称(源码原样)
    currentProp!.rawName = name
  }

  // 【重复属性检测】遍历当前标签已经收集的所有props
  if (
    currentOpenTag!.props.some(
      // 判断规则:指令节点拿 rawName,普通属性节点拿 name,对比是否等于本次name
      p => (p.type === NodeTypes.DIRECTIVE ? p.rawName : p.name) === name,
    )
  ) {
    // 找到重复属性,抛出【重复属性】编译错误,定位到属性起始位置
    emitError(ErrorCodes.DUPLICATE_ATTRIBUTE, start)
  }
},

步骤四:解析属性表达式,构建指令 exp 表达式节点

源码片段 <div :class=",读取到双引号 ",执行 stateBeforeAttrValue,状态切换为 State.InAttrValueDq,进入 tokenizer 主循环。 继续读到 {,进入 stateInAttrValueDoubleQuotes,调用 handleInAttrValue。当字符不等于引号(本例为"),调用 fastForwardTo 快速扫描到闭合引号;随后依次触发 onattribdataonattribend 回调,解析完成后状态切回 State.BeforeAttrName

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) 
    }
}
// tokenizer.ts 818~820行
private stateInAttrValueDoubleQuotes(c: number): void {
    this.handleInAttrValue(c, CharCodes.DoubleQuote)
}
// tokenizer.ts 805~817行
private handleInAttrValue(c: number, quote: number) {
    if (c === quote || (__BROWSER__ && this.fastForwardTo(quote))) {
      this.cbs.onattribdata(this.sectionStart, this.index)
      this.sectionStart = -1
      this.cbs.onattribend(
        quote === CharCodes.DoubleQuote ? QuoteType.Double : QuoteType.Single,
        this.index + 1,
      )
      this.state = State.BeforeAttrName
    } else if (!__BROWSER__ && c === CharCodes.Amp) {
      this.startEntity()
    }
}

fastForwardTo:向前快速扫描字符流(本例目标字符为 "),更新索引位置到目标字符(本例中目标字符位置this.index =33),途中记录换行符;找到目标字符返回 true,缓冲区遍历结束未找到则返回 false

ts 复制代码
private fastForwardTo(c: number): boolean {
    while (++this.index < this.buffer.length) {
      const cc = this.buffer.charCodeAt(this.index)
      if (cc === CharCodes.NewLine) {
        this.newlines.push(this.index)
      }
      if (cc === c) {
        return true
      }
}

onattribdata:读取属性值文本片段的回调。属性值可能分段多次触发,拼接文本片段。本例收集到 currentAttrValue="{ active: isActive }"

ts 复制代码
// parser.ts 284~288行
onattribdata(start, end) {
    // 拼接本次截取到的属性值文本片段,属性值可能分段多次触发此回调
    currentAttrValue += getSlice(start, end)
    // 如果属性值起始标记还未记录,赋值属性值的起始索引(只赋值一次)
    if (currentAttrStartIndex < 0) currentAttrStartIndex = start
    // 更新属性值结束索引,每次都覆盖,保存最新片段的end
    currentAttrEndIndex = end
},

onattribend:单条属性解析链路的收尾回调。Tokenizer 解析完完整属性(属性名 + 属性值 + 引号)后触发。区分普通 HTML 属性 ATTRIBUTE 和 Vue 指令 DIRECTIVE,分别构建对应的 AST 节点。

ts 复制代码
// parser.ts 312~410行(保留核心代码,省略本案例无关分支)
onattribend(quote, end) {
  if (currentOpenTag && currentProp) {
    // finalize end pos
    // 更新当前属性节点loc的结束位置,补全源码区间,用于sourcemap、错误定位
    setLocEnd(currentProp.loc, end)

    // quote !== QuoteType.NoValue:说明这个属性有=和属性值,不是布尔属性
    if (quote !== QuoteType.NoValue) {
      // 浏览器编译模式:属性值包含&符号,执行HTML实体解码 &amp; → &
      if (__BROWSER__ && currentAttrValue.includes('&')) {
        currentAttrValue = currentOptions.decodeEntities!(
          currentAttrValue,
          true,
        )
      }

      // 分支一:普通HTML属性 NodeTypes.ATTRIBUTE
      if (currentProp.type === NodeTypes.ATTRIBUTE) {
        // 代码省略...
      } else {
        // 分支二:当前是指令节点 NodeTypes.DIRECTIVE,处理指令表达式 exp
        let expParseMode = ExpParseMode.Normal // 默认表达式解析模式:普通表达式
        // 仅在非浏览器打包版本(node端编译器)进行解析模式分支判断
        if (!__BROWSER__) {
          if (currentProp.name === 'for') {
            // v-for:Skip模式,不使用标准表达式解析器,后面单独用parseForExpression处理
            expParseMode = ExpParseMode.Skip
          } else if (currentProp.name === 'slot') {
            // v-slot:Params模式,支持函数参数语法(插槽参数)
            expParseMode = ExpParseMode.Params
          } else if (
            currentProp.name === 'on' &&
            currentAttrValue.includes(';')
          ) {
            // v-on事件,表达式含分号,代表多条语句,切换到Statements多语句模式
            expParseMode = ExpParseMode.Statements
          }
        }
        // 创建指令exp表达式节点(SimpleExpressionNode)
        currentProp.exp = createExp(
          currentAttrValue,
          false,                                  // isStatic=false,指令表达式默认运行时求值
          getLoc(currentAttrStartIndex, currentAttrEndIndex), // exp源码位置
          ConstantTypes.NOT_CONSTANT,             // 标记非常量,不可静态提升
          expParseMode,                           // 上面判断得到的表达式解析模式
        )
        // v-for 单独解析循环表达式,生成forParseResult存放v-for解析结果
        if (currentProp.name === 'for') {
          currentProp.forParseResult = parseForExpression(currentProp.exp)
        }
        // 2.x兼容逻辑:v-bind:xxx.sync 语法自动转换成 v-model:xxx 
        // 代码省略...
      }
    }
    // 入栈到当前开始标签props数组,**v-pre指令本身不push**
    // v-pre只作为标记开关,不需要保留在属性列表参与后续编译
	// 代码省略...
  }
  // 清空属性解析临时变量,重置索引,准备解析下一个属性
  currentAttrValue = ''
  currentAttrStartIndex = currentAttrEndIndex = -1
},
相关推荐
mldong8 小时前
你的 Vue3 项目也能有钉钉同款审批流设计器:npm 装包,10 分钟画出第一条审批流
前端·vue.js
2分钟速写快排9 小时前
什么是 RAG?如何用 RAG 实现一个用户记忆?
前端·后端·ai编程
passerby606110 小时前
如何自己造一个时间处理库
前端·javascript·github
走到天涯海角11 小时前
react里面的长列表渲染优化
前端·react.js·前端框架
小羊没烦恼!11 小时前
Hello Web API系列教程——Web API与国际化
java·服务器·前端·javascript·php
北岛贰11 小时前
迷茫焦虑期,我做了一个带支付带官网的 AI 聊天虚拟恋人 App
前端·人工智能·后端
mayaairi12 小时前
Vue2 组件通讯(三):全局事件总线、PubSub、插槽与组件实例属性
前端·javascript·vue.js
kyriewen13 小时前
面试官问我:AI 都能写代码了,前端凭什么还值 25K
前端·javascript·人工智能
风骏时光牛马14 小时前
AI源码分析:拆解模型底层实现逻辑
前端