Vue script setup 中组件静默消失的原因:一次命名冲突排查

Vue 3 的 <script setup> 很好用,但它也会把一些原本显式的东西变成编译器规则。平时这让代码更短,出问题时也会让排查路径变得很绕。

这篇文章记录一个真实排查:页面里一个组件没有渲染出来,控制台没有明显 error,最后发现原因是 <script setup> 里的普通变量名和组件名只差大小写,模板编译时把组件 tag 解析到了错误的变量上。

问题本身不复杂,但如果只看源码,很难第一时间想到它。真正的关键在编译产物和 Vue 编译器的组件解析规则里。

现象

有一个 Popup 页面,底部应该渲染一个反馈组件:

1
<popup-status :message="popupStatus.message" :type="popupStatus.type" />

组件本身从 barrel 里导入:

1
import { ActionFooter, EmptyState, KeySelectionTree, PopupStatus, SettingsForm } from './components';

同一个 <script setup> 里又从页面流程 composable 解构出一个状态对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const {
copyConsoleWriteScript,
copyConsoleWriteScriptIsLoading,
hasKeySelectionPreview,
keySelectionOverrides,
popupStatus,
primaryActionIsLoading,
readCurrentTable,
selectedTagKeys,
settings,
targetTabsSnapshot,
updateSettings,
writeSelectedKeys
} = usePopupFlow();

页面运行后,底部反馈文案没有了。奇怪的是控制台没有明显 error,页面其他部分也能正常工作。

这就很容易把排查方向带偏:是不是样式把它盖住了,是不是 v-if 没命中,是不是组件导出错了,是不是构建产物没更新。真正原因藏在 <popup-status> 这个 tag 的解析结果里。

修复方式

最终修复很简单:把状态对象从 popupStatus 改成不会和组件名冲突的 popupFeedback

1
<popup-status :message="popupFeedback.message" :type="popupFeedback.type" />

对应脚本也改成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const {
copyConsoleWriteScript,
copyConsoleWriteScriptIsLoading,
hasKeySelectionPreview,
keySelectionOverrides,
popupFeedback,
primaryActionIsLoading,
readCurrentTable,
selectedTagKeys,
settings,
targetTabsSnapshot,
updateSettings,
writeSelectedKeys
} = usePopupFlow();

改完之后,底部提示恢复显示。这个结果基本可以反证:问题确实是组件名和变量名冲突。

但只知道「改名能修」还不够。更重要的问题是:为什么 Vue 会把一个普通对象当成组件,而且为什么没有直接报错?

文档里写了什么

Vue 官方文档其实已经写了几条相关规则,只是没有把这次这个边界完整展开。

Vue <script setup> 文档 说明,<script setup> 里的顶层绑定会暴露给 template。这里的顶层绑定包括变量、函数和 import。

也就是说,下面这个变量可以直接在模板里用:

1
2
3
4
5
6
7
<script setup>
const msg = 'Hello';
</script>

<template>
<div>{{ msg }}</div>
</template>

import 进来的函数也可以直接用:

1
2
3
4
5
6
7
<script setup>
import { capitalize } from './helpers';
</script>

<template>
<div>{{ capitalize('hello') }}</div>
</template>

组件也是同一套心智模型。

Vue <script setup> 的 Using Components 说明,<script setup> 里的组件可以直接作为自定义组件 tag 使用。文档里还特别提醒:可以把 MyComponent 理解成一个变量引用;<my-component> 这种 kebab-case 等价写法也可以工作,但更推荐使用 PascalCase。

例如:

1
2
3
4
5
6
7
<script setup>
import MyComponent from './MyComponent.vue';
</script>

<template>
<MyComponent />
</template>

或者:

1
2
3
<template>
<my-component />
</template>

Vue 组件注册文档里的 Component Name Casing 也说明,Vue 支持把 kebab-case tag 解析到 PascalCase 注册名或组件名上。

这些文档足够告诉我们两件事:

  • <script setup> 的模板能看到顶层变量和 import。
  • <popup-status> 有机会解析到 PopupStatus

但文档没有明确说清楚一个细节:如果同一个 SFC 里同时存在 PopupStatuspopupStatus<popup-status> 会先命中谁。

这个优先级,就需要看编译产物和源码。

最小复现

先写一个最小化 SFC。

1
2
3
4
5
6
7
8
9
<template>
<popup-status :message="popupStatus.message" :type="popupStatus.type" />
</template>

<script setup lang="ts">
import { PopupStatus } from './components';

const popupStatus = { message: 'hello', type: 'success' };
</script>

直觉上,<popup-status> 应该对应导入的 PopupStatus 组件,:message:type 读取本地状态对象 popupStatus

实际不是。

可以用 vue/compiler-sfc 做一个最小编译实验:

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
32
33
34
import { parse, compileScript, compileTemplate } from 'vue/compiler-sfc';

const source = `
<template>
<popup-status :message="popupStatus.message" :type="popupStatus.type" />
</template>

<script setup lang="ts">
import { PopupStatus } from './components';

const popupStatus = { message: 'hello', type: 'success' };
</script>
`;

// 1. parse 把 .vue 文件拆成 SFC descriptor,也就是 template、script、style 这些块。
const { descriptor } = parse(source, { filename: 'Conflict.vue' });

// 2. compileScript 处理 <script setup>,返回 script 编译结果。
const compiledScript = compileScript(descriptor, { id: 'demo' });

// 3. compileTemplate 处理 <template>,生成 render 函数。
// 这里把 compiledScript.bindings 传进去,是为了让模板编译器知道模板里每个名字来自哪里。
const template = compileTemplate({
id: 'demo',
filename: 'Conflict.vue',
source: descriptor.template.content,
compilerOptions: {
bindingMetadata: compiledScript.bindings
}
});

console.log(compiledScript.bindings);
console.log(compiledScript.content);
console.log(template.code);

先看 compiledScript。它不是原来的 <script> 标签,而是 compileScript() 返回的「script 编译结果」对象。这个对象里不止有两个字段,例如还会有 attrsimportsmapscriptAstscriptSetupAstdeps 等信息;这里只关注和当前问题直接相关的两个字段:

  • compiledScript.bindings 是给编译器继续使用的元数据,描述每个顶层绑定是什么类型。
  • compiledScript.content 是一个「内容为代码的字符串」,也就是 compileScript() 生成出来、后续会交给打包器继续处理并输出到产物里的组件代码。

第一个 console.log(compiledScript.bindings) 的输出是:

1
2
3
4
5
// compiledScript.bindings
{
"PopupStatus": "setup-maybe-ref",
"popupStatus": "setup-const"
}

第二个 console.log(compiledScript.content) 的输出是一段代码字符串。为了阅读方便,这里直接按代码格式展示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { defineComponent as _defineComponent } from 'vue'
import { PopupStatus } from './components';

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

const popupStatus = { message: 'hello', type: 'success' };

const __returned__ = {
popupStatus,
get PopupStatus() { return PopupStatus }
}
Object.defineProperty(__returned__, '__isScriptSetup', {
enumerable: false,
value: true
})
return __returned__
}
})

这段产物先说明一件事:<script setup> 并不是浏览器直接执行的语法。它会被 compileScript 改成一个标准 defineComponent 组件,原来的顶层变量会放进 setup(),最后通过 __returned__ 暴露给模板。

