Vue script setup 中 defineProps 的编译边界与错误排查

在 Vue 3 的 <script setup> 里,defineProps 看起来像一个函数,但它不是普通运行时函数。它更准确的身份是 编译器宏:源码里写出来,构建时被 @vue/compiler-sfc 识别并替换,最终运行在浏览器里的代码里不应该再出现它。

这个差别平时不太容易被注意到,因为常见写法都很自然:

1
const props = defineProps<{ foo: string }>();

问题通常出现在想顺手再包一层的时候。比如需要把 props 里的字段转成 Ref,于是写成:

1
const { foo } = toRefs(defineProps<{ foo: string }>());

这段代码从 JavaScript 直觉看很顺:defineProps() 返回 props 对象,toRefs() 再把对象转成 refs。可是在 <script setup> 里,它会绕过 Vue 编译器对 defineProps 的识别,导致宏没有被编译掉,最后在浏览器里变成运行时错误。

<script setup> 实际上会先被编译

.vue 文件里的 <script setup> 不是浏览器最终执行的脚本块。它是 Vue SFC 编译器提供的一层语法,构建时会被 @vue/compiler-sfc 转成普通组件代码,常见产物就是一个 defineComponent({ ... })

也就是说,开发时看到的是:

1
2
3
<script setup lang="ts">
const props = defineProps<{ foo: string }>();
</script>

浏览器运行时真正接近的是:

1
2
3
4
5
6
7
8
9
10
11
12
import { defineComponent as _defineComponent } from 'vue';

export default /*#__PURE__*/_defineComponent({
props: {
foo: { type: String, required: true }
},
setup(__props) {
const props = __props;

return { props };
}
});

这只是一个简化例子。实际编译过程中,<script setup> 还会处理更多事情:顶层变量会暴露给模板使用,defineProps 会生成组件 props 选项,defineEmits 会生成 emits 选项,defineExpose 会改写成 setup 上下文里的 expose 调用,defineOptions 会合并到组件选项,defineModel 会生成对应 prop、emit 和模型 ref,顶层 await 也会让 setup 变成 async setup。

这些能力成立的前提是:编译器能在源码 AST 里识别出对应宏调用。definePropsdefineEmitsdefineExposedefineOptionsdefineSlotsdefineModelwithDefaults 都属于这一类。除了 Vue 明确支持的组合形态,不要把这些宏随便包进普通运行时函数里,例如 readonly(defineProps(...))custom(defineEmits(...))someFn(defineModel(...)) 这类写法都不应该作为可用模式。

useSlots()useAttrs() 不一样。它们是 Vue 明确提供的运行时函数,返回 setup context 里的 slots 和 attrs;这类函数按普通 Composition API 使用即可,不属于本文讨论的编译器宏残留问题。

  • Vue 官方文档:<script setup> 说明了 <script setup> 的编译语义、宏能力、顶层绑定、withDefaultsdefineModeldefineExposeuseSlots / useAttrs 的区别。

现象

在一个 Vue 3.4 项目里,页面原本需要从 props 里拿到 svipLevel,再用于埋点通参:

1
2
3
4
5
6
7
// sugo-web-app/src/pages/vip-center/views/Svip/components/BirthdayPrivilegeEntry/index.vue:20-25
const props = defineProps<{ svipLevel: number }>();
const { svipLevel } = toRefs(props);
const { t } = useI18n();
const { isCollapsed } = useCollapse();
const eventReportParams = computed(() => ({ source: 'svip_center', svip_level: svipLevel.value }));
const { handleClick } = useOpenPopupPage({ eventReportParams });

正确写法是先把 defineProps 单独放出来,再交给 toRefs

出问题的写法是把它直接包进 toRefs

1
const { svipLevel } = toRefs(defineProps<{ svipLevel: number }>());

页面表现可能不是一个非常直观的 defineProps 报错,而是在切换 Tab 或组件更新时白屏,并且控制台后面出现类似错误:

