Vue 的 script setup 泛型组件到底解决了什么

在一次菜单组件改造里,我看到一个有点陌生的写法:

<!-- CommonActionMenuPopover.vue -->
<script setup lang="ts" generic="TKey extends string = string">

第一眼看,它很像某种 Vue 特有的黑魔法。再往下读会发现,它其实只是把 TypeScript 里最普通的泛型参数搬进了单文件组件:调用方传进来的 action key 是 replace | delete,组件内部选择某个 action 后,再通过 emit 把同一个 action 交回去。中间只要有一层组件把类型写成 string,后面的分支映射、事件回调和穷尽检查都会变钝

这篇文章想讲清楚三件事:

  • generic="..." 到底是 Vue 哪里的能力。
  • 它真正解决的是“同一个类型参数能不能穿过组件边界”。
  • 它在语言工具里大概怎么被解析成可检查的 TypeScript。
  • 多个泛型参数怎么写,以及一个完整组件例子该长什么样。
  • 为什么有些场景一旦用了泛型组件,中间子组件也要跟着写 generic

Vue 泛型组件的类型链路

它是 Vue 3.3 之后的 SFC 语法

generic 是 Vue 单文件组件里 <script setup> 的一个编译期属性,不是运行时 prop,也不会变成 DOM attribute。

genericVue 3.3 的 TypeScript DX 改进里正式出现:使用 <script setup> 的组件可以通过 generic 属性接收泛型类型参数。这个能力在早期需要显式启用,后来在新版 Volar / vue-tsc 里默认可用。

generic 的取值规则也很直接:它和 TypeScript 里尖括号内的类型参数列表一致,这一点在 Vue 官方 SFC API 文档里有明确说明。也就是说,下面这些写法在语义上都符合 TypeScript 泛型参数的习惯:

<script setup lang="ts" generic="T">
<script setup lang="ts" generic="T extends string | number, U extends Item">
<script setup lang="ts" generic="TKey extends string = string">

Vue 的泛型组件主要有两条入口:SFC 里用 <script setup generic="...">,以及 render function / JSX 场景里用 defineComponent() 的函数签名。日常写 SFC 时,前者是最常见的入口。

这条设计的动机很朴素:组件作者应该能清楚表达组件期待的类型,而不是在 props 里把复杂数据退回 ObjectArraynull 这类过宽类型。这个方向来自 Vue RFC #436,放到业务组件里,就是尽量保留调用方已经知道的精确信息。

社区资料也能佐证这个能力的使用边界。value: Titems: Array<T> 共享同一个 T 时,Volar 能发现 valueitems 类型不一致的问题,这个例子出现在 Ninja Squad 的 Vue 3.3 解读里。输入组件的泛型价值则更具体:调用方可以决定 option 的形状、展示方式和取值方式,而不是把所有 option 都强行映射成固定 { label, value },这个思路可以参考 Abdelrahman Awad 的泛型 Select 文章

Vue 3.3 之前,社区需要用更绕的方式表达泛型组件。Awad 在 2022 年写过一篇基于 Composition API 绕出泛型组件的旧方案,文章开头也补充说明 Vue 3.3 之后 SFC 泛型有了官方写法。这个 Stack Overflow 泛型 Select 问题也很有代表性:2021 年问题还在绕,2023 年高赞答案已经改成 Vue 3.3 + Vue Language Tools 的官方路径。

泛型组件保存的是“类型之间的关系”

普通 TypeScript 泛型最常见的例子是 identity

function identity<T>(value: T): T {
  return value
}

它的价值不是让 value 有类型。即使不用泛型,也可以写成 stringnumberunknown。它真正保存的是:输入是什么类型,输出就是什么类型

identity 的核心点是“捕获参数类型,再把这份类型信息用于返回值”,这个解释来自 TypeScript Handbook 的 Generics 章节。把这句话移到 Vue 组件里,props 就像输入,emitsslotsv-model 就像输出。

Vue 泛型组件也是同一个道理。它最适合处理这种关系:调用方传进来的类型,要在组件的另一个出口被拿回去。

