一、概念
在 Vue 3 的 <script setup> 编译阶段,defineProps() 支持结构赋值语法:
typescript
const { foo, bar } = defineProps<{ foo: string; bar: number }>()
编译器需要在转换过程中:
- 注册结构后的变量(
foo,bar); - 保留其与原始 prop 名的映射;
- 禁止对这些变量的重新赋值;
- 保证在运行时能正确访问到组件的 props。
这一过程正是由 processPropsDestructure 和 transformDestructuredProps 两个函数配合完成的。
二、原理
这两段逻辑主要位于 @vue/compiler-sfc 内部的 script/compileScript.ts 模块。
它们的职责如下:
processPropsDestructure:分析defineProps()的解构模式,建立绑定元数据;transformDestructuredProps:遍历整个 AST,检测并替换结构出来的变量为对__props的访问。
整体编译思路是「先收集,再替换」:
scss
parse AST
└──> detect defineProps destructuring
└──> register bindings (processPropsDestructure)
└──> later transform identifier usages (transformDestructuredProps)
三、源码分节详解
1. processPropsDestructure
javascript
export function processPropsDestructure(
ctx: ScriptCompileContext,
declId: ObjectPattern,
): void {
if (ctx.options.propsDestructure === 'error') {
ctx.error(`Props destructure is explicitly prohibited via config.`, declId)
} else if (ctx.options.propsDestructure === false) {
return
}
ctx.propsDestructureDecl = declId
🔍 注释解析:
- 如果配置禁止解构,则直接报错或跳过;
- 将解构节点 (
ObjectPattern) 存入上下文,方便后续引用。
接下来定义一个内部注册函数 registerBinding:
vbnet
const registerBinding = (
key: string,
local: string,
defaultValue?: Expression,
) => {
ctx.propsDestructuredBindings[key] = { local, default: defaultValue }
if (local !== key) {
ctx.bindingMetadata[local] = BindingTypes.PROPS_ALIASED
;(ctx.bindingMetadata.__propsAliases ||
(ctx.bindingMetadata.__propsAliases = {}))[local] = key
}
}
💡 功能说明:
key:原始 prop 名;local:解构后的本地变量名;defaultValue:是否提供默认值。
例如:
css
const { foo: localFoo = 123 } = defineProps<{ foo: number }>()
会得到:
css
ctx.propsDestructuredBindings = {
foo: { local: 'localFoo', default: 123 }
}
然后开始遍历结构属性:
rust
for (const prop of declId.properties) {
if (prop.type === 'ObjectProperty') {
const propKey = resolveObjectKey(prop.key, prop.computed)
...
if (prop.value.type === 'AssignmentPattern') {
// 带默认值的结构
registerBinding(propKey, left.name, right)
} else if (prop.value.type === 'Identifier') {
// 普通结构
registerBinding(propKey, prop.value.name)
}
} else {
// 处理剩余参数:const { foo, ...rest } = defineProps()
ctx.propsDestructureRestId = (prop.argument as Identifier).name
}
}
🧠 机制要点:
- 禁止嵌套结构;
- 禁止动态键名;
- 自动登记
rest变量为「响应式常量」。
2. transformDestructuredProps
这个函数负责第二阶段:AST 重写。
其目标是:
- 找出所有使用了解构 prop 的地方;
- 将其替换为
__props.xxx; - 防止直接修改这些变量;
- 检测非法用法(例如直接传入
toRef(foo))。
(1)作用域与注册逻辑
typescript
const rootScope: Scope = Object.create(null)
const scopeStack: Scope[] = [rootScope]
let currentScope: Scope = rootScope
const excludedIds = new WeakSet<Identifier>()
const propsLocalToPublicMap: Record<string, string> = Object.create(null)
💬 每个作用域(Scope)是一个对象:
yaml
{ foo: true } // true 表示这是一个 prop 绑定
{ bar: false } // false 表示普通局部变量
(2)递归扫描与注册变量
ini
function walkScope(node: Program | BlockStatement, isRoot = false) {
for (const stmt of node.body) {
if (stmt.type === 'VariableDeclaration') {
walkVariableDeclaration(stmt, isRoot)
} else if (stmt.type === 'FunctionDeclaration') {
registerLocalBinding(stmt.id)
}
...
}
}
每次进入一个新的函数或块作用域时:
scss
pushScope()
walkScope(node.body)
popScope()
这样可以保证在不同层级区分同名变量,防止误替换。
(3)重写标识符
核心逻辑在 rewriteId:
python
function rewriteId(id: Identifier, parent: Node, parentStack: Node[]) {
if (
(parent.type === 'AssignmentExpression' && id === parent.left) ||
parent.type === 'UpdateExpression'
) {
ctx.error(`Cannot assign to destructured props as they are readonly.`, id)
}
if (isStaticProperty(parent) && parent.shorthand) {
ctx.s.appendLeft(
id.end! + ctx.startOffset!,
`: ${genPropsAccessExp(propsLocalToPublicMap[id.name])}`,
)
} else {
ctx.s.overwrite(
id.start! + ctx.startOffset!,
id.end! + ctx.startOffset!,
genPropsAccessExp(propsLocalToPublicMap[id.name]),
)
}
}
🧩 示例:
arduino
console.log(foo)
会被改写为:
arduino
console.log(__props.foo)
(4)非法用法检测
lua
function checkUsage(node: Node, method: string, alias = method) {
if (isCallOf(node, alias)) {
const arg = unwrapTSNode(node.arguments[0])
if (arg.type === 'Identifier' && currentScope[arg.name]) {
ctx.error(
`"${arg.name}" is a destructured prop and should not be passed directly to ${method}(). Pass a getter () => ${arg.name} instead.`,
arg,
)
}
}
}
🧠 用法限制:
scss
watch(foo) ❌ // 错误
watch(() => foo) ✅
四、对比
| 功能点 | processPropsDestructure |
transformDestructuredProps |
|---|---|---|
| 阶段 | AST 收集阶段 | AST 遍历 & 转换阶段 |
| 输入 | 解构声明 (ObjectPattern) |
整个脚本 AST |
| 产出 | props → local 映射表 | 重写后的代码、错误提示 |
| 作用 | 记录关系 | 替换标识符引用 |
五、实践:编译前后对比
输入:
typescript
const { foo, bar } = defineProps<{ foo: string; bar: number }>()
console.log(foo, bar)
输出(编译后):
ini
const __props = defineProps();
console.log(__props.foo, __props.bar);
六、拓展思考
-
为什么要禁止赋值?
因为 props 是只读的,Vue 内部通过 Proxy 实现响应式引用,重新赋值会破坏单向数据流。
-
为什么需要作用域栈?
为了区分局部变量与外层 prop 名称,例如:
javascriptfunction test(foo) { console.log(foo) }这里的
foo是局部变量,不应被替换为__props.foo。 -
为何需要检测
watch和toRef?它们接收的是响应式引用对象,而非普通值。直接传入解构变量无法追踪依赖变化。
七、潜在问题与改进
- 性能问题 :
walk整个 AST 对大型文件代价较高。 - 可扩展性:目前只支持一层解构,若未来 Vue 支持深层结构则需调整。
- 代码生成位置偏移 :由于
s.overwrite基于字符偏移操作,AST 与源码偏移不匹配可能导致编译错误。
八、总结
processPropsDestructure 与 transformDestructuredProps 是 Vue <script setup> 编译的关键一环。
它们的设计保证了开发者能以最自然的语法使用 props 解构,同时不破坏响应式系统与只读约束。
🧩 一句话总结 :
Vue 编译器通过「静态分析 + 精准重写」,实现了安全、透明且直观的 props 解构支持。
本文部分内容借助 AI 辅助生成,并由作者整理审核。