所以 bindings__returned__ 是对应的:

1
2
3
4
{
"PopupStatus": "setup-maybe-ref", // import 进来的组件变量
"popupStatus": "setup-const" // 本地 const 状态
}

接着看 template。它是 compileTemplate() 返回的「template 编译结果」对象,也不只有 code,还包括 asttipserrorsmap 等信息。当前只关注 template.code,因为它能直接看出 <popup-status> 最终被编译成了什么。

第三个 console.log(template.code) 的输出是:

1
2
3
4
5
6
7
8
import { openBlock as _openBlock, createBlock as _createBlock } from "vue";

export function render(_ctx, _cache, $props, $setup, $data, $options) {
return (_openBlock(), _createBlock($setup["popupStatus"], {
message: $setup.popupStatus.message,
type: $setup.popupStatus.type
}, null, 8 /* PROPS */, ["message", "type"]));
}

看到这里,可能会冒出另一个问题:compiledScript.content 里有 importexport defaulttemplate.code 里也有 importexport function render,它们看起来像两个独立模块。真实项目里怎么会变成一个 .vue 文件的最终产物?

答案是:compileScript()compileTemplate() 是偏底层的编译 API,它们各自返回「模块形态的代码片段」,方便构建工具继续处理;真实项目里通常还有一层构建插件负责组装。以 Vite 为例,这一步主要由 @vitejs/plugin-vue 做。

@vitejs/plugin-vuetransformMain 会先生成 script 和 template 两段代码,并把 template 的 render 记录成要挂到组件上的属性。随后它把这些代码片段放进同一个 output 数组里,最后通过 _export_sfc(_sfc_main, [['render', _sfc_render]]) 导出最终组件。

为了避免一个模块里出现两个顶层导出,插件会做两类改写。

第一类是把 script 的默认导出改成内部变量。@vitejs/plugin-vue 里有一个固定的组件变量名 _sfc_maincompileScript() 在合适的时候会直接生成 const _sfc_main = ...;否则插件也会用 rewriteDefault() 把默认导出改写成这个变量。

scriptIdentifier 就是 _sfc_main。这一步的目的很直接:先拿到组件对象,但暂时不要 export default

第二类是把 template 的具名导出改成内部 render 函数。compileTemplate() 原本输出的是 export function render(...),而 Vite 插件在把 template 合进主模块时,会把它改成 function _sfc_render(...)

transformTemplateInMain 做的核心事情就是把 export function renderexport const render 改成 _sfc_render 这种内部名字。

用伪代码表示,最终合成的模块大概长这样。这段不是为了还原 @vitejs/plugin-vue 的源码细节,只是帮助读者快速抓住 scripttemplate 和最终默认导出之间的关系,不对应某一个源码文件:

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
32
33
34
35
36
import { defineComponent as _defineComponent } from 'vue';
import { openBlock as _openBlock, createBlock as _createBlock } from 'vue';
import { PopupStatus } from './components';
import _export_sfc from 'plugin-vue:export-helper';

const _sfc_main = /* @__PURE__ */ _defineComponent({
__name: 'Conflict',
setup(__props, { expose: __expose }) {
__expose();

const popupStatus = { message: 'hello', type: 'success' };

const __returned__ = {
popupStatus,
get PopupStatus() { return PopupStatus }
};

Object.defineProperty(__returned__, '__isScriptSetup', {
enumerable: false,
value: true
});

return __returned__;
}
});

function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
return (_openBlock(), _createBlock($setup["popupStatus"], {
message: $setup.popupStatus.message,
type: $setup.popupStatus.type
}, null, 8 /* PROPS */, ["message", "type"]));
}

export default /*#__PURE__*/_export_sfc(_sfc_main, [
['render', _sfc_render]
]);

这段代码不是逐字逐行的真实产物,而是把关键结构压缩出来后的示意。真正要看的重点是:最终模块只有一个默认导出;compiledScript.content 贡献的是组件主体 _sfc_maintemplate.code 贡献的是渲染函数 _sfc_render,构建插件把后者挂回前者,再导出完整组件。

这里不用深入理解 Vue 的内部优化,只要抓一个点:openBlock 是打开一个 block,createBlock 是在里面创建一个有意义的渲染块。createBlock 的第一个参数就是要渲染的组件类型或标签类型。

也就是说,这一句最关键:

1
_createBlock($setup["popupStatus"], ...)

这里一定要分清两个阶段。

$setup 是 render 函数运行时收到的参数,可以粗略理解成前面 setup() 返回给模板使用的对象。它的具体值要等组件运行起来以后才有。

["popupStatus"] 不是运行时动态决定的。它已经在编译阶段被写死进 render 函数了。也就是说,真正的问题发生在编译阶段:模板编译器已经决定 <popup-status> 要读取 $setup["popupStatus"],运行时只是照着这段代码执行。

前面的 __returned__ 里同时有 popupStatusPopupStatus。一旦编译产物固定成 $setup["popupStatus"],运行时即使 $setup 里也存在真正的组件 PopupStatus,这里也不会再去重新选择它。

Vue 没有把 <popup-status> 编译成 $setup["PopupStatus"],而是编译成了 $setup["popupStatus"]

换句话说,它把这个普通对象当成了组件:

1
const popupStatus = { message: 'hello', type: 'success' };

改名后的产物

把状态对象改成 popupFeedback

1
2
3
4
5
6
7
8
9
<template>
<popup-status :message="popupFeedback.message" :type="popupFeedback.type" />
</template>

<script setup lang="ts">
import { PopupStatus } from './components';

const popupFeedback = { message: 'hello', type: 'success' };
</script>

再编译一次,结构会变成这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// compiledScript.bindings
{
"PopupStatus": "setup-maybe-ref",
"popupFeedback": "setup-const"
}

// compiledScript.content 里的返回对象
const __returned__ = {
popupFeedback,
get PopupStatus() { return PopupStatus }
}
return __returned__

// template.code 里的 render 函数
import { openBlock as _openBlock, createBlock as _createBlock } from "vue";

export function render(_ctx, _cache, $props, $setup, $data, $options) {
return (_openBlock(), _createBlock($setup["PopupStatus"], {
message: $setup.popupFeedback.message,
type: $setup.popupFeedback.type
}, null, 8 /* PROPS */, ["message", "type"]));
}

这次 __returned__ 里没有 popupStatus<popup-status> 的候选名只会命中 PopupStatus。所以 render 函数里变成了正确结果:

1
_createBlock($setup["PopupStatus"], ...)

为什么会优先命中普通变量

读到这里,真正要追的问题已经很明确了:为什么 compileTemplate 会把 <popup-status> 绑定到 $setup["popupStatus"],而不是 $setup["PopupStatus"]

把前面的产物连起来看,调用链大概是这样:

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
32
33
34
+------------------------------------------------------+
| <script setup> |
| |
| import { PopupStatus } from './components' |
| const popupStatus = { ... } |
+------------------------------------------------------+

+------------------------------------------------------+
| @vue/compiler-sfc / compileScript |
| |
| bindingMetadata = { |
| PopupStatus: 'setup-maybe-ref', |
| popupStatus: 'setup-const' |
| } |
+------------------------------------------------------+