以菜单组件为例,先定义一个 action:

export interface CommonActionMenuAction<TKey extends string = string> {
  key: TKey
  label: string
  disabled?: boolean
}

业务侧有一个明确的枚举:

enum PhotoActionKey {
  Replace = 'replace',
  Delete = 'delete',
}

const actions: CommonActionMenuAction<PhotoActionKey>[] = [
  { key: PhotoActionKey.Replace, label: '替换图片' },
  { key: PhotoActionKey.Delete, label: '删除' },
]

调用方希望在选择菜单时拿到的仍然是 PhotoActionKey,而不是普通 string

function handleSelect(action: CommonActionMenuAction<PhotoActionKey>) {
  const handlerByKey = {
    [PhotoActionKey.Replace]: openReplaceDialog,
    [PhotoActionKey.Delete]: openDeleteDialog,
  }

  handlerByKey[action.key]()
}

这时组件的 props 和 emits 之间就有一条类型关系:

<script setup lang="ts" generic="TKey extends string = string">
import type { CommonActionMenuAction } from './types'

defineProps<{
  actions: readonly CommonActionMenuAction<TKey>[]
}>()

const emit = defineEmits<{
  select: [action: CommonActionMenuAction<TKey>]
}>()

function handleActionClick(action: CommonActionMenuAction<TKey>) {
  emit('select', action)
}
</script>

这段代码里的 TKey 同时出现在三个地方:

  • actions 里每一项的 key
  • handleActionClick 接收到的 action。
  • emit('select') 交给父组件的 action。

只要这三个位置共享同一个 TKey,调用方就能把 replace | delete 这类精确联合类型带进组件,再从事件里拿回来。

不写 generic 会在哪里变宽

如果组件写成这样:

<script setup lang="ts">
import type { CommonActionMenuAction } from './types'

defineProps<{
  actions: readonly CommonActionMenuAction[]
}>()

const emit = defineEmits<{
  select: [action: CommonActionMenuAction]
}>()
</script>

由于 CommonActionMenuAction<TKey> 给了默认值 string,这里的 CommonActionMenuAction 等价于:

CommonActionMenuAction<string>

业务侧虽然传入了 CommonActionMenuAction<PhotoActionKey>[],组件内部看到的却是 CommonActionMenuAction<string>[]。这个退化通常不会马上报错,因为 replacedelete 本来就是字符串;真正的问题发生在后续阅读和维护里。

例如回调里写映射时,action.key 已经变成了 string。TypeScript 没办法提醒你漏了某个枚举项,也没办法阻止你把一个不属于菜单的 key 传进来。组件越通用,这种类型放宽越容易藏进边界里。

generic 的作用就是把这条边界收紧:组件不关心 TKey 具体是什么,但它承诺同一个 TKey 会从输入一路走到输出

它到底怎么被解析出来

generic 看起来写在 <script> 标签上,很容易被误解成运行时属性。实际更接近“给语言工具看的类型参数”。浏览器运行的 JavaScript 不需要知道 TKey 是什么,IDE 和 vue-tsc 才需要把它放进一段可被 TypeScript 检查的代码里。

当前这篇文章写作时,我用一个 Nuxt 项目确认到本地依赖大致是 Vue 3.5.35@vue/language-core 2.2.12vue-tsc 2.2.12。源码细节按 @vue/language-core@2.2.12 这条线解释;以后版本实现可能会调整,但大方向仍然是“解析 SFC 属性 -> 生成虚拟 TypeScript -> 交给 TS 语言服务检查”。

script setup generic 的类型工具链

SFC block 解析阶段已经能拿到这段信息。@vue/language-coreparseSfc.ts 里遍历 <script> 节点的属性:遇到 setup 就标记这是 script setup,遇到 generic 就把属性内容保存到内部字段。对应逻辑可以压成这段阅读版伪代码:

// vuejs/language-tools/packages/language-core/lib/utils/parseSfc.ts
for (const prop of scriptNode.props) {
  if (prop.name === 'setup') {
    block.setup = true
  }

  if (isScriptBlock(block) && prop.name === 'generic') {
    block.__generic = parseAttr(prop, node)
  }
}

