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

0 阅读10分钟

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

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

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

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,准备解析后续属性

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

// 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 特殊逻辑

// 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,回到主循环继续解析。

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

// 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"

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

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

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

// 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 节点。

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