+------------------------------------------------------+
| @vue/compiler-sfc / compileTemplate |
| |
| 把 bindingMetadata 传给模板编译流程 |
+------------------------------------------------------+

+------------------------------------------------------+
| @vue/compiler-core / resolveSetupReference |
| |
| 用 'popup-status' 在 bindingMetadata 里查找 |
| 最终选择 popupStatus |
+------------------------------------------------------+

+------------------------------------------------------+
| render |
| |
| _createBlock($setup["popupStatus"], ...) |
+------------------------------------------------------+

@vue/compiler-sfc 负责 SFC 这层事情:把 .vue 拆成 template、script、style,处理 <script setup>,并整理出 bindingMetadata。它在这一步知道「有一个导入的 PopupStatus,还有一个本地的 popupStatus」,但它还没有决定模板里的 <popup-status> 最终应该用哪一个。

compileTemplate()@vue/compiler-sfc 暴露出来的模板编译入口。它会带着 bindingMetadata 调用 @vue/compiler-core 的模板编译能力。@vue/compiler-core 负责把模板 AST 转成 render 函数;在这个问题里,<popup-status> 到底变成 $setup["popupStatus"] 还是 $setup["PopupStatus"],关键就在它的元素解析逻辑。

所以问题可以拆成两步:先看 bindingMetadata 怎么把 popupStatusPopupStatus 分到不同类型,再看 compiler-core 怎么按这些类型和候选名做查找。

变量先被归类

先看分类从哪里来。这里先抓住两个来源:PopupStatus 来自顶层 import,popupStatus 来自 <script setup> 里的本地变量。它们会先进入同一份 bindingMetadata,随后才会被归到不同类型里。

compileScript() 会先收集 <script><script setup> 里的 import,处理普通 <script><script setup> 和编译宏,完成 props 解构转换、宏参数作用域检查,并移除非 script 内容。随后进入绑定分析阶段,把前面收集到的 userImportsscriptBindingssetupBindings 合并成 bindingMetadata

相关源码都在 Vue 3.5.34 的 packages/compiler-sfc/src/compileScript.ts 里:scriptBindings / setupBindings 初始化walkDeclaration() 写入普通 <script> 绑定walkDeclaration() 写入 <script setup> 绑定bindingMetadata 合并。下面只用整行注释省略不相关分支,保留源码原本的结构。

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
// ...省略 compileScript 进入绑定分析前的准备步骤:
// 收集 import、处理普通 <script>、处理 <script setup> 和编译宏、
// props 解构转换、宏参数作用域检查、移除非 script 内容。
const scriptBindings: Record<string, BindingTypes> = Object.create(null)
const setupBindings: Record<string, BindingTypes> = Object.create(null)

if (scriptAst) {
for (const node of scriptAst.body) {
// ...省略普通 <script> 的 default export、具名导出和位置移动处理。
if (node.declaration) {
walkDeclaration(
'script',
node.declaration,
scriptBindings,
vueImportAliases,
hoistStatic,
)
} else if (
// 普通 <script> 顶层变量、函数、类、TypeScript enum。
(node.type === 'VariableDeclaration' ||
node.type === 'FunctionDeclaration' ||
node.type === 'ClassDeclaration' ||
node.type === 'TSEnumDeclaration') &&
!node.declare
) {
walkDeclaration('script', node, scriptBindings, vueImportAliases, hoistStatic)
}
}
}

for (const node of scriptSetupAst.body) {
// ...省略 <script setup> 的 defineProps / defineEmits / defineModel 等宏处理。
if (
// <script setup> 顶层变量、函数、类、TypeScript enum。
(node.type === 'VariableDeclaration' ||
node.type === 'FunctionDeclaration' ||
node.type === 'ClassDeclaration' ||
node.type === 'TSEnumDeclaration') &&
!node.declare
) {
walkDeclaration(
'scriptSetup',
node,
setupBindings,
vueImportAliases,
hoistStatic,
!!ctx.propsDestructureDecl,
)
}
}

// 6. analyze binding metadata
// `defineProps` & `defineModel` also register props bindings
if (scriptAst) {
Object.assign(ctx.bindingMetadata, analyzeScriptBindings(scriptAst.body))
}

for (const [key, { isType, imported, source }] of Object.entries(ctx.userImports)) {
if (isType) continue
ctx.bindingMetadata[key] =
imported === '*' ||
(imported === 'default' && source.endsWith('.vue')) ||
source === 'vue'
? BindingTypes.SETUP_CONST
: BindingTypes.SETUP_MAYBE_REF
}

for (const key in scriptBindings) {
ctx.bindingMetadata[key] = scriptBindings[key]
}

for (const key in setupBindings) {
ctx.bindingMetadata[key] = setupBindings[key]
}

// ...省略 v-model 绑定降级、useCssVars、setup 参数和 default export 生成。

这段里最重要的是两条分类入口:

  • import 会在 ctx.userImports 这一段直接分类,并写进 ctx.bindingMetadata
  • 除 import 之外的顶层定义会先交给 walkDeclaration() 分类,结果写进 scriptBindingssetupBindings,最后再合并进 ctx.bindingMetadata

PopupStatus 来自 ctx.userImports,所以看这条 import 分支:只有命名空间 import、从 .vue 文件 default import、或者从 vue 包本身 import,才会被标成 SETUP_CONST。其他 import 都会落到 SETUP_MAYBE_REF

popupStatus 不是 import,而是 <script setup> 里的本地变量。它会先进入 setupBindings,而 setupBindings 的分类来自 walkDeclaration()

walkDeclaration() 的精确源码在 packages/compiler-sfc/src/compileScript.ts。下面同样是完整判断结构的阅读版,省掉了对象 / 数组解构辅助函数的展开,但保留了变量、枚举、函数、类这些和本文表格相关的分类分支。

