Vue script setup 中组件静默消失的原因:一次命名冲突排查(3):检测组件与指令的绑定冲突
Vue script setup 中组件静默消失的原因:一次命名冲突排查 · 1 · 2 · 第 3 篇(当前)
同一个 script setup 可以合法声明 PopupStatus 和 popupStatus,但模板中的 popup-status 可能先命中普通状态。编译器按照既定候选顺序生成代码,普通的未定义组件、未使用变量和作用域遮蔽检查因此未必报告。
这里集中讨论怎样检测组件与自定义指令的名字冲突。复现是组件与状态只差大小写,修复是让它们具有不同语义名称;编译器绑定分类和候选名顺序见顶层绑定怎样参与模板名字解析。
静态检查能不能发现
静态检查可以发现这类冲突,但现成规则和专门模拟编译器解析的规则,覆盖范围不同。
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 是状态:
<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>
即使打开这些规则:
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'
}
实际结果也不会报 PopupStatus 和 popupStatus 冲突。
这几条规则各自检查的条件都没有被触发:
<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 推荐规则暂时抓不到;需要补一条更贴近 Vue 编译器解析顺序的规则,专门模拟这个命名冲突。
规则的判断口径可以先收窄到这几类场景:
- 只检查
<script setup>的 SFC。 - 对组件 tag,收集顶层 import 进来的 PascalCase 名字,例如
PopupStatus,再收集顶层普通绑定,例如popupStatus,包括变量、解构变量、函数和类。 - 对自定义指令,收集
<script setup>顶层绑定,并把v-popup-status映射成v-popup-status -> vPopupStatus -> VPopupStatus这组候选名。 - 遍历 template 里的组件 tag 和非内置自定义指令名,模拟 Vue compiler-core 的解析顺序。
- 如果同一个模板名字同时命中多个候选,而且当前写法会稳定落到更可疑的绑定,就报错。
- 静态事件名、props / attrs、slot 名和 template ref 先不检查;它们不走同一条
resolveSetupReference()候选解析链路。
这条检查现在已经沉淀到 @sheng/eslint-plugin 里的 @sheng/vue-no-component-name-conflict。项目接入时优先看规则文档和插件的使用指南,不要再从这篇文章里手动复制旧源码。
规则源码的核心在 src/rules/vue-no-component-name-conflict/index.ts。script visitor 先收集三类顶层名字,分别是 import 进来的 PascalCase 组件、普通绑定和局部指令;template visitor 再处理 tag,把组件 tag 归一化成 raw -> camel -> Pascal 候选,把自定义指令归一化成 v-raw -> vCamel -> VPascal 候选。规则只在模板真正出现歧义名字且候选同时命中时报告。
这类冲突可以用专门的 Lint 规则持续检查,组件渲染测试则适合补充本次回归,不必给每个组件都写一条「它真的渲染出来了」的测试。
Vue + TypeScript + 多组件协作的项目仍然值得引入 ESLint。它能提前拦下未定义组件、未使用组件、模板变量遮蔽、废弃写法和错误的 Vue API 用法;这次问题提醒的是工具链分层:现成 ESLint 规则覆盖不了所有编译器边界。
规则页里保留了可编辑示例、接入配置、维护入口和测试位置。业务项目确认 vue-eslint-parser 配置正常后,可以考虑把这条规则从默认的 warn 覆写成 error,因为它命中时已经是稳定解析歧义,不只是代码风格偏好。
本地护栏之外
@sheng/eslint-plugin 先处理项目内的命名冲突。上游可以分别讨论 ESLint 规则、官方文档提醒和 Vue 编译诊断,它们解决的问题不同。
第一条线是 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 会更贴近问题。
这条规则的边界先压在最小复现能证明的范围里:
- 只检查
<script setup>。 - 收集顶层
import,识别组件候选:.vuedefault import。- PascalCase import。
defineAsyncComponent返回值。- 其他可通过配置补充的组件工厂。
- 收集普通顶层绑定:
const/let/function/class。- 解构变量。
- composable 返回值解构。
- 遍历 template 里的组件 tag,生成 raw / camel / Pascal 候选名;如果要覆盖自定义指令,对
v-xxx生成v-xxx / vXxx / VXxx候选名。 - 模拟 Vue compiler-core 的解析顺序:
- 按源码顺序逐种检查
BindingTypes,包括同一组里不同类型的先后顺序。 - 每种类型内部依次检查 raw、camel、Pascal,命中就停止。
- 按源码顺序逐种检查
- 如果同一个 tag 同时命中组件候选和非组件候选,并且非组件候选会先命中,就 report。
- 默认不 autofix,只给 suggest:
- 重命名状态变量。
- 或给组件 import 起别名。
- 对命名空间组件、显式组件变量、动态组件、无法判断类型的复杂表达式保守跳过,降低误报。
第二条线是 Vue 官方文档。文档可以在 <script setup> 的 Using Components 附近补一个 warning,把心智模型讲清楚:
Imported components and local bindings share the same template scope.
Avoid declaring local bindings whose camelCase / PascalCase names overlap with component tags.
第三条线是 Vue core。2026-08-15 核对到的 vuejs/core@a2b40db9 还没有解决这个问题;resolveSetupReference() 仍然按既有顺序返回第一个命中的 setup 绑定,也没有多候选诊断。
如果改 Vue core,不应该改解析优先级,也不应该让编译器猜「哪个 import 更像组件」。更稳的做法是只补诊断:同一个模板名字归一化后命中多个 setup 绑定时,继续按旧规则生成代码,但在开发场景把它作为 compiler error 抛给调用方,在构建场景保留 warning。Vite、Nuxt、Webpack 这类开发服务器本来就会把 compiler error 通过自己的 overlay 显示到浏览器前台;Vue core 不需要直接耦合某个 bundler 的 overlay 实现。
运行时 warning 可以作为后续单独优化,但它只能做兜底。当前运行时看到的是「一个普通对象被当成组件,但对象没有 render/template」,所以提示容易停在组件格式不对。更有帮助的文案可以提醒读者检查 <script setup> 里的组件 tag 是否解析到了同名普通绑定。不过,普通函数被误当成 functional component 时不一定会进入这个分支,因此上游诊断更适合优先放在 compiler-core。
自定义指令如果也进入 Vue core 诊断,resolveSetupReference() 的错误文案就不能写死成 Component tag ...,需要让调用点传入 component / directive 这类语义,或者改成更泛化的 template name 文案。事件、props、slot、ref 这些场景不走同一条解析链路,PR 文档里说明边界即可。
总结
这类检查需要同时理解模板名字与 script setup 顶层绑定,并模拟编译器真正使用的类型组和候选顺序。现成规则能判断有没有定义,却不一定判断最终选中了开发者意图中的那个定义;专门护栏补的是这个职责缺口。
项目规则、官方文档提醒与编译诊断可以分别推进,复杂表达式和无法确认的组件形态保守跳过。组件 tag 与自定义指令共用解析机制,事件、props、slot 和 ref 不应因为也涉及命名就一并纳入。
上一篇:顶层绑定怎样参与模板名字解析。