跳到正文

Vue script setup 中组件静默消失的原因:一次命名冲突排查(2):顶层绑定怎样参与模板名字解析

Vue script setup 中组件静默消失的原因:一次命名冲突排查 · 1 · 第 2 篇(当前) · 3

模板中的 popup-status 本来应渲染 PopupStatus 组件,同一个 script setup 里却还有 popupStatus 状态对象。Vue 3.5.34 的编译器先按绑定类型分组,再依次尝试原名、camelCase 和 PascalCase;较早命中的状态对象因此成为组件引用。

下面复用同一组名字,追踪 compiler-sfc 提供绑定元数据、compiler-core 生成 render 引用的过程。分析固定在文中源码版本;Vue 3.0、Vue 2.7 与早期第三方方言不能套用同一套结论,版本对照见故障定位篇。

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

compileTemplate 根据顶层绑定的分类和候选名顺序,决定 <popup-status> 引用哪个变量。script 和 template 的编译通过下面这条调用链衔接:

script 与 template 怎样通过绑定元数据衔接

@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 合并。下面只用整行注释省略不相关分支,保留源码原本的结构。

绑定元数据合并部分如下;顶层声明遍历与其他宏处理保留在完整节选中。

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]
}

完整上下文见绑定分析源码节选弹窗。

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

  • 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。

import 的来源和形态决定绑定分类

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

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

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

这里只截取字面量、reactive 与可确定不是 Ref 的 const 分支;后续 Ref、解构和 enum 分支见完整节选。

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
}

其他分支见完整绑定分类弹窗。

BindingTypes 是编译器记录在 bindingMetadata 里的绑定分类,描述它能否确定这个值是 ref、绑定是否可能被重新赋值,以及值能否静态处理。它不是 JavaScript 变量声明关键字的直接映射:const count = ref(0) 会归为 SETUP_REF,普通调用 const value = useFoo() 则是 SETUP_MAYBE_REF。

Vue script setup 中三个 CONST 类型、REF、MAYBE_REF、LET 与 import 的分类对照

图中对应 Vue 3.5.34 的常见分类,定义见 BindingTypes 枚举,判定条件见 walkDeclaration()。字面量示例以只有 <script setup> 且开启 hoistStatic 为前提;关闭静态提升后,图中的 label 和 n 会归为 SETUP_CONST。

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

写法 分类 原因
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 成员都静态时可提升,否则按稳定绑定处理

同样写 const,分类看的是右侧表达式

回到这个例子:

import { PopupStatus } from './components';

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

而本地变量这边:

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

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

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

{
  "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。

同一绑定类型下先检查原名、camelCase,再检查 PascalCase。下面保留名字检查与 const 返回的节选,Ref 和 props 的回退分支放在材料中。

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)}]`
}

完整函数见组件候选解析源码弹窗。

这里有两层顺序。

第一层是绑定类型顺序。SETUP_CONST、SETUP_REACTIVE_CONST、LITERAL_CONST 这一组在 fromConst 里先检查;SETUP_LET、SETUP_REF、SETUP_MAYBE_REF 这一组在 fromMaybeRef 里后检查;PROPS 最后在 fromProps 里检查。

第二层是同一个类型内部的候选名顺序。<popup-status> 对应的候选名依次是 popup-status、popupStatus、PopupStatus。

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

按绑定类型的检查顺序,逐个代入原名、camelCase 和 PascalCase 候选,本例的查找过程如下:

绑定类型逐种检查,每种内部按原名 camelCase PascalCase 查找,命中 popupStatus 后停止

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

图中灰色项表示本例在前面命中后没有继续检查;const-like 内部的各个 BindingTypes 也有先后顺序。

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:

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()。

启用 SFC 的 inlineTemplate 时,compileScript() 会将模板编译器的 compilerOptions.inline 设为 true;普通 Vite 项目默认由 @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 组就不会再继续检查。

同一份绑定,inline 改变 render 的访问路径

再往外看一层,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() 是两条不同路径。

组件 tag 先按外层解析顺序查找;进入 setup 绑定分支后,再按内部的绑定类型分组查找:

组件 tag 的逐级解析与 setup 绑定优先级

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

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

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

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

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

_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 编译阶段允许 popupStatus 和 PopupStatus 这两个合法的顶层绑定共存,并按既定顺序为 <popup-status> 选中一个。compiler-core 没有将多候选命中识别成冲突,也不知道开发者原本想渲染的是组件 PopupStatus。

等到运行时,选中的状态对象缺少模板和 render 函数,Vue 才给出对应 warning。这条提示说明的是对象无法渲染,却没有指出名字解析已经命中了另一个绑定,排查时还需要回到编译产物。

组件之外还有没有同类问题

判断这类问题时,先看同一个模板名字是否会进入 resolveSetupReference(),并按 raw / camel / Pascal 去 <script setup> 顶层绑定里找候选。按这个标准看,真正同源的是自定义指令。

<template>
  <input v-popup-status />
</template>

<script setup lang="ts">
import { VPopupStatus } from './directives'

const vPopupStatus = {
  mounted() {},
}
</script>

Vue compiler-core 的 buildDirectiveArgs() 会把自定义指令名拼成 v- + dir.name,再交给 resolveSetupReference()。对 v-popup-status 来说,候选名就是 v-popup-status、vPopupStatus、VPopupStatus。如果同一个 SFC 里同时有 vPopupStatus 和 VPopupStatus,当前解析会先命中 vPopupStatus。

这个场景比组件 tag 少见,因为局部指令本来就推荐写成 vFocus 这种 lower camel 形态;但它和组件 tag 共享同一段编译器机制,Lint 规则把它纳入检查是合理的。

静态事件名不属于这一类:

<Child @save-user="saveUser" />

save-user 会被转成 prop key onSaveUser;真正进入表达式作用域解析的是右侧 handler 表达式 saveUser。即使同一个 SFC 里存在 saveUser 和 SaveUser,事件名本身也不会去 setup 里找这两个候选。@[eventName] 这种动态事件名本来就是表达式,风险属于普通变量命名和类型约束,不属于组件 tag 这类归一化候选冲突。

props / attrs / v-model 参数、slot 名、template ref 也要分开看。它们可能有大小写规范或运行时归一化,例如 prop key 匹配和 v-model:foo-bar 生成 onUpdate:fooBar,但它们不是「同一个模板名字在 setup 顶层绑定里查多个候选」。因此这条规则适合覆盖组件 tag 和自定义指令,不适合把事件名、props、slot、ref 都塞进去。

总结

compileScript 收集 import 与顶层声明,形成 bindingMetadata;compileTemplate 再把这份分类交给 compiler-core。组件标签进入 setup 绑定解析后,先按绑定组查找,每组内部按原名、camelCase、PascalCase 尝试,命中后停止。

把组件改为直接 .vue 默认导入,只会改变绑定分组,不能消除组内的候选名冲突。自定义指令也进入同一条解析链,事件名、props 参数、slot 和 ref 则要按各自机制判断;检测规则应与这些实际分支保持一致。

上一篇:从静默消失定位到命名冲突。下一篇:检测组件与指令的绑定冲突。

绑定登记与分类的源码节选
// ...省略 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 生成。

walkDeclaration 的完整节选

// ...省略函数参数类型、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') {
        let bindingType: BindingTypes
        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
}
组件候选解析的源码节选
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)}])`
  }
}