这里保存的不只是文本,还会保留 offset。offset 对 IDE 很关键:当虚拟 TypeScript 里报错、跳转或补全时,语言工具需要把位置映射回 .vue 文件里的真实位置。

第二步是把内部字段暴露成 script setup 的 generic 信息。@vue/language-corecomputedSfc.ts 会从 __generic 计算出 scriptSetup.generic。后面的 codegen 不再关心它来自哪个 HTML attribute,只关心当前 script setup 有没有泛型参数。

第三步是生成虚拟 TypeScript。scriptSetup.ts 遇到 scriptSetup.generic 后,会生成一个带泛型参数的函数形状,并把 setup、props、emit、slots、expose 等类型塞进这段虚拟代码里。阅读版伪代码可以压成这样:

// vuejs/language-tools/packages/language-core/lib/codegen/script/scriptSetup.ts
export default (<TKey extends string = string,>(
  __VLS_props,
  __VLS_ctx,
  __VLS_expose,
) => {
  // 这里继续放 defineProps / defineEmits / slots / setup 返回值等类型信息
})

注意泛型参数后面那个逗号。真实实现里,如果 generic 文本末尾没有逗号,语言工具会补一个逗号。这是 TSX / 泛型箭头函数常见的消歧写法,能减少 <T>() => {} 被误读成 JSX 标签的风险。

这也解释了 generic 为什么不会出现在运行时行为里。它属于 Vue Language Tools 为 IDE、vue-tsc 和模板类型检查生成虚拟 TypeScript 时消费的类型信息,不属于 Vue runtime 创建组件实例时读取的选项。

@vue-generic 也是同一条工具链上的东西。官方文档把它设计成模板注释指令;@vue/language-corevue-template-inline-ts.ts 会识别形如 <!-- @vue-generic {SomeType} --> 的注释,再把花括号里的内容作为 TypeScript 片段处理。它服务的是“模板里这一次组件实例化用什么类型参数”,不是运行时指令。

为什么子组件也要一路写下去

泛型参数不会自动穿透组件树

假设 CommonActionMenu 里面又包了一层 CommonActionMenuPopover。外层组件写了 generic,但内层没有写:

<!-- CommonActionMenu.vue -->
<script setup lang="ts" generic="TKey extends string = string">
defineProps<{
  actions: readonly CommonActionMenuAction<TKey>[]
}>()
</script>

<template>
  <CommonActionMenuPopover :actions="actions" />
</template>
<!-- CommonActionMenuPopover.vue -->
<script setup lang="ts">
defineProps<{
  actions: readonly CommonActionMenuAction[]
}>()
</script>

类型在外层还是 TKey,到了内层又回到 string。从调用方视角看,类型链路中间断了一次。

如果内层也只是透传 action,内层也应该写成泛型组件:

<!-- CommonActionMenuPopover.vue -->
<script setup lang="ts" generic="TKey extends string = string">
import type { CommonActionMenuAction } from '../types'

defineProps<{
  actions: readonly CommonActionMenuAction<TKey>[]
}>()

const emit = defineEmits<{
  select: [action: CommonActionMenuAction<TKey>]
}>()
</script>

这就是为什么你会看到一串组件都带着同一个 generic。这些组件共同参与了同一条类型管道;只要中间某层要接收、展示、再交回同一个类型,它就需要显式把这个类型参数接住。

Vue 泛型组件使用判断

generic 值就是 TypeScript 参数列表

generic 属性里写的是 TypeScript 泛型参数列表。

所以这类写法是自然的:

<script setup lang="ts" generic="TKey extends string = string">

它等价于你在 TypeScript 函数或类型别名上写:

type ActionList<TKey extends string = string> = Array<CommonActionMenuAction<TKey>>

所以它不是只能传一个泛型。多个泛型参数、extends 约束、默认类型、导入类型都写在同一个 generic 字符串里,和普通 TypeScript 泛型参数列表一样:

<script setup lang="ts" generic="TValue, TKey extends string = string">

