情形五:动态属性(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
步骤一:标签名解析,进入指令解析预备状态
当解析到 <div(div 后面紧跟空格),标签名解析阶段结束。空格属于标签结束分隔符,触发 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,主循环持续读取字符 c,stateInDirArg 不做额外处理,直到读到 <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 快速扫描到闭合引号;随后依次触发 onattribdata、onattribend 回调,解析完成后状态切回 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实体解码 & → &
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
},