walkDeclaration() 的第三个参数 bindings 是分类结果的写入目标:普通 <script> 会传入 scriptBindings<script setup> 会传入 setupBindings。后面的分支负责判断当前声明属于哪种 BindingTypes,并把结果写进这个对象。

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
// ...省略函数参数类型、AST 类型守卫和解构辅助函数。
function walkDeclaration(
from: 'script' | 'scriptSetup',
node: Declaration,
bindings: Record<string, BindingTypes>,
userImportAliases: Record<string, string>,
hoistStatic: boolean,
isPropsDestructureEnabled = false,
): boolean {
let isAllLiteral = false

if (node.type === 'VariableDeclaration') {
const isConst = node.kind === 'const'
isAllLiteral =
isConst &&
node.declarations.every(
decl => decl.id.type === 'Identifier' && isStaticNode(decl.init!),
)

for (const { id, init: _init } of node.declarations) {
const init = _init && unwrapTSNode(_init)
const isConstMacroCall =
isConst &&
isCallOf(
init,
c =>
c === DEFINE_PROPS ||
c === DEFINE_EMITS ||
c === WITH_DEFAULTS ||
c === DEFINE_SLOTS,
)

if (id.type === 'Identifier') {
const userReactiveBinding = userImportAliases['reactive']
if (
(hoistStatic || from === 'script') &&
(isAllLiteral || (isConst && isStaticNode(init!)))
) {
// const foo = 'x'
bindingType = BindingTypes.LITERAL_CONST
} else if (isCallOf(init, userReactiveBinding)) {
// const foo = reactive({})
bindingType = isConst
? BindingTypes.SETUP_REACTIVE_CONST
: BindingTypes.SETUP_LET
} else if (
isConstMacroCall ||
(isConst && canNeverBeRef(init!, userReactiveBinding))
) {
// const foo = {}
// const props = defineProps()
bindingType = isCallOf(init, DEFINE_PROPS)
? BindingTypes.SETUP_REACTIVE_CONST
: BindingTypes.SETUP_CONST
} else if (isConst) {
if (
isCallOf(
init,
m =>
m === userImportAliases['ref'] ||
m === userImportAliases['computed'] ||
m === userImportAliases['shallowRef'] ||
m === userImportAliases['customRef'] ||
m === userImportAliases['toRef'] ||
m === userImportAliases['useTemplateRef'] ||
m === DEFINE_MODEL,
)
) {
// const foo = ref(0)
// const foo = computed(...)
bindingType = BindingTypes.SETUP_REF
} else {
// const foo = useFoo()
bindingType = BindingTypes.SETUP_MAYBE_REF
}
} else {
// let foo = 1
bindingType = BindingTypes.SETUP_LET
}

registerBinding(bindings, id, bindingType)
} else {
if (isCallOf(init, DEFINE_PROPS) && isPropsDestructureEnabled) {
continue
}
// const { foo } = xxx / const [foo] = xxx
// 解构变量会继续按 const / let / defineProps 等信息登记。
if (id.type === 'ObjectPattern') {
walkObjectPattern(id, bindings, isConst, isConstMacroCall)
} else if (id.type === 'ArrayPattern') {
walkArrayPattern(id, bindings, isConst, isConstMacroCall)
}
// ...省略 walkObjectPattern / walkArrayPattern 的完整展开。
}
}
} else if (node.type === 'TSEnumDeclaration') {
isAllLiteral = node.members.every(
member => !member.initializer || isStaticNode(member.initializer),
)
bindings[node.id!.name] = isAllLiteral
? BindingTypes.LITERAL_CONST
: BindingTypes.SETUP_CONST
} else if (
node.type === 'FunctionDeclaration' ||
node.type === 'ClassDeclaration'
) {
// export function foo() {} / export class Foo {}
bindings[node.id!.name] = BindingTypes.SETUP_CONST
}

return isAllLiteral
}

落到常见写法上,可以粗略这样看:

写法 分类 原因
import { ref } from 'vue' SETUP_CONST source === 'vue',编译器知道这是 Vue API
import * as Vue from 'vue' SETUP_CONST imported === '*',命名空间 import 是稳定对象
import Foo from './Foo.vue' SETUP_CONST default import 且来源以 .vue 结尾,编译器能确定是组件模块
import { Foo } from './components' SETUP_MAYBE_REF barrel 具名导入,编译器不知道它最终是不是 ref
const foo = {} SETUP_CONST 普通对象字面量不可能是 ref
const foo = reactive({}) SETUP_REACTIVE_CONST reactive() 结果是稳定响应式对象
const foo = ref(0) / computed(...) SETUP_REF 明确由 ref 类 API 创建
const foo = useFoo() SETUP_MAYBE_REF 普通函数调用结果可能是 ref,也可能不是
let foo = 1 SETUP_LET let 绑定运行时可能变化
const foo = 'x' LITERAL_CONST 可静态提升的字面量常量
function foo() {} / class Foo {} SETUP_CONST 函数 / 类声明是稳定绑定
enum Foo { A = 1 } LITERAL_CONST / SETUP_CONST 成员都静态时可提升,否则按稳定绑定处理

回到这个例子:

1
import { PopupStatus } from './components';

这里的 importedPopupStatussource./components。它不是命名空间 import,不是从 .vue 文件 default import,也不是从 vue 包导入,所以会进入最后的 SETUP_MAYBE_REF

而本地变量这边:

1
const popupStatus = { message: 'hello', type: 'success' };

它是普通对象字面量,不可能是 ref,所以会被 walkDeclaration() 归到 SETUP_CONST

于是当前例子的 bindingMetadata 会变成这样:

1
2
3
4
{
"PopupStatus": "setup-maybe-ref",
"popupStatus": "setup-const"
}

到这里还没有发生错误。它只是把两个顶层绑定分到了不同类型里。

分类再参与解析

真正把 <popup-status> 解析成 $setup["popupStatus"] 的,是 @vue/compiler-core 里的 resolveSetupReference()。它在 Vue 3.5.34 的 packages/compiler-core/src/transforms/transformElement.ts 里,源码不长,可以直接看完整结构:

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
function resolveSetupReference(name: string, context: TransformContext) {
const bindings = context.bindingMetadata
if (!bindings || bindings.__isScriptSetup === false) {
return
}

const camelName = camelize(name)
const PascalName = capitalize(camelName)
const checkType = (type: BindingTypes) => {
if (bindings[name] === type) {
return name
}
if (bindings[camelName] === type) {
return camelName
}
if (bindings[PascalName] === type) {
return PascalName
}
}

const fromConst =
checkType(BindingTypes.SETUP_CONST) ||
checkType(BindingTypes.SETUP_REACTIVE_CONST) ||
checkType(BindingTypes.LITERAL_CONST)
if (fromConst) {
return context.inline
? fromConst
: `$setup[${JSON.stringify(fromConst)}]`
}

const fromMaybeRef =
checkType(BindingTypes.SETUP_LET) ||
checkType(BindingTypes.SETUP_REF) ||
checkType(BindingTypes.SETUP_MAYBE_REF)
if (fromMaybeRef) {
return context.inline
? `${context.helperString(UNREF)}(${fromMaybeRef})`
: `$setup[${JSON.stringify(fromMaybeRef)}]`
}

const fromProps = checkType(BindingTypes.PROPS)
if (fromProps) {
return `${context.helperString(UNREF)}(${
context.inline ? '__props' : '$props'
}[${JSON.stringify(fromProps)}])`
}
}

这里有两层顺序。

第一层是绑定类型顺序。SETUP_CONSTSETUP_REACTIVE_CONSTLITERAL_CONST 这一组在 fromConst 里先检查;SETUP_LETSETUP_REFSETUP_MAYBE_REF 这一组在 fromMaybeRef 里后检查;PROPS 最后在 fromProps 里检查。

第二层是同一个类型内部的候选名顺序。<popup-status> 会被转换成多个候选名字:

1
2
3
popup-status
popupStatus
PopupStatus

检查单个类型时,Vue 会按「原名 -> camelCase -> PascalCase」的顺序查。对 <popup-status> 来说,原名 popup-status 通常不会命中,第二个候选 popupStatus 会先于第三个候选 PopupStatus 被检查。

现在把这两层顺序和前面的分类放在一起看:

1
2
3
4
5
6
7
8
9
10
fromConst 先检查:
popup-status -> 没有
popupStatus -> 命中 setup-const,直接返回
PopupStatus -> 不再检查

fromMaybeRef 后检查:
因为 fromConst 已经返回,所以不会走到这里

fromProps 最后检查:
因为 fromConst 已经返回,所以也不会走到这里

所以 <popup-status> 会先命中 popupStatus,而不是 PopupStatus