复杂一点也可以这样写:

<script
  setup
  lang="ts"
  generic="TKey extends string, TPayload = undefined, TMeta extends Record<string, unknown> = {}"
>

这解决的是“参数列表在哪里写”的问题。Vue 没有给每个泛型参数单独设计一个 attribute,也不需要写成 generic-keygeneric-value 之类的拆分形式。整个 generic 字符串就是 TypeScript 的泛型参数列表

generic 可以使用多个参数、extends 约束、默认类型,也可以引用 imported types;这些能力在 Vue 官方 SFC API 文档里有说明。实际使用时,我会优先保留默认类型:

<script setup lang="ts" generic="TKey extends string = string">

默认值的好处是,普通调用方不需要关心泛型。只有当调用方传入更精确的 CommonActionMenuAction<SomeEnum>[] 时,语言工具才把 TKey 推断成那个枚举或字符串联合。

一个完整例子:ActionMenu<TKey, TPayload>

只讲 key 还不够直观。真实业务里,菜单 action 往往不只有“点了哪个动作”,还会带一点业务数据。比如照片菜单里,replace 可能带当前图片 id,delete 可能也需要同一份图片信息。

这个场景可以拆成两个泛型参数:

  • TKey:动作 key,用来决定后续走哪个分支。
  • TPayload:动作携带的数据,用来让回调拿到业务上下文。

双泛型 ActionMenu 的完整使用链路

先把 action 类型放到独立 types.ts,这样组件和调用方能共享同一份契约:

// types.ts
export interface Action<TKey extends string, TPayload = undefined> {
  key: TKey
  label: string
  payload: TPayload
  disabled?: boolean
}

组件用两个泛型参数声明 props 和 emits:

<!-- ActionMenu.vue -->
<script setup lang="ts" generic="TKey extends string, TPayload = undefined">
import type { Action } from './types'

const props = defineProps<{
  actions: readonly Action<TKey, TPayload>[]
}>()

const emit = defineEmits<{
  select: [action: Action<TKey, TPayload>]
}>()

function handleActionClick(action: Action<TKey, TPayload>) {
  emit('select', action)
}
</script>

<template>
  <button
    v-for="action in props.actions"
    :key="action.key"
    :disabled="action.disabled"
    type="button"
    @click="handleActionClick(action)"
  >
    {{ action.label }}
  </button>
</template>

调用方可以把 TKeyTPayload 都收窄:

<!-- PhotoActions.vue -->
<script setup lang="ts">
import ActionMenu from './ActionMenu.vue'
import type { Action } from './types'

enum PhotoActionKey {
  Replace = 'replace',
  Delete = 'delete',
}

interface PhotoActionPayload {
  imageId: string
  albumId: string
}

const actions: Action<PhotoActionKey, PhotoActionPayload>[] = [
  {
    key: PhotoActionKey.Replace,
    label: '替换图片',
    payload: { imageId: 'img_1', albumId: 'album_1' },
  },
  {
    key: PhotoActionKey.Delete,
    label: '删除图片',
    payload: { imageId: 'img_1', albumId: 'album_1' },
  },
]

function handleSelect(action: Action<PhotoActionKey, PhotoActionPayload>) {
  action.key
  //    ^? PhotoActionKey

  action.payload.imageId
  //             ^? string
}
</script>

<template>
  <ActionMenu :actions="actions" @select="handleSelect" />
</template>

这段示例里,TKeyTPayload 是同一组 action 的两个维度。组件内部不需要知道 PhotoActionKeyPhotoActionPayload 具体长什么样,但它保证:actions 里传进来的 key / payload 类型,会原样出现在 select 事件里

如果换成普通非泛型写法,组件很容易把 key 放宽成 string,把 payload 放宽成 unknown 或某个过度通用的对象。此时调用方还是能点菜单,但 handleSelect 里就少了精确提示和穷尽检查。

泛型参数放在组件上,还是放在事件上

TypeScript Handbook 里还有一个细节很适合迁移到 Vue 组件:泛型参数可以放在函数调用签名上,也可以放在整个接口上。放在调用签名上,表示“每次调用都可以决定一次类型”;放在接口上,表示“这个对象里的多个成员共享同一个类型参数”。

