Vue script setup 中组件静默消失的原因:一次命名冲突排查
Vue 3 的 <script setup> 很好用,但它也会把一些原本显式的东西变成编译器规则。平时这让代码更短,出问题时也会让排查路径变得很绕。
这篇文章记录一个真实排查:页面里一个组件没有渲染出来,控制台没有明显 error,最后发现原因是 <script setup> 里的普通变量名和组件名只差大小写,模板编译时把组件 tag 解析到了错误的变量上。
问题本身不复杂,但如果只看源码,很难第一时间想到它。真正的关键在编译产物和 Vue 编译器的组件解析规则里。
现象
有一个 Popup 页面,底部应该渲染一个反馈组件:
1 | |
组件本身从 barrel 里导入:
1 | |
同一个 <script setup> 里又从页面流程 composable 解构出一个状态对象:
1 | |
页面运行后,底部反馈文案没有了。奇怪的是控制台没有明显 error,页面其他部分也能正常工作。
这就很容易把排查方向带偏:是不是样式把它盖住了,是不是 v-if 没命中,是不是组件导出错了,是不是构建产物没更新。真正原因藏在 <popup-status> 这个 tag 的解析结果里。
修复方式
最终修复很简单:把状态对象从 popupStatus 改成不会和组件名冲突的 popupFeedback。
1 | |
对应脚本也改成:
1 | |
改完之后,底部提示恢复显示。这个结果基本可以反证:问题确实是组件名和变量名冲突。
但只知道「改名能修」还不够。更重要的问题是:为什么 Vue 会把一个普通对象当成组件,而且为什么没有直接报错?
文档里写了什么
Vue 官方文档其实已经写了几条相关规则,只是没有把这次这个边界完整展开。
Vue
<script setup>文档 说明,<script setup>里的顶层绑定会暴露给 template。这里的顶层绑定包括变量、函数和 import。
也就是说,下面这个变量可以直接在模板里用:
1 | |
import 进来的函数也可以直接用:
1 | |
组件也是同一套心智模型。
Vue
<script setup>的 Using Components 说明,<script setup>里的组件可以直接作为自定义组件 tag 使用。文档里还特别提醒:可以把MyComponent理解成一个变量引用;<my-component>这种 kebab-case 等价写法也可以工作,但更推荐使用 PascalCase。
例如:
1 | |
或者:
1 | |
Vue 组件注册文档里的 Component Name Casing 也说明,Vue 支持把 kebab-case tag 解析到 PascalCase 注册名或组件名上。
这些文档足够告诉我们两件事:
<script setup>的模板能看到顶层变量和 import。<popup-status>有机会解析到PopupStatus。
但文档没有明确说清楚一个细节:如果同一个 SFC 里同时存在 PopupStatus 和 popupStatus,<popup-status> 会先命中谁。
这个优先级,就需要看编译产物和源码。
最小复现
先写一个最小化 SFC。
1 | |
直觉上,<popup-status> 应该对应导入的 PopupStatus 组件,:message 和 :type 读取本地状态对象 popupStatus。
实际不是。
可以用 vue/compiler-sfc 做一个最小编译实验:
1 | |
先看 compiledScript。它不是原来的 <script> 标签,而是 compileScript() 返回的「script 编译结果」对象。这个对象里不止有两个字段,例如还会有 attrs、imports、map、scriptAst、scriptSetupAst、deps 等信息;这里只关注和当前问题直接相关的两个字段:
compiledScript.bindings是给编译器继续使用的元数据,描述每个顶层绑定是什么类型。compiledScript.content是一个「内容为代码的字符串」,也就是compileScript()生成出来、后续会交给打包器继续处理并输出到产物里的组件代码。
第一个 console.log(compiledScript.bindings) 的输出是:
1 | |
第二个 console.log(compiledScript.content) 的输出是一段代码字符串。为了阅读方便,这里直接按代码格式展示:
1 | |
这段产物先说明一件事:<script setup> 并不是浏览器直接执行的语法。它会被 compileScript 改成一个标准 defineComponent 组件,原来的顶层变量会放进 setup(),最后通过 __returned__ 暴露给模板。
所以 bindings 和 __returned__ 是对应的:
1 | |
接着看 template。它是 compileTemplate() 返回的「template 编译结果」对象,也不只有 code,还包括 ast、tips、errors、map 等信息。当前只关注 template.code,因为它能直接看出 <popup-status> 最终被编译成了什么。
第三个 console.log(template.code) 的输出是:
1 | |
看到这里,可能会冒出另一个问题:compiledScript.content 里有 import 和 export default,template.code 里也有 import 和 export function render,它们看起来像两个独立模块。真实项目里怎么会变成一个 .vue 文件的最终产物?
答案是:compileScript() 和 compileTemplate() 是偏底层的编译 API,它们各自返回「模块形态的代码片段」,方便构建工具继续处理;真实项目里通常还有一层构建插件负责组装。以 Vite 为例,这一步主要由 @vitejs/plugin-vue 做。
@vitejs/plugin-vue的transformMain会先生成 script 和 template 两段代码,并把 template 的 render 记录成要挂到组件上的属性。随后它把这些代码片段放进同一个output数组里,最后通过_export_sfc(_sfc_main, [['render', _sfc_render]])导出最终组件。
为了避免一个模块里出现两个顶层导出,插件会做两类改写。
第一类是把 script 的默认导出改成内部变量。@vitejs/plugin-vue 里有一个固定的组件变量名 _sfc_main,compileScript() 在合适的时候会直接生成 const _sfc_main = ...;否则插件也会用 rewriteDefault() 把默认导出改写成这个变量。
scriptIdentifier就是_sfc_main。这一步的目的很直接:先拿到组件对象,但暂时不要export default。
第二类是把 template 的具名导出改成内部 render 函数。compileTemplate() 原本输出的是 export function render(...),而 Vite 插件在把 template 合进主模块时,会把它改成 function _sfc_render(...)。
transformTemplateInMain做的核心事情就是把export function render或export const render改成_sfc_render这种内部名字。
用伪代码表示,最终合成的模块大概长这样。这段不是为了还原 @vitejs/plugin-vue 的源码细节,只是帮助读者快速抓住 script、template 和最终默认导出之间的关系,不对应某一个源码文件:
1 | |
这段代码不是逐字逐行的真实产物,而是把关键结构压缩出来后的示意。真正要看的重点是:最终模块只有一个默认导出;compiledScript.content 贡献的是组件主体 _sfc_main,template.code 贡献的是渲染函数 _sfc_render,构建插件把后者挂回前者,再导出完整组件。
这里不用深入理解 Vue 的内部优化,只要抓一个点:openBlock 是打开一个 block,createBlock 是在里面创建一个有意义的渲染块。createBlock 的第一个参数就是要渲染的组件类型或标签类型。
也就是说,这一句最关键:
1 | |
这里一定要分清两个阶段。
$setup 是 render 函数运行时收到的参数,可以粗略理解成前面 setup() 返回给模板使用的对象。它的具体值要等组件运行起来以后才有。
但 ["popupStatus"] 不是运行时动态决定的。它已经在编译阶段被写死进 render 函数了。也就是说,真正的问题发生在编译阶段:模板编译器已经决定 <popup-status> 要读取 $setup["popupStatus"],运行时只是照着这段代码执行。
前面的 __returned__ 里同时有 popupStatus 和 PopupStatus。一旦编译产物固定成 $setup["popupStatus"],运行时即使 $setup 里也存在真正的组件 PopupStatus,这里也不会再去重新选择它。
Vue 没有把 <popup-status> 编译成 $setup["PopupStatus"],而是编译成了 $setup["popupStatus"]。
换句话说,它把这个普通对象当成了组件:
1 | |
改名后的产物
把状态对象改成 popupFeedback:
1 | |
再编译一次,结构会变成这样:
1 | |
这次 __returned__ 里没有 popupStatus,<popup-status> 的候选名只会命中 PopupStatus。所以 render 函数里变成了正确结果:
1 | |
为什么会优先命中普通变量
读到这里,真正要追的问题已经很明确了:为什么 compileTemplate 会把 <popup-status> 绑定到 $setup["popupStatus"],而不是 $setup["PopupStatus"]?
把前面的产物连起来看,调用链大概是这样:
1 | |
@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 怎么把 popupStatus 和 PopupStatus 分到不同类型,再看 compiler-core 怎么按这些类型和候选名做查找。
变量先被归类
先看分类从哪里来。这里先抓住两个来源:PopupStatus 来自顶层 import,popupStatus 来自 <script setup> 里的本地变量。它们会先进入同一份 bindingMetadata,随后才会被归到不同类型里。
compileScript() 会先收集 <script> 和 <script setup> 里的 import,处理普通 <script>、<script setup> 和编译宏,完成 props 解构转换、宏参数作用域检查,并移除非 script 内容。随后进入绑定分析阶段,把前面收集到的 userImports、scriptBindings、setupBindings 合并成 bindingMetadata。
相关源码都在 Vue 3.5.34 的
packages/compiler-sfc/src/compileScript.ts里:scriptBindings/setupBindings初始化、walkDeclaration()写入普通<script>绑定、walkDeclaration()写入<script setup>绑定、bindingMetadata合并。下面只用整行注释省略不相关分支,保留源码原本的结构。
1 | |
这段里最重要的是两条分类入口:
- import 会在
ctx.userImports这一段直接分类,并写进ctx.bindingMetadata。 - 除 import 之外的顶层定义会先交给
walkDeclaration()分类,结果写进scriptBindings或setupBindings,最后再合并进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 | |
落到常见写法上,可以粗略这样看:
| 写法 | 分类 | 原因 |
|---|---|---|
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 | |
这里的 imported 是 PopupStatus,source 是 ./components。它不是命名空间 import,不是从 .vue 文件 default import,也不是从 vue 包导入,所以会进入最后的 SETUP_MAYBE_REF。
而本地变量这边:
1 | |
它是普通对象字面量,不可能是 ref,所以会被 walkDeclaration() 归到 SETUP_CONST。
于是当前例子的 bindingMetadata 会变成这样:
1 | |
到这里还没有发生错误。它只是把两个顶层绑定分到了不同类型里。
分类再参与解析
真正把 <popup-status> 解析成 $setup["popupStatus"] 的,是 @vue/compiler-core 里的 resolveSetupReference()。它在 Vue 3.5.34 的 packages/compiler-core/src/transforms/transformElement.ts 里,源码不长,可以直接看完整结构:
1 | |
这里有两层顺序。
第一层是绑定类型顺序。SETUP_CONST、SETUP_REACTIVE_CONST、LITERAL_CONST 这一组在 fromConst 里先检查;SETUP_LET、SETUP_REF、SETUP_MAYBE_REF 这一组在 fromMaybeRef 里后检查;PROPS 最后在 fromProps 里检查。
第二层是同一个类型内部的候选名顺序。<popup-status> 会被转换成多个候选名字:
1 | |
检查单个类型时,Vue 会按「原名 -> camelCase -> PascalCase」的顺序查。对 <popup-status> 来说,原名 popup-status 通常不会命中,第二个候选 popupStatus 会先于第三个候选 PopupStatus 被检查。
现在把这两层顺序和前面的分类放在一起看:
1 | |
所以 <popup-status> 会先命中 popupStatus,而不是 PopupStatus。
这里顺手解释一个细节:fromConst、fromMaybeRef 和 fromProps 分开,不只是为了排序,也是在服务不同的代码生成方式。当前实验产物是非 inline template 模式,所以 fromConst 和 fromMaybeRef 最后都会生成 $setup[...];源码仍然保留两个分支,是因为 inline template 模式下,可能是 ref 的绑定需要显式生成 unref(xxx)。
严格说,这里有两个相邻名字:对外的 SFC 编译选项叫
inlineTemplate,compiler-core 里真正影响resolveSetupReference()的选项叫inline。compileScript()打开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
2compileScript(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里映射到字符串unref。context.helperString(UNREF)会先登记这个 helper,再返回生成代码里使用的名字_unref,最终对应import { unref as _unref } from 'vue'这类产物。
回到当前问题,inline 差异不是根因,只是解释了为什么源码需要分成不同返回分支。真正影响结果的仍然是分组顺序:setup-const 组先查到 popupStatus 以后,后面的 setup-maybe-ref 组和 props 组就不会再继续检查。
再往外看一层,resolveSetupReference() 也不是组件 tag 的全部解析逻辑。resolveComponentType() 会先处理动态组件和内置组件,再尝试从 setup 绑定里解析;这里的 setup 绑定包括 SETUP_CONST、SETUP_LET、SETUP_REF、SETUP_MAYBE_REF 这几类,也包括 PROPS。如果这些都没命中,才会继续走文件名推断出来的自引用组件,最后生成 resolveComponent(tag),交给运行时去查局部注册和全局注册的组件。
这条外层顺序在
resolveComponentType()里。运行时局部 / 全局组件查询在resolveAsset()里。
所以,全局组件不是靠 bindingMetadata 判断的。它们通常会走最后的运行时解析分支:编译产物里出现 resolveComponent("xxx"),运行时再从当前组件的局部 components 和 appContext.components 里按原名、camelCase、PascalCase 查找。
全局函数又是另一条链路。模板表达式里的函数调用由表达式转换处理;如果名字能在 setup 绑定或 props 里找到,就按对应绑定类型生成访问代码。如果找不到,它会落到 _ctx.xxx 这类实例上下文访问上,再由运行时组件代理去解析。它不属于组件 tag 的 resolveComponentType() 这条链路。
表达式里的变量访问逻辑在
transformExpression.ts里;它和组件 tag 的resolveComponentType()是两条不同路径。
这样就能回答几个相邻的冲突场景。先整理成一个更容易记的优先级模型:
1 | |
第一,如果是 prop 和导入组件同名,通常不会抢过导入组件。例如同时存在 import { PopupStatus } ... 和 defineProps<{ popupStatus: unknown }>(),<popup-status> 会先在 fromMaybeRef 里命中 PopupStatus,不会走到最后的 fromProps。
第二,如果没有导入组件,只有一个同名 prop,情况就不同。popupStatus 作为 PROPS 会参与组件 tag 解析,而且它会早于运行时注册组件解析命中。此时 <popup-status> 会被编译成类似:
1 | |
这意味着 prop 也可能挡住运行时注册组件,包括全局组件。运行时注册组件只有在 setup 绑定、props、自引用组件都没命中时,才会进入 resolveComponent("popup-status") 这条解析链路。
第三,如果说的是 app.config.globalProperties 上挂的全局函数或全局变量,它不走组件 tag 的解析路径,而是走模板表达式解析。表达式解析里的大方向也类似:本地 setup 绑定和 props 先参与匹配,全局属性最后才通过组件实例上下文兜底。它出现在模板表达式里时,例如 {{ popupStatus() }},找不到本地 setup 绑定或 prop 时才会落到:
1 | |
所以全局函数 / 全局变量的冲突重点不在 <popup-status> 这种组件 tag,而在模板表达式名。只要本地 setup 绑定或 prop 有同名标识符,就会优先按本地绑定处理,不会优先访问 _ctx 上的全局属性。
即使改成直接从 .vue 文件 default import,结果也还是不对。这样只会让 PopupStatus 也进入 setup-const 这一组,但同一组类型内部仍然会先检查 camelCase 的 popupStatus,再检查 PascalCase 的 PopupStatus。只要本地还存在 const popupStatus = ...,<popup-status> 仍然会先命中普通对象。
也就是说,直接 .vue default import 只解决了「类型分组」差异,没有解决「候选名顺序」冲突。真正要修的是命名冲突本身。
这就是组件静默消失的根因。
这里还有一个容易误会的点:Vue 编译阶段并没有把它当成「冲突」处理。从编译器视角看,popupStatus 和 PopupStatus 都是合法顶层绑定,<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_CONST、SETUP_REACTIVE_CONST和LITERAL_CONST;Vue 3.1 / 3.2 的resolveSetupReference()只检查单个SETUP_CONST,所以表格里单独写SETUP_CONST。对当前案例来说,最重要的是 import 和 const 的相对顺序。从 Vue 3.1 开始,
resolveSetupReference()已经按「先 const / 后 maybe-ref」处理,因此const popupStatus比import { 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.1、Vue 3.3、Vue 3.5 的
resolveSetupReference(),Vue 2.7 的checkBindingType(),以及unplugin-vue2-script-setup@0.11.4的transformScriptSetup()。
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 | |
对应源码在
transform.ts。这说明它不是让 Vue 2 编译器原生理解<script setup>,而是在 Vue 2 编译器前面先做一次源码转译。
它还会先从模板里收集组件 tag。<popup-status> 会被转成 PopupStatus,放进 template.components。
1 | |
随后 transformScriptSetup() 会用这个组件名去顶层声明里找匹配项:
1 | |
这段顺序很关键。假设代码里同时有:
1 | |
<popup-status> 对应的组件名是 PopupStatus。插件会先用 declare === component 精确命中导入的 PopupStatus,只有精确找不到时,才会尝试 pascalize(popupStatus)。因此在这个组合里,popupStatus 这个普通状态对象不会抢走组件 tag。
最后插件会把命中的组件注入普通组件选项,生成效果类似:
1 | |
对应源码在
parseSFC.ts和transformScriptSetup.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 | |
这个对象没有 setup、没有 render、没有 template。在开发环境里,Vue 会给出 warning:
1 | |
但它不是 error。生产构建里,warning 通常会被移除或不再输出。于是页面最终只会渲染出一个空注释节点:
1 | |
这就是为什么问题表面上非常安静:没有红色 error,没有白屏,其他组件还在正常工作,只是某一块 UI 消失了。
可以用一个运行时最小例子模拟:
1 | |
冲突版渲染结果是:
1 | |
正确版渲染结果是:
1 | |
如果开发环境 warning 被大量日志淹没,或者项目加载的是生产构建,这个问题就很容易被误判成样式、条件渲染或数据问题。
这和显式组件注册有什么不同
非 <script setup> 的普通组件写法里,组件可以显式写在 components 选项里,状态从 setup() 返回:
1 | |
这种写法里,组件注册名和 setup() 返回对象虽然仍然可能引起阅读混乱,但开发者至少能清楚看到「组件注册表」和「模板状态」是两块东西。
对 <popup-status> 这种静态组件 tag 来说,components 里的 PopupStatus 会优先于 setup() 返回的 popupStatus。更准确地说,普通组件写法不会让组件 tag 走 <script setup> 的 resolveSetupReference():普通 <script> 的 bindingMetadata 会被标记成 __isScriptSetup: false,模板编译会生成 resolveComponent("popup-status"),运行时再去查当前组件的 components 和全局 app.component。
1 | |
所以这里不是 components.PopupStatus 和 setup().popupStatus 比谁优先,而是静态组件 tag 根本不走 setup() 返回对象这条解析路径。setup() 返回的 popupStatus 仍然用于 :message="popupStatus.message" 这类模板表达式。
这个结论只针对静态组件 tag。若写成
<component :is="popupStatus">,或者在 render 函数里直接h(popupStatus),那就是表达式 / 运行时值路径,setup()返回值当然会参与。相关源码可以看两处:普通
<script>的绑定分析会标记__isScriptSetup: false,resolveSetupReference()遇到这个标记会直接跳过;运行时组件解析则在resolveAsset()里先查局部注册,再查全局注册。
<script setup> 的机制不同。它把顶层绑定整体暴露给模板,组件 import 也是绑定,普通变量也是绑定。模板里的组件 tag 也会走这套绑定解析逻辑。
这也是为什么这个问题不容易靠直觉排出来:在 <script setup> 里,组件名不是一个单独的注册表 key,它本质上也是一个变量引用。
怎么规避
第一,组件名和状态名不要只差大小写或 kebab/camel 形态。
1 | |
推荐改成更具体的业务名:
1 | |
或者:
1 | |
第二,业务状态名不要为了贴近组件名而重复组件名。组件叫 PopupStatus,状态不一定也要叫 popupStatus。状态应该描述它在业务里的含义,例如反馈、写入结果、操作提示、错误消息。
第三,如果必须保留某个状态名,也可以给组件 import 起别名:
1 | |
模板里使用:
1 | |
不过这通常不如改状态名自然。因为状态名 popupStatus 本身也偏宽,只说明它给 Popup 用,没有说明它承载的是反馈文案、写入结果还是页面状态。
第四,遇到组件无声消失时,不要只查样式和条件。可以直接看编译产物:
1 | |
如果这里不是预期组件名,就说明模板 tag 已经解析错了。
静态检查能不能发现
理论上可以,现实里要分两层看。
eslint-plugin-vue官方说明里写得很清楚:它可以检查.vue文件里的<template>和<script>。这意味着它具备同时看模板和脚本的基础能力,并不是只能查普通 TypeScript 文件。
它也确实有一些看起来相关的规则。
vue/no-undef-components会检查模板里使用了但没有在<script setup>或 Options APIcomponents里定义的组件。
vue/no-unused-components会检查注册后没有在模板里使用的组件。
vue/no-template-shadow会检查模板局部变量遮蔽外层变量,例如v-for变量名和外层作用域冲突。
但这些规则并不能直接抓出本文这个问题。
可以用最小样例验证。这个例子里,PopupStatus 是组件,popupStatus 是状态:
1 | |
即使打开这些规则:
1 | |
实际结果也不会报 PopupStatus 和 popupStatus 冲突。
原因是站在普通 ESLint 规则的视角看,这段代码似乎都是「合理」的:
<popup-status>能找到一个叫PopupStatus的导入组件,所以它不是未定义组件。PopupStatus看起来被模板使用了,所以它不是未使用组件。popupStatus被:message和:type用到了,所以它也不是未使用变量。popupStatus和PopupStatus是两个不同的 JavaScript 标识符,所以普通重复 key 或 shadow 规则也不会认为它们冲突。
vue/no-undef-components 的源码更能说明这个边界。
vue/no-undef-components的 GitHub 源码 里有一个DefinedInSetupComponents。它会收集<script setup>里的变量名,再用接近 Vue 的rawName -> camelName -> pascalName逻辑判断组件是否已定义。
这条规则的目标是「有没有定义」,不是「是不是解析到了更合适的定义」。因此只要 <script setup> 里存在 popupStatus 或 PopupStatus 这种可匹配名字,它就倾向于认为 <popup-status> 有定义。它不会继续判断「这个定义是组件 import,还是普通状态对象」。
vue/no-template-shadow 也包不住。它检查的是模板局部变量,例如 v-for="item in list" 里的 item 是否遮蔽了外层变量;本文这个冲突发生在 <script setup> 顶层绑定和组件 tag 解析之间,不是模板局部作用域声明。
问题恰好藏在 Vue 编译器更细的解析优先级里:<popup-status> 会同时尝试 popupStatus 和 PopupStatus,并且 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 | |
报错信息可以直接写成:
1 | |
所以,如果只是为了这次问题本身,单纯引入 ESLint 还不够;还需要补一条本地规则。测试可以作为本次具体回归的补充手段,但它不适合当成通用解决方案:总不能给每个组件都写一条「它真的渲染出来了」的测试。
但如果项目本来已经在走 Vue + TypeScript + 多组件协作,引入 ESLint 仍然是有价值的。它可以把很多更常见的问题提前拦住,例如未定义组件、未使用组件、模板变量遮蔽、废弃写法、错误的 Vue API 使用方式。本文这个问题更像是提醒:工具链要分层,不要把「有 ESLint」误解成「所有编译器边界都能被现成规则发现」。
后续我要做什么
这个问题已经可以整理成上游贡献,不只是停留在规避经验里。后续我会按三条线推进。
第一条线是 eslint-plugin-vue。这是最合适的落点,因为问题本质上是「静态可发现的高风险命名冲突」,而且 eslint-plugin-vue 已经能同时读取 template 和 <script setup> 作用域。
我会先准备最小复现和规则设计,再开 issue 说明为什么现有 vue/no-undef-components、vue/no-unused-components、vue/no-template-shadow 包不住它。规则不应该塞进 vue/no-undef-components,因为这里不是 undefined component,而是 ambiguous / shadowing。更自然的方向是新增一条规则,例如 vue/no-setup-component-name-conflict。
规则设计可以先按这个纲领走:
1 | |
第二条线是 Vue 官方文档。文档可以在 <script setup> 的 Using Components 附近补一个 warning,重点不是说「Vue 有 bug」,而是把心智模型讲清楚:
1 | |
第三条线是 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-status、popupStatus、PopupStatus 这些候选名,并且按绑定类型分优先级。普通 const popupStatus 可能比 import 进来的 PopupStatus 更早命中。
所以这类命名会很危险:
1 | |
更稳的写法是把状态命名成它真实承担的业务含义:
1 | |
这个问题最值得记住的不是「不要叫 popupStatus」,而是:<script setup> 的模板不是在运行时按字符串注册表找组件,它是在编译阶段把 tag 解析成当前 setup 作用域里的绑定。只要接受这个模型,很多看起来奇怪的现象都会变得可解释。