这里顺手解释一个细节:fromConstfromMaybeReffromProps 分开,不只是为了排序,也是在服务不同的代码生成方式。当前实验产物是非 inline template 模式,所以 fromConstfromMaybeRef 最后都会生成 $setup[...];源码仍然保留两个分支,是因为 inline template 模式下,可能是 ref 的绑定需要显式生成 unref(xxx)

严格说,这里有两个相邻名字:对外的 SFC 编译选项叫 inlineTemplate,compiler-core 里真正影响 resolveSetupReference() 的选项叫 inlinecompileScript() 打开 inlineTemplate 后,会在内部调用 compileTemplate(),并把 compilerOptions.inline 设成 true

inline template 的作用,是把 template 编译出来的 render 函数直接放进 setup() 里。这样 render 可以直接访问 setup() 的局部变量,不需要先把所有绑定整理成一个返回对象再交给外部 render 访问。它更偏生产构建优化,不是业务代码里的功能开关。

代价是 template 不能再和组件状态分开热更新。Vue 官方源码也把这一点写在 inlineTemplate 注释里:它只影响 <script setup>,并且应该只在生产使用,因为会阻止 template 和组件状态分开 HMR。

如果直接使用 @vue/compiler-sfc,开关就是 compileScript()inlineTemplate

1
2
compileScript(descriptor, { inlineTemplate: true })  // 开启
compileScript(descriptor, { inlineTemplate: false }) // 关闭

普通 Vite 项目里通常不在业务代码里手动开关,而是由 @vitejs/plugin-vue 决定:开发服务下关闭,方便 template 单独热更新;生产构建里,如果是 <script setup> 且 template 不是外部 src,插件会打开 inline template。最新插件源码里还会避开 Vue DevTools 开启的场景。

这就是它需要单独处理 maybe-ref 的原因:render 如果直接访问 setup() 里的局部变量,拿到的就是原始局部变量本身。对于 SETUP_MAYBE_REF 这类绑定,编译器只能知道它「可能是 ref」,不能在编译时确定运行时值是不是 ref。为了保持模板里 ref 自动解包的语义,inline 分支会生成 unref(xxx)

非 inline template 不直接访问 setup() 局部变量,而是通过 $setup[...] 访问 setup 暴露给 render 的对象,所以 resolveSetupReference() 在这一支里不生成 unref()

inlineTemplate 的选项说明、compilerOptions.inline = true 的传递位置,以及 @vitejs/plugin-vue 的自动判断 可以放在一起看。UNREF 本身不是调用表达式,而是 compiler-core 里表示 runtime helper 的 symbol,并在 helperNameMap 里映射到字符串 unrefcontext.helperString(UNREF) 会先登记这个 helper,再返回生成代码里使用的名字 _unref,最终对应 import { unref as _unref } from 'vue' 这类产物。

回到当前问题,inline 差异不是根因,只是解释了为什么源码需要分成不同返回分支。真正影响结果的仍然是分组顺序:setup-const 组先查到 popupStatus 以后,后面的 setup-maybe-ref 组和 props 组就不会再继续检查。

再往外看一层,resolveSetupReference() 也不是组件 tag 的全部解析逻辑。resolveComponentType() 会先处理动态组件和内置组件,再尝试从 setup 绑定里解析;这里的 setup 绑定包括 SETUP_CONSTSETUP_LETSETUP_REFSETUP_MAYBE_REF 这几类,也包括 PROPS。如果这些都没命中,才会继续走文件名推断出来的自引用组件,最后生成 resolveComponent(tag),交给运行时去查局部注册和全局注册的组件。

这条外层顺序在 resolveComponentType() 里。运行时局部 / 全局组件查询在 resolveAsset() 里。

所以,全局组件不是靠 bindingMetadata 判断的。它们通常会走最后的运行时解析分支:编译产物里出现 resolveComponent("xxx"),运行时再从当前组件的局部 componentsappContext.components 里按原名、camelCase、PascalCase 查找。

全局函数又是另一条链路。模板表达式里的函数调用由表达式转换处理;如果名字能在 setup 绑定或 props 里找到,就按对应绑定类型生成访问代码。如果找不到,它会落到 _ctx.xxx 这类实例上下文访问上,再由运行时组件代理去解析。它不属于组件 tag 的 resolveComponentType() 这条链路。

表达式里的变量访问逻辑在 transformExpression.ts 里;它和组件 tag 的 resolveComponentType() 是两条不同路径。

这样就能回答几个相邻的冲突场景。先整理成一个更容易记的优先级模型:

1
2
3
4
5
6
7
8
9
10
11
12
组件 tag 解析:
动态组件 / 内置组件
-> resolveSetupReference() 解析 bindingMetadata
每个分组内部继续按名字格式匹配:kebab-case -> camelCase -> PascalCase
1. const-like:SETUP_CONST / SETUP_REACTIVE_CONST / LITERAL_CONST
本例命中:const popupStatus = { ... };
2. maybe-ref-like:SETUP_LET / SETUP_REF / SETUP_MAYBE_REF
本例未检查:import { PopupStatus } from './components'; // 上一步已返回
3. props:PROPS
-> 文件名推断出来的自引用组件
-> 运行时注册组件(局部 components,再到全局 app.component)
内部同样按 kebab-case -> camelCase -> PascalCase 查找

第一,如果是 prop 和导入组件同名,通常不会抢过导入组件。例如同时存在 import { PopupStatus } ...defineProps<{ popupStatus: unknown }>()<popup-status> 会先在 fromMaybeRef 里命中 PopupStatus,不会走到最后的 fromProps

第二,如果没有导入组件,只有一个同名 prop,情况就不同。popupStatus 作为 PROPS 会参与组件 tag 解析,而且它会早于运行时注册组件解析命中。此时 <popup-status> 会被编译成类似:

1
_createBlock(_unref($props["popupStatus"]))

这意味着 prop 也可能挡住运行时注册组件,包括全局组件。运行时注册组件只有在 setup 绑定、props、自引用组件都没命中时,才会进入 resolveComponent("popup-status") 这条解析链路。

第三,如果说的是 app.config.globalProperties 上挂的全局函数或全局变量,它不走组件 tag 的解析路径,而是走模板表达式解析。表达式解析里的大方向也类似:本地 setup 绑定和 props 先参与匹配,全局属性最后才通过组件实例上下文兜底。它出现在模板表达式里时,例如 {{ popupStatus() }},找不到本地 setup 绑定或 prop 时才会落到:

1
_ctx.popupStatus()

所以全局函数 / 全局变量的冲突重点不在 <popup-status> 这种组件 tag,而在模板表达式名。只要本地 setup 绑定或 prop 有同名标识符,就会优先按本地绑定处理,不会优先访问 _ctx 上的全局属性。

即使改成直接从 .vue 文件 default import,结果也还是不对。这样只会让 PopupStatus 也进入 setup-const 这一组,但同一组类型内部仍然会先检查 camelCase 的 popupStatus,再检查 PascalCase 的 PopupStatus。只要本地还存在 const popupStatus = ...<popup-status> 仍然会先命中普通对象。

也就是说,直接 .vue default import 只解决了「类型分组」差异,没有解决「候选名顺序」冲突。真正要修的是命名冲突本身。

这就是组件静默消失的根因。