Vue 组件里的 generic 更像后者。一个组件实例在这次使用里被绑定到某个 TKey,于是它的 props、emits、slots 和内部子组件透传都应该围绕这个 TKey 展开。

如果把泛型只放在某个事件函数上,语义会变成“每次 emit 都可以是不同的 T”。这通常不是菜单、Select、Table 这类组件想表达的契约。菜单组件想表达的是:这一组 actions 的 key 是某个集合,选中事件也只能从这个集合里返回。

// 不推荐:每次 select 都像是可以临时决定一个新 TKey
type Emits = {
  select: <TKey extends string>(action: CommonActionMenuAction<TKey>) => void
}

// 更符合组件语义:组件实例共享同一个 TKey
type Props<TKey extends string> = {
  actions: CommonActionMenuAction<TKey>[]
}

type Emits<TKey extends string> = {
  select: [action: CommonActionMenuAction<TKey>]
}

这个区别能帮助判断“generic 应该写在哪”。如果一个类型只约束某个内部函数,放在函数上就够了;如果它要同时约束 props、emit、slot props、v-model 和子组件透传,就应该放在组件上。

模板里推断不到时用 @vue-generic

大部分时候,Vue 语言工具能从 props 推断泛型参数。比如 actions 的类型已经是 CommonActionMenuAction<PhotoActionKey>[],模板里使用组件时不需要额外标注。

但有些组件的泛型参数无法从当前 props 明确推断。此时可以用 Vue 官方 SFC API 文档里的 @vue-generic 注释指令,在模板里显式传类型:

<template>
  <!-- @vue-generic {PhotoActionKey} -->
  <CommonActionMenu :actions="actions" @select="handleSelect" />
</template>

这不是常规开发里每次都要写的东西。它更像一个兜底工具:当泛型组件的输入太间接,或者模板侧推断不出你想要的具体类型时,用它告诉语言工具这次实例化的类型参数。

一个常见信号是:代码逻辑明明成立,但模板里的事件参数被推成了过宽类型,或者多个 props 之间被推成了不想要的联合类型。此时优先检查 props 类型是否足够直接;如果 props 本身确实无法承载推断信息,再考虑 @vue-generic

ref 泛型组件时不要用 InstanceType

泛型组件还有一个容易漏的边界:模板 ref。

普通组件经常这样写:

const modalRef = ref<InstanceType<typeof CommonModal>>()

泛型组件不能直接用 InstanceType 拿暴露类型,需要使用 vue-component-type-helpers 里的 ComponentExposed;这个边界也写在 Vue 官方 SFC API 文档里:

import type { ComponentExposed } from 'vue-component-type-helpers'
import CommonActionMenu from './CommonActionMenu.vue'

const menuRef = ref<ComponentExposed<typeof CommonActionMenu>>()

这背后的原因可以粗略理解为:泛型组件的类型不是一个已经完全实例化的普通构造类型,InstanceType 无法表达它暴露出来的泛型实例形态。日常写 props / emits 时不一定会碰到这个问题;只要你开始给泛型组件加 ref,就需要换这个 helper。

它不会改变运行时代码

generic 是类型层能力。它服务于 SFC 编译、Vue language tools、vue-tsc 和 IDE 提示,不会让组件在运行时多一个 prop,也不会改变浏览器里的行为。

这也解释了一个常见现象:页面能正常跑,不代表类型链路是对的。把 TKey 写丢之后,菜单仍然能弹出,点击仍然能触发,emit 仍然会把对象传出去。只是 TypeScript 不再知道这个 key 的精确范围,后续改枚举、改映射、补 action 时就少了一层保护。

所以判断泛型组件有没有价值,不能只看运行效果,要看它有没有让调用方少写类型断言、少丢联合类型、少写 as SomeKey