1
2
3
4
5
TypeError: Cannot read properties of null (reading 'emitsOptions')
at shouldUpdateComponent
at updateComponent
at processComponent
at patch

这个错误看起来像组件 emits 配置有问题,但它通常不是根因。真正要看的,是控制台更早的位置有没有 defineProps is not defined,或者有没有 Vue 提示 setup 执行失败。后面的 emitsOptions 更像是组件没有正常建立之后,Vue patch 流程继续往下走时产生的二次错误。

项目里的 Vue 版本

这个现场来自一个 Vue 3.4 项目。package.json 里锁定的是:

1
2
3
4
5
// sugo-web-app/package.json:180-183
{
"vite": "~4.5.5",
"vue": "3.4.24"
}

本地实际安装的 vue@vue/compiler-sfc 也都是 3.4.24。这点很重要,因为 Vue 3.5 之后对 props 解构做过增强,某些写法在新版本里语义已经不同,但这不代表宏可以被任意函数包裹。

defineProps 到底会被编译成什么

可以用 @vue/compiler-sfc 编译一个最小例子看结果。

源码:

1
2
3
4
import { toRefs } from 'vue';

const props = defineProps<{ foo: string }>();
const { foo } = toRefs(props);

编译后关键部分是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { defineComponent as _defineComponent } from 'vue'
import { toRefs } from 'vue';

export default /*#__PURE__*/_defineComponent({
__name: 'stable',
props: {
foo: { type: String, required: true }
},
setup(__props: any, { expose: __expose }) {
__expose();

const props = __props;
const { foo } = toRefs(props);

return { props, foo, toRefs };
}
});

这里有两个变化:

  • defineProps<{ foo: string }>() 被拿来生成了组件的 props 选项。
  • setup 里真正能访问到的是 Vue 传进来的 __props,原来的 defineProps 调用已经不存在了。

这才是正常结果。defineProps 只负责在编译期告诉 Vue「这个组件有哪些 props」,不应该进入浏览器运行时。

包进 toRefs 后发生了什么

再看有问题的写法:

1
2
3
import { toRefs } from 'vue';

const { foo } = toRefs(defineProps<{ foo: string }>());

编译后的关键部分会变成:

1
2
3
4
5
6
7
8
9
10
11
12
13
import { defineComponent as _defineComponent } from 'vue'
import { toRefs } from 'vue';

export default /*#__PURE__*/_defineComponent({
__name: 'nestedToRefs',
setup(__props, { expose: __expose }) {
__expose();

const { foo } = toRefs(defineProps<{ foo: string }>());

return { foo, toRefs };
}
});

最关键的问题就在这一行:

1
const { foo } = toRefs(defineProps<{ foo: string }>());

defineProps 没有被替换成 __props,也没有被拿去生成组件的 props 选项。它作为一个调用表达式残留在 setup 里。

后续 TypeScript 转 JavaScript 时,类型参数会被去掉,浏览器真正执行时大致会变成:

1
const { foo } = toRefs(defineProps());

运行时没有 defineProps 这个函数,所以就会报:

1
ReferenceError: defineProps is not defined

如果这个错误被控制台大量日志淹没,或者后续 Vue 更新流程继续抛出内部错误,最后看到的就可能是更迷惑的 emitsOptions 报错。

编译器为什么没有识别里面那层 defineProps

原因在 @vue/compiler-sfc 的识别方式。

@vue/compiler-sfc 处理 .vue 文件时,会先解析出 SFC 结构,再把 <script setup> 里的代码解析成 JavaScript / TypeScript 的 AST。后面贴到的 node.typedecl.initcalleearguments 都是 AST 节点上的信息,不是源码字符串本身。