这里还有一个容易误会的点:Vue 编译阶段并没有把它当成「冲突」处理。从编译器视角看,popupStatusPopupStatus 都是合法顶层绑定,<popup-status> 也确实命中了一个合法绑定。它不知道开发者的真实意图是「我要渲染组件 PopupStatus,不是状态对象 popupStatus」。

所以最本质的问题不是 Vue 发现冲突后报错太弱,而是 Vue compiler-core 根本没有把这件事识别成冲突。它只按既定顺序解析出了一个绑定;等到运行时发现这个绑定不是可渲染组件,报错信息才变成「组件没有模板或 render 函数」。这条 warning 指向的是运行时结果,不会反向告诉读者「其实是名字解析命中了另一个绑定」。

版本边界

这套顺序要按版本说清楚。当前文章主要按 Vue 3.5 的源码解释,它能覆盖现在项目里遇到的问题,但不能直接说「Vue 3 所有版本完全一样」。

版本范围 静态组件 tag 的关键解析机制
Vue 3.4+ / 3.5 const-like -> maybe-ref-like -> props,然后才是自引用组件和运行时注册组件
Vue 3.3 const-like -> maybe-ref-like;没有 props 这一支
Vue 3.1 / 3.2 SETUP_CONST -> maybe-ref-like;这里只有单个 SETUP_CONST,还不是后来的完整 const-like 组,也没有 props 这一支
Vue 3.0 只做 bindingMetadata[tag] === 'setup' 的原名精确匹配,没有后续版本的 kebab-case -> camelCase -> PascalCase
Vue 2.7 官方 <script setup> 类似地按名字格式匹配,并且 const-like 早于 maybe-ref-like,但没有 props 这一支;它属于 Vue 2 编译器的另一套实现
Vue 2.6 或更早 + antfu 插件 第三方插件先转成普通 <script>,并把模板组件 tag 注入 components;当前案例不走 $setup["popupStatus"] 路径

表格里的 const-like 是本文为了方便阅读起的分组名,不是 Vue 源码里的单个枚举。Vue 3.3+ 的 const-like 包括 SETUP_CONSTSETUP_REACTIVE_CONSTLITERAL_CONST;Vue 3.1 / 3.2 的 resolveSetupReference() 只检查单个 SETUP_CONST,所以表格里单独写 SETUP_CONST

对当前案例来说,最重要的是 import 和 const 的相对顺序。从 Vue 3.1 开始,resolveSetupReference() 已经按「先 const / 后 maybe-ref」处理,因此 const popupStatusimport { PopupStatus } ... 更早命中的风险已经存在。props 参与组件 tag 解析,则是 Vue 3.4+ 才需要放进同一张优先级图里的边界。

Vue 3.0 不能归到同一结论里。它的组件 tag 解析只检查原始 tag 是否正好等于某个 setup 绑定名,所以 <popup-status> 不会继续尝试 popupStatus / PopupStatus。由于 popup-status 不是合法的 JS 变量名,kebab-case 静态 tag 在 Vue 3.0 里几乎不可能命中 <script setup> 顶层导入;如果它还能工作,通常是因为后续掉到了 resolveComponent("popup-status"),再由局部 components 或全局组件注册命中。这意味着当前文章里的典型冲突机制并不是「Vue 3 所有版本都一样」。真正要重点防的是 Vue 3.1+ 之后形成的候选名和分组顺序。

这也是 Vue 3.0 和 antfu 插件最关键的差异:antfu 插件会先把模板组件 tag 统一转成 PascalCase 再找声明,Vue 3.0 的 setup 绑定解析没有这一步。Vue 3.0 的 PascalCase / camelCase 兼容只会在后续 resolveComponent() 运行时解析局部或全局注册组件时出现,不会用于 <script setup> 顶层导入匹配。

上面这些判断分别来自 Vue 3.0 的早期 setup 绑定判断Vue 3.1Vue 3.3Vue 3.5resolveSetupReference(),Vue 2.7 的 checkBindingType(),以及 unplugin-vue2-script-setup@0.11.4transformScriptSetup()

Vue 2.7 之前的第三方方言

Vue 2.7 发布前,常见的 <script setup> 方言主要是 Anthony Fu 的 unplugin-vue2-script-setup。它的 README 写得很清楚:Vue 2.7 已经内置 Composition API 和 <script setup>,因此不再需要这个插件;插件进入维护模式,只支持 Vue 2.6 或更早版本。

unplugin-vue2-script-setup 的说明见 README。这里讨论的是这个插件的最后版本 v0.11.4,不是 Vue 2.7 官方编译器实现。

这个插件的核心方式是先改写源码。transformVue() 会解析 SFC,调用 transformScriptSetup() 生成普通 <script> 内容,然后用这个 <script> 替换原来的 <script setup> 块。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const sfc = await parseSFC(input, id);

const { code } = transformScriptSetup(sfc, options);

if (code) {
const block = `<script ${attr}>\n${code}\n</script>`;

s.remove(sfc.script.start, sfc.script.end);

if (sfc.scriptSetup.start !== sfc.scriptSetup.end) {
s.overwrite(sfc.scriptSetup.start, sfc.scriptSetup.end, block);
}

// ...
}

对应源码在 transform.ts。这说明它不是让 Vue 2 编译器原生理解 <script setup>,而是在 Vue 2 编译器前面先做一次源码转译。

它还会先从模板里收集组件 tag。<popup-status> 会被转成 PopupStatus,放进 template.components

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const result = codeOfTemplate
? findReferencesForSFC(codeOfTemplate)
: undefined;

return {
// ...
template: {
components: new Set(result?.components.map(pascalize)),
directives: new Set(/* ... */),
identifiers: new Set(result?.identifiers)
},
scriptSetup,
script
};

随后 transformScriptSetup() 会用这个组件名去顶层声明里找匹配项:

1
2
3
4
5
6
7
const components = Array.from(template.components)
.map(
component =>
declarationArray.find(declare => declare === component)
?? declarationArray.find(declare => pascalize(declare) === component),
)
.filter(notNullish);

这段顺序很关键。假设代码里同时有:

1
2
3
import { PopupStatus } from './components';

const popupStatus = { message: 'hello', type: 'success' };

<popup-status> 对应的组件名是 PopupStatus。插件会先用 declare === component 精确命中导入的 PopupStatus,只有精确找不到时,才会尝试 pascalize(popupStatus)。因此在这个组合里,popupStatus 这个普通状态对象不会抢走组件 tag。

最后插件会把命中的组件注入普通组件选项,生成效果类似:

1
2
3
4
__sfc_main.components = Object.assign(
{ PopupStatus },
__sfc_main.components,
);

对应源码在 parseSFC.tstransformScriptSetup.ts。组件注入在 transformScriptSetup.ts

所以,Vue 2.6 或更早版本配这个插件时,当前文章里的典型问题不能直接照搬。它会先把导入组件挂到 components 上,静态组件 tag 后续走普通 Vue 2 组件解析;而 Vue 3.1+ / Vue 2.7 官方 <script setup> 的问题点,是编译器把静态组件 tag 解析成 setup 绑定。

这不表示 2.6 插件完全没有名字风险。如果模板里只有一个叫 popupStatus 的普通对象可以被 pascalize() 匹配到,它仍然可能被注入 components。只是对「导入组件 PopupStatus + 同名状态对象 popupStatus」这个真实案例来说,2.7 之前的 antfu 插件不会按本文这条 $setup["popupStatus"] 路径失败。