这也意味着,泛型组件的回归测试不能只靠浏览器点击。浏览器点击能证明菜单弹出、事件触发、回调执行;它证明不了 action.key 有没有保持在 PhotoActionKey 联合类型里。更合适的护栏是把运行时测试和类型测试分开:

  • 运行时测试验证交互和事件载荷。
  • vue-tsc 或类型测试验证错误 key 会被拦住。
  • @ts-expect-error 用例验证“这段代码应该失败”,避免后续改宽类型后静默放过。

如果项目没有单独的类型测试框架,至少要让泛型组件所在包参与 vue-tsc --noEmit,并在关键调用点保留能触发类型检查的示例。

不要把所有组件都写成 generic

泛型组件是为了保存类型关系,不是为了显得类型更高级。

这几类组件通常不需要泛型:

  • 纯展示组件,只接收固定结构的数据,不把其中某个类型再交回调用方。
  • 已经有明确业务类型的组件,例如只服务某个页面的用户信息卡片。
  • 内部能用普通联合类型表达清楚的组件,例如固定的 'primary' | 'danger' | 'ghost' 按钮 variant。

这几类组件更适合泛型:

  • Select<T>:传入 options,v-model 返回同一个 T
  • Table<TRow>:传入 rows,slot props 或 row click 返回同一个 TRow
  • ActionMenu<TKey>:传入 action key,点击事件返回同一个 key。
  • FormField<TValue>:组件内部不关心值具体类型,但提交、校验、回填要保持同一个 TValue

判断标准可以压成一句话:如果调用方传入的类型,会在组件的另一个出口被调用方拿回去,generic 才有意义

这里还要警惕一种过度设计:为了让组件“未来更通用”,提前把固定业务组件改成泛型。泛型会增加阅读成本,也会把错误信息变长。一个只在头像菜单里使用的组件,如果永远只处理 replace | delete,直接写业务枚举更清楚;等它变成跨业务 ActionMenu,再把 key 抽成 TKey

对组件库代码的几个实践规则

第一,类型参数名要表达业务关系。T 适合非常短的通用示例;真实组件里,TKeyTValueTRow 往往更好读。读模板和事件时,后续维护者能立刻知道这个类型参数代表哪个维度。

第二,能加默认值就加默认值。TKey extends string = string 让普通调用方保持低成本;需要精确类型的调用方再通过 props 推断拿到更窄的类型。

第三,wrapper 组件不要做类型黑洞。只要 wrapper 接收泛型数据,又继续传给子组件或事件,就要让 wrapper 自己也带上同一个泛型参数。

第四,不要用 as 把问题压下去。如果写完泛型组件后调用方还需要频繁 as PhotoActionKey,说明某一层 props、emits、slot 或中间变量的类型关系断了。

第五,类型测试要覆盖“应该报错”的场景。运行时单测能保证点击菜单会触发事件;类型层还需要通过 vue-tsctsdexpectTypeOf 或带 @ts-expect-error 的用例确认错误 key 进不来。

第六,文档和示例要写“类型关系”,不要只写“支持泛型”。比如 ActionMenu<TKey> 的说明应该写成“actions[].keyselect 事件里的 action.key 保持同一个 TKey”,这比“支持泛型 key”更容易让调用方知道该怎么用。

第七,遇到第三方 UI 库时,先看它是否已经暴露泛型入口。Headless UI Listbox 负责交互和可访问性,外层业务 Select 负责把 option 类型、label 映射和 value 关系收紧;这个组合方式可以参考 Maylor 的泛型 SFC 文章

回到那个 generic 属性

现在再看这行代码:

<script setup lang="ts" generic="TKey extends string = string">

它的意思可以翻译成:

这个组件不决定 key 的具体集合,但它会保证 props 里传进来的 key 类型,和事件里交回去的 key 类型,是同一个类型。

这就是 generic 在 Vue SFC 里的主要价值。它让组件像 TypeScript 函数一样表达**“输入和输出之间的类型关系”**,而不是只给每个局部变量贴上看似正确的静态类型。

组件只在自己边界内写类型还不够。真正影响长期可维护性的,是调用方、包装组件、子组件和事件回调之间的类型有没有连续。generic 解决的正是这条连续性。