宏识别也发生在这层 AST 上。Vue 编译 <script setup> 时,会遍历顶层语句;遇到变量声明时,它看的是变量初始化表达式,也就是 decl.init

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// sugo-web-app/node_modules/@vue/compiler-sfc/dist/compiler-sfc.cjs.js:20224-20239
if (node.type === "VariableDeclaration" && !node.declare) {
const total = node.declarations.length;
let left = total;
let lastNonRemoved;
for (let i = 0; i < total; i++) {
const decl = node.declarations[i];
const init = decl.init && CompilerDOM.unwrapTSNode(decl.init);
if (init) {
if (processDefineOptions(ctx, init)) {
ctx.error(
`${DEFINE_OPTIONS}() has no returning value, it cannot be assigned.`,
node
);
}
const isDefineProps = processDefineProps(ctx, init, decl.id);
const isDefineEmits = !isDefineProps && processDefineEmits(ctx, init, decl.id);

processDefineProps 继续判断这个表达式是不是 defineProps(...)

1
2
3
4
5
6
7
// sugo-web-app/node_modules/@vue/compiler-sfc/dist/compiler-sfc.cjs.js:19345-19350
const DEFINE_PROPS = "defineProps";
const WITH_DEFAULTS = "withDefaults";
function processDefineProps(ctx, node, declId) {
if (!isCallOf(node, DEFINE_PROPS)) {
return processWithDefaults(ctx, node, declId);
}

isCallOf 的判断很直接:当前节点必须是一个调用表达式,并且 callee 必须是目标名字。

1
2
3
4
// sugo-web-app/node_modules/@vue/compiler-sfc/dist/compiler-sfc.cjs.js:58-60
function isCallOf(node, test) {
return !!(node && test && node.type === "CallExpression" && node.callee.type === "Identifier" && (typeof test === "string" ? node.callee.name === test : test(node.callee.name)));
}

所以这几种 AST 形态完全不一样:

1
const props = defineProps<Props>();

变量初始化表达式是:

1
2
CallExpression
callee: Identifier "defineProps"

编译器能识别。

1
const { foo } = toRefs(defineProps<Props>());

变量初始化表达式是:

1
2
3
4
5
CallExpression
callee: Identifier "toRefs"
arguments:
CallExpression
callee: Identifier "defineProps"

编译器第一眼看到的是 toRefs(...),不是 defineProps(...)。它不会把任意函数参数继续当成宏入口递归处理,所以这段不会被当成 props 声明。

这不是 Vue 不会分析函数调用,而是宏必须保持明确、可静态识别。否则编译器需要理解任意运行时函数的语义,比如 toRefsreadonly、自定义 helper、第三方函数,宏和普通函数的边界会变得非常不稳定。

为什么 withDefaults(defineProps(...)) 可以

withDefaults 也是包了一层,但它能工作,因为 Vue 编译器对它写了专门分支。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// sugo-web-app/node_modules/@vue/compiler-sfc/dist/compiler-sfc.cjs.js:19379-19408
function processWithDefaults(ctx, node, declId) {
if (!isCallOf(node, WITH_DEFAULTS)) {
return false;
}
if (!processDefineProps(ctx, node.arguments[0], declId)) {
ctx.error(
`${WITH_DEFAULTS}' first argument must be a ${DEFINE_PROPS} call.`,
node.arguments[0] || node
);
}
if (ctx.propsRuntimeDecl) {
ctx.error(
`${WITH_DEFAULTS} can only be used with type-based ${DEFINE_PROPS} declaration.`,
node
);
}
if (ctx.propsDestructureDecl) {
ctx.error(
`${WITH_DEFAULTS}() is unnecessary when using destructure with ${DEFINE_PROPS}().
Prefer using destructure default values, e.g. const { foo = 1 } = defineProps(...).`,
node.callee
);
}
ctx.propsRuntimeDefaults = node.arguments[1];
if (!ctx.propsRuntimeDefaults) {
ctx.error(`The 2nd argument of ${WITH_DEFAULTS} is required.`, node);
}
ctx.propsCall = node;
return true;
}

这段逻辑说明了两件事:

  • 外层如果是 withDefaults(...),编译器会进入专门的 processWithDefaults
  • withDefaults 的第一个参数必须继续是 defineProps(...),否则直接报错。

所以 withDefaults(defineProps(...), defaults) 不是「宏可以被任意包裹」的证据,而是 withDefaults 本身也是 Vue 认识的宏形态。

对应源码:

1
2
3
const props = withDefaults(defineProps<{ foo?: string }>(), {
foo: 'x'
});

编译后会生成 runtime props default:

1
2
3
4
5
6
7
8
9
10
11
12
13
export default /*#__PURE__*/_defineComponent({
__name: 'withDefaults',
props: {
foo: { type: String, required: false, default: 'x' }
},
setup(__props: any, { expose: __expose }) {
__expose();

const props = __props;

return { props };
}
});

常见可用写法

日常写 Vue 3.4 项目时,可以把 defineProps 当成一组固定语法形态来记。

只声明 props,模板里直接用

1
2
3
defineProps<{
foo: string;
}>();

这种适合 <script setup> 不需要在 TS 逻辑里读 props,只在模板里使用的情况。

在逻辑里使用 props 对象

1
2
3
4
5
const props = defineProps<{
foo: string;
}>();

const title = computed(() => props.foo);

这是最稳定、最容易理解的写法。props.foo 在 computed、watch getter、普通函数里都很清楚。

需要 Ref 时先拿 props,再 toRefs

1
2
3
4
5
6
7
const props = defineProps<{
foo: string;
}>();

const { foo } = toRefs(props);

const title = computed(() => foo.value);

这适合后续逻辑明确需要 .value,或者要把某个 prop 传给另一个 composable,并且对方需要 Ref<T> 的情况。

类型声明 props 需要默认值时用 withDefaults

1
2
3
4
5
6
7
8
9
10
const props = withDefaults(
defineProps<{
foo?: string;
labels?: string[];
}>(),
{
foo: 'hello',
labels: () => []
}
);

在 Vue 3.4 及更早版本里,类型声明的 props 如果要写默认值,withDefaults 是官方推荐入口。

常见危险写法

这些写法都不要用:

1
2
3
4
5
6
7
const { foo } = toRefs(defineProps<Props>());

const foo = toRef(defineProps<Props>(), 'foo');

const props = readonly(defineProps<Props>());

const props = customNormalizeProps(defineProps<Props>());

它们的共同问题是:变量初始化表达式的最外层已经不是 defineProps(...) 或 Vue 特别识别的 withDefaults(...),编译器就不会把它当成宏入口处理。

如果确实需要这些运行时处理,先把宏单独写出来:

1
2
3
4
5
const props = defineProps<Props>();

const { foo } = toRefs(props);
const readonlyProps = readonly(props);
const normalizedProps = customNormalizeProps(props);

Vue 3.5 之后有什么变化

Vue 3.5 开始,defineProps 的解构变量会保持响应性。官方文档里的例子是:

1
2
3
4
5
const { foo } = defineProps(['foo']);

watchEffect(() => {
console.log(foo);
});

在 3.5 及以上版本里,编译器会把同一个 <script setup> 里访问 foo 的地方自动改成访问 props:

1
2
3
4
5
const props = defineProps(['foo']);

watchEffect(() => {
console.log(props.foo);
});

这解决的是「直接从 defineProps 解构是否响应式」的问题。

从编译流程看,这个增强发生在宏入口已经被识别之后。编译器仍然需要先通过前面说的 processDefineProps(ctx, init, decl.id) 识别出 defineProps(...),并且看到左侧 decl.id 是对象解构,后续才会记录解构出来的 prop 绑定,再把同一 <script setup> 里访问这些绑定的位置改写为 props.xxx__props.xxx

所以 Vue 3.5 的增强依赖前面的宏识别结果,但不改变宏入口的识别规则。toRefs(defineProps(...)) 的外层初始化表达式仍然是 toRefs(...),第一步就不会被当成 defineProps 宏处理,自然也不会进入后续的响应式 props 解构转换。

当前项目安装的是 @vue/compiler-sfc@3.4.24,这里的 props destructure 还是实验能力;Vue 3.5 把它稳定并默认启用。用这份源码看流程会更直观:先由 processPropsDestructure 记录「公开 prop 名」和「本地变量名」的关系。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// sugo-web-app/node_modules/@vue/compiler-sfc/dist/compiler-sfc.cjs.js:19585-19600
function processPropsDestructure(ctx, declId) {
if (!ctx.options.propsDestructure) {
return;
}
warnOnce(
`This project is using reactive props destructure, which is an experimental feature. It may receive breaking changes or be removed in the future, so use at your own risk.
To stay updated, follow the RFC at https://github.com/vuejs/rfcs/discussions/502.`
);
ctx.propsDestructureDecl = declId;
const registerBinding = (key, local, defaultValue) => {
ctx.propsDestructuredBindings[key] = { local, default: defaultValue };
if (local !== key) {
ctx.bindingMetadata[local] = "props-aliased";
(ctx.bindingMetadata.__propsAliases || (ctx.bindingMetadata.__propsAliases = {}))[local] = key;
}
};

后面如果确实记录到了 propsDestructureDecl,才会进入 transformDestructuredProps。这一步会把本地变量名反查回真实 prop 名,再把源码里访问本地变量的位置改写成访问 __props.xxx

1
2
3
4
// sugo-web-app/node_modules/@vue/compiler-sfc/dist/compiler-sfc.cjs.js:20323-20326
if (ctx.propsDestructureDecl) {
transformDestructuredProps(ctx, vueImportAliases);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// sugo-web-app/node_modules/@vue/compiler-sfc/dist/compiler-sfc.cjs.js:19633-19647
function transformDestructuredProps(ctx, vueImportAliases) {
if (!ctx.options.propsDestructure) {
return;
}
const rootScope = {};
const scopeStack = [rootScope];
let currentScope = rootScope;
const excludedIds = /* @__PURE__ */ new WeakSet();
const parentStack = [];
const propsLocalToPublicMap = /* @__PURE__ */ Object.create(null);
for (const key in ctx.propsDestructuredBindings) {
const { local } = ctx.propsDestructuredBindings[key];
rootScope[local] = true;
propsLocalToPublicMap[local] = key;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// sugo-web-app/node_modules/@vue/compiler-sfc/dist/compiler-sfc.cjs.js:19698-19716
function rewriteId(id, parent, parentStack2) {
if (parent.type === "AssignmentExpression" && id === parent.left || parent.type === "UpdateExpression") {
ctx.error(`Cannot assign to destructured props as they are readonly.`, id);
}
if (CompilerDOM.isStaticProperty(parent) && parent.shorthand) {
if (!parent.inPattern || CompilerDOM.isInDestructureAssignment(parent, parentStack2)) {
ctx.s.appendLeft(
id.end + ctx.startOffset,
`: ${shared.genPropsAccessExp(propsLocalToPublicMap[id.name])}`
);
}
} else {
ctx.s.overwrite(
id.start + ctx.startOffset,
id.end + ctx.startOffset,
shared.genPropsAccessExp(propsLocalToPublicMap[id.name])
);
}
}

把前面的样例代码打开 props destructure 后编译,结果也能对上这个流程。

源码:

1
2
3
4
5
6
7
import { watchEffect } from 'vue';

const { foo } = defineProps<{ foo: string }>();

watchEffect(() => {
console.log(foo);
});

编译后关键部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { defineComponent as _defineComponent } from 'vue'
import { watchEffect } from 'vue';

export default /*#__PURE__*/_defineComponent({
__name: 'Demo',
props: {
foo: { type: String, required: true }
},
setup(__props: any, { expose: __expose }) {
__expose();

watchEffect(() => {
console.log(__props.foo);
});

return { watchEffect };
}
});

这里可以看到,defineProps 先生成了组件 props 选项,原来的解构声明没有保留,watchEffect 里访问 foo 的地方被改成了 __props.foo。这就是 Vue 3.5 说的「解构出来的 prop 仍然保持响应性」背后的核心改写。

它不等于下面这种写法也成立:

1
const { foo } = toRefs(defineProps<Props>());

toRefs(defineProps(...)) 的问题是宏识别入口被包住了。Vue 3.5 的响应式 props 解构增强,主要针对的是这种形态:

1
const { foo = 'hello' } = defineProps<Props>();

也就是 defineProps 本身仍然处在编译器能识别的位置。

如果代码需要的是 Ref,比如后面必须写 foo.value,或者要把它传给一个接收 Ref<T> 的 composable,那么即使在 Vue 3.5,也仍然可以使用更明确的两步写法:

1
2
const props = defineProps<Props>();
const { foo } = toRefs(props);

为什么最后看到的是 emitsOptions

Cannot read properties of null (reading 'emitsOptions') 这类错误容易误导排查方向。

Vue runtime 里 shouldUpdateComponent 会从旧 vnode 上拿组件实例,再读它的 emitsOptions

1
2
3
4
5
// sugo-web-app/node_modules/@vue/runtime-core/dist/runtime-core.esm-bundler.js:1060-1064
function shouldUpdateComponent(prevVNode, nextVNode, optimized) {
const { props: prevProps, children: prevChildren, component } = prevVNode;
const { props: nextProps, children: nextChildren, patchFlag } = nextVNode;
const emits = component.emitsOptions;

正常情况下,组件 vnode 上应该有 component 实例。可是如果 setup 阶段已经因为 defineProps is not defined 失败,组件没有按预期完成初始化,后续更新流程再读 component.emitsOptions,就可能报出这个看起来完全不相关的错误。

排查这类白屏时,应该优先找 第一条错误,不要只看最后一条堆栈。最后一条堆栈常常只是 Vue 内部更新流程被前面的问题带歪后的结果。

怎么快速确认是不是宏没有被编译掉

可以用一个很小的脚本在本地直接看编译结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const { parse, compileScript } = require('@vue/compiler-sfc');

const source = `
<script setup lang="ts">
import { toRefs } from 'vue';

const { foo } = toRefs(defineProps<{ foo: string }>());
</script>
`;

const descriptor = parse(source, { filename: 'Demo.vue' }).descriptor;
const result = compileScript(descriptor, { id: 'demo' });

console.log(result.content);

如果输出里还能搜到 defineProps(,就说明宏没有被正确识别。正常的 <script setup> 编译产物里,defineProps 应该已经被组件 props 选项和 setup 参数替换掉。

排查真实项目时,也可以按这个顺序看:

  • 先看当前项目的 vue@vue/compiler-sfc 版本。
  • 再看控制台最早的错误,不只看最后的 Vue patch 堆栈。
  • 对可疑 SFC 做最小编译,看输出里是否残留 defineProps
  • 搜索代码里是否存在 toRefs(definePropstoRef(definePropsreadonly(defineProps、自定义函数包裹 defineProps 这类写法。
  • 修成 const props = defineProps<Props>(); const { foo } = toRefs(props); 后重新验证页面。

推荐写法

在 Vue 3.4 项目里,最稳妥的团队规则可以写成:

1
const props = defineProps<Props>();

如果需要默认值:

1
const props = withDefaults(defineProps<Props>(), defaults);

如果需要把 prop 当成 Ref 使用:

1
2
const props = defineProps<Props>();
const { foo } = toRefs(props);

不要把 defineProps 放进普通运行时函数里。defineProps 不是普通值来源,而是编译器入口;运行时处理要发生在宏已经被编译器接住之后。