为什么控制台没有明显报错

运行时发生的是:Vue 尝试把这个对象当成组件 options。

1
{ message: 'hello', type: 'success' }

这个对象没有 setup、没有 render、没有 template。在开发环境里,Vue 会给出 warning:

1
Component is missing template or render function

但它不是 error。生产构建里,warning 通常会被移除或不再输出。于是页面最终只会渲染出一个空注释节点:

1
<!---->

这就是为什么问题表面上非常安静:没有红色 error,没有白屏,其他组件还在正常工作,只是某一块 UI 消失了。

可以用一个运行时最小例子模拟:

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
import { createApp, h } from 'vue';

const realPopupStatusComponent = {
props: ['message', 'type'],
render() {
return h('p', { class: 'popup-status' }, this.message);
}
};

const popupStatusStateObject = { message: '测试反馈文案', type: 'success' };

const conflictRoot = {
render() {
return h(popupStatusStateObject, {
message: popupStatusStateObject.message,
type: popupStatusStateObject.type
});
}
};

const fixedRoot = {
render() {
return h(realPopupStatusComponent, {
message: popupStatusStateObject.message,
type: popupStatusStateObject.type
});
}
};

冲突版渲染结果是:

1
<!---->

正确版渲染结果是:

1
<p class="popup-status">测试反馈文案</p>

如果开发环境 warning 被大量日志淹没,或者项目加载的是生产构建,这个问题就很容易被误判成样式、条件渲染或数据问题。

这和显式组件注册有什么不同

<script setup> 的普通组件写法里,组件可以显式写在 components 选项里,状态从 setup() 返回:

1
2
3
4
5
6
7
8
9
10
11
12
export default {
components: {
PopupStatus
},
setup() {
const popupStatus = { message: 'hello', type: 'success' };

return {
popupStatus
};
}
};

这种写法里,组件注册名和 setup() 返回对象虽然仍然可能引起阅读混乱,但开发者至少能清楚看到「组件注册表」和「模板状态」是两块东西。

<popup-status> 这种静态组件 tag 来说,components 里的 PopupStatus 会优先于 setup() 返回的 popupStatus。更准确地说,普通组件写法不会让组件 tag 走 <script setup>resolveSetupReference():普通 <script>bindingMetadata 会被标记成 __isScriptSetup: false,模板编译会生成 resolveComponent("popup-status"),运行时再去查当前组件的 components 和全局 app.component

1
const _component_popup_status = _resolveComponent("popup-status")

所以这里不是 components.PopupStatussetup().popupStatus 比谁优先,而是静态组件 tag 根本不走 setup() 返回对象这条解析路径。setup() 返回的 popupStatus 仍然用于 :message="popupStatus.message" 这类模板表达式。

这个结论只针对静态组件 tag。若写成 <component :is="popupStatus">,或者在 render 函数里直接 h(popupStatus),那就是表达式 / 运行时值路径,setup() 返回值当然会参与。

相关源码可以看两处:普通 <script> 的绑定分析会标记 __isScriptSetup: falseresolveSetupReference() 遇到这个标记会直接跳过;运行时组件解析则在 resolveAsset() 里先查局部注册,再查全局注册。

<script setup> 的机制不同。它把顶层绑定整体暴露给模板,组件 import 也是绑定,普通变量也是绑定。模板里的组件 tag 也会走这套绑定解析逻辑。

这也是为什么这个问题不容易靠直觉排出来:在 <script setup> 里,组件名不是一个单独的注册表 key,它本质上也是一个变量引用。

怎么规避

第一,组件名和状态名不要只差大小写或 kebab/camel 形态。

1
2
3
// 容易冲突
import { PopupStatus } from './components';
const popupStatus = ref(...);

推荐改成更具体的业务名:

1
2
import { PopupStatus } from './components';
const popupFeedback = ref(...);

或者:

1
2
const operationFeedback = ref(...);
const writeResultFeedback = ref(...);

第二,业务状态名不要为了贴近组件名而重复组件名。组件叫 PopupStatus,状态不一定也要叫 popupStatus。状态应该描述它在业务里的含义,例如反馈、写入结果、操作提示、错误消息。

第三,如果必须保留某个状态名,也可以给组件 import 起别名:

1
import { PopupStatus as PopupStatusView } from './components';

模板里使用:

1
<popup-status-view :message="popupStatus.message" :type="popupStatus.type" />

不过这通常不如改状态名自然。因为状态名 popupStatus 本身也偏宽,只说明它给 Popup 用,没有说明它承载的是反馈文案、写入结果还是页面状态。

第四,遇到组件无声消失时,不要只查样式和条件。可以直接看编译产物:

1
_createBlock($setup["popupStatus"], ...)

如果这里不是预期组件名,就说明模板 tag 已经解析错了。

静态检查能不能发现

理论上可以,现实里要分两层看。

eslint-plugin-vue 官方说明里写得很清楚:它可以检查 .vue 文件里的 <template><script>。这意味着它具备同时看模板和脚本的基础能力,并不是只能查普通 TypeScript 文件。

它也确实有一些看起来相关的规则。

vue/no-undef-components 会检查模板里使用了但没有在 <script setup> 或 Options API components 里定义的组件。

vue/no-unused-components 会检查注册后没有在模板里使用的组件。

vue/no-template-shadow 会检查模板局部变量遮蔽外层变量,例如 v-for 变量名和外层作用域冲突。

但这些规则并不能直接抓出本文这个问题。

可以用最小样例验证。这个例子里,PopupStatus 是组件,popupStatus 是状态:

1
2
3
4
5
6
7
8
9
10
11
12
<template>
<popup-status :message="popupStatus.message" :type="popupStatus.type" />
</template>

<script setup lang="ts">
import PopupStatus from './popup-status.vue';

const popupStatus = {
message: 'ok',
type: 'success'
};
</script>

即使打开这些规则:

1
2
3
4
5
6
7
8
rules: {
'vue/no-undef-components': 'error',
'vue/no-unused-components': 'error',
'vue/no-template-shadow': 'error',
'vue/no-undef-properties': 'error',
'vue/no-unused-properties': ['error', { groups: ['setup'] }],
'vue/no-dupe-keys': 'error'
}

实际结果也不会报 PopupStatuspopupStatus 冲突。

原因是站在普通 ESLint 规则的视角看,这段代码似乎都是「合理」的:

  • <popup-status> 能找到一个叫 PopupStatus 的导入组件,所以它不是未定义组件。
  • PopupStatus 看起来被模板使用了,所以它不是未使用组件。
  • popupStatus:message:type 用到了,所以它也不是未使用变量。
  • popupStatusPopupStatus 是两个不同的 JavaScript 标识符,所以普通重复 key 或 shadow 规则也不会认为它们冲突。

vue/no-undef-components 的源码更能说明这个边界。

vue/no-undef-components 的 GitHub 源码 里有一个 DefinedInSetupComponents。它会收集 <script setup> 里的变量名,再用接近 Vue 的 rawName -> camelName -> pascalName 逻辑判断组件是否已定义。

这条规则的目标是「有没有定义」,不是「是不是解析到了更合适的定义」。因此只要 <script setup> 里存在 popupStatusPopupStatus 这种可匹配名字,它就倾向于认为 <popup-status> 有定义。它不会继续判断「这个定义是组件 import,还是普通状态对象」。

vue/no-template-shadow 也包不住。它检查的是模板局部变量,例如 v-for="item in list" 里的 item 是否遮蔽了外层变量;本文这个冲突发生在 <script setup> 顶层绑定和组件 tag 解析之间,不是模板局部作用域声明。

问题恰好藏在 Vue 编译器更细的解析优先级里:<popup-status> 会同时尝试 popupStatusPopupStatus,并且 setup-const 会先于 setup-maybe-ref 命中。现成规则没有模拟这一层 resolveSetupReference 的优先级,所以漏掉了。

vue-tsc 也不能作为这类问题的保险。我用同样的最小复现跑过当前项目同代版本的 vue-tsc --noEmit,结果没有报错。这个现象也合理:类型检查看到的是 Vue 模板生成后的类型上下文,而不是开发者「原本想让 <popup-status> 命中哪个组件」的意图。

其他知名工具目前也不适合作为主要兜底:

Oxlint 的插件兼容说明 里,vue 标记为不完整支持,说明它只覆盖能作用在 script tag 上的部分 eslint-plugin-vue 规则。这个问题需要同时理解 template tag、<script setup> 顶层绑定和 Vue 编译器解析优先级,不能只靠 script 规则完成。

Biome 的语言支持说明 提到 Vue / Svelte / Astro 这类文件存在 HTML 侧支持边界。即使工具能解析 .vue 文件,也不等于已经实现了 Vue 编译器级别的组件解析冲突检查。

所以当前更现实的结论是:官方 Vue ESLint 规则、vue-tsc、Oxlint、Biome 都不应该被当成这次问题的现成防线。

这并不代表静态检查做不到。更准确的结论是:通用 ESLint + 官方 Vue 推荐规则暂时抓不到,但可以写一条项目内 ESLint 自定义规则专门抓。

这条规则的思路并不复杂:

1
2
3
4
5
6
7
1. 只检查 <script setup> 的 SFC。
2. 收集顶层 import 进来的组件名,例如 PopupStatus。
3. 收集顶层变量、解构变量、函数名,例如 popupStatus。
4. 读取 template 里的组件 tag,例如 popup-status。
5. 按 Vue 编译器同样的规则生成候选名:
popup-status -> popupStatus -> PopupStatus。
6. 如果同一个 tag 同时命中组件绑定和非组件绑定,并且非组件绑定会更早被解析,就报错。

报错信息可以直接写成:

1
2
3
<popup-status> 同时匹配到组件 PopupStatus 和状态 popupStatus。
Vue 编译器会优先解析 popupStatus,组件可能不会渲染。
请把状态改名为 popupFeedback,或给组件 import 起别名。

所以,如果只是为了这次问题本身,单纯引入 ESLint 还不够;还需要补一条本地规则。测试可以作为本次具体回归的补充手段,但它不适合当成通用解决方案:总不能给每个组件都写一条「它真的渲染出来了」的测试。

但如果项目本来已经在走 Vue + TypeScript + 多组件协作,引入 ESLint 仍然是有价值的。它可以把很多更常见的问题提前拦住,例如未定义组件、未使用组件、模板变量遮蔽、废弃写法、错误的 Vue API 使用方式。本文这个问题更像是提醒:工具链要分层,不要把「有 ESLint」误解成「所有编译器边界都能被现成规则发现」。

后续我要做什么

这个问题已经可以整理成上游贡献,不只是停留在规避经验里。后续我会按三条线推进。

第一条线是 eslint-plugin-vue。这是最合适的落点,因为问题本质上是「静态可发现的高风险命名冲突」,而且 eslint-plugin-vue 已经能同时读取 template 和 <script setup> 作用域。

我会先准备最小复现和规则设计,再开 issue 说明为什么现有 vue/no-undef-componentsvue/no-unused-componentsvue/no-template-shadow 包不住它。规则不应该塞进 vue/no-undef-components,因为这里不是 undefined component,而是 ambiguous / shadowing。更自然的方向是新增一条规则,例如 vue/no-setup-component-name-conflict

规则设计可以先按这个纲领走:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
1. 只检查 <script setup>。
2. 收集顶层 import,识别组件候选:
- .vue default import。
- PascalCase import。
- defineAsyncComponent 返回值。
- 其他可通过配置补充的组件工厂。
3. 收集普通顶层绑定:
- const / let / function / class。
- 解构变量。
- composable 返回值解构。
4. 遍历 template 里的组件 tag,生成 raw / camel / Pascal 候选名。
5. 模拟 Vue compiler-core 的解析顺序:
- 先按绑定类型分组。
- 同一组内按 raw -> camel -> Pascal 检查。
6. 如果同一个 tag 同时命中组件候选和非组件候选,并且非组件候选会先命中,就 report。
7. 默认不 autofix,只给 suggest:
- 重命名状态变量。
- 或给组件 import 起别名。
8. 对命名空间组件、显式组件变量、动态组件、无法判断类型的复杂表达式保守跳过,降低误报。

第二条线是 Vue 官方文档。文档可以在 <script setup> 的 Using Components 附近补一个 warning,重点不是说「Vue 有 bug」,而是把心智模型讲清楚:

1
2
Imported components and local bindings share the same template scope.
Avoid declaring local bindings whose camelCase / PascalCase names overlap with component tags.

第三条线是 Vue core。这里要更谨慎。这个现象发生在 compiler-core 的模板转换阶段,不是 compiler-sfc 独自能解决的问题。编译器如果要加开发态 warning,需要额外判断「哪个绑定是组件候选,哪个绑定是普通状态」,还要处理普通变量本来就是组件、defineAsyncComponent、命名空间组件、动态组件等合法场景。

我不会一上来给 Vue core 提改解析优先级的 PR。更稳的路线是先开 issue / discussion,把最小复现、当前编译产物、运行时 warning、eslint-plugin-vue 规则方案都放进去,请维护者判断它更适合放在 compiler warning、文档 warning,还是 lint 规则里。

如果最后确实要动 Vue core,我也只会先尝试开发态 warning,不会改解析行为。解析优先级属于兼容性敏感区域,不能因为一个边界问题直接改行为。

结论

<script setup> 里,模板可以直接访问顶层绑定。这个能力包括普通变量、函数、import,也包括导入的组件。

当模板里写 <popup-status> 时,Vue 编译器会尝试从 setup 绑定里解析它。它会检查 popup-statuspopupStatusPopupStatus 这些候选名,并且按绑定类型分优先级。普通 const popupStatus 可能比 import 进来的 PopupStatus 更早命中。

所以这类命名会很危险:

1
2
import { PopupStatus } from './components';
const popupStatus = ref(...);

更稳的写法是把状态命名成它真实承担的业务含义:

1
2
import { PopupStatus } from './components';
const popupFeedback = ref(...);

这个问题最值得记住的不是「不要叫 popupStatus」,而是:<script setup> 的模板不是在运行时按字符串注册表找组件,它是在编译阶段把 tag 解析成当前 setup 作用域里的绑定。只要接受这个模型,很多看起来奇怪的现象都会变得可解释。