前端开发··2 阅读·预计 9 分钟

Vue 3 泛型组件的类型安全设计:从 Props 推导到 Emits 约束

引言

Vue 3.3 正式引入了泛型组件支持,让 definePropsdefineEmits 可以直接接收泛型参数。但在实际项目中,大多数开发者仍然用最基础的方式声明类型,错失了类型系统带来的自动推导和安全保障。本文结合正反例对比,梳理 Vue 3 泛型组件从 Props 推导到 Emits 约束再到插槽透传的完整链路。

一、defineProps 泛型:告别手动类型断言

❌ 反例:宽泛的 any 类型

<script setup lang="ts">
const props = defineProps({
  items: Array,
  getKey: Function
})

// items 的每个元素是 any,完全没有类型提示
// getKey 的参数和返回值也是 any
</script>

这几乎是 Vue 2 时代的写法。TypeScript 完全感知不到 items 的元素类型,也无法校验 getKey 的签名。

✅ 正例:defineProps 泛型自动推导

<script setup lang="ts" generic="T, K extends keyof T">
interface SelectProps<T, K extends keyof T> {
  options: T[]
  valueKey: K
  labelKey: K
  modelValue: T | null
}

const props = defineProps<SelectProps<T, K>>()

// TypeScript 自动推导:
// props.options[0].id        ✅ 有提示
// props.options[0].noneExist ❌ 编译报错
</script>

<template>
  <div v-for="item in props.options" :key="item[props.valueKey]">
    {{ item[props.labelKey] }}
  </div>
</template>

当父组件传入:

<SelectBox
  :options="[{id: 1, name: 'Alice'}]"
  value-key="id"
  label-key="name"
/>

TypeScript 会推导出 T = {id: number, name: string}, K = 'id' | 'name',确保 valueKeylabelKey 只能是 'id''name'

核心要点<script generic="T"> 声明了组件的类型参数,所有 Props 中引用 T 的位置都会在实例化时被具体类型替换。

二、defineEmits 类型约束:事件载荷的类型安全

❌ 反例:丢失载荷类型

<script setup lang="ts">
const emit = defineEmits(['update:modelValue', 'change'])

// 调用时无类型检查
emit('change', { id: 'oops' }) // 本应传 id: number
</script>

✅ 正例:结合泛型的 emit 签名

<script setup lang="ts" generic="T">
interface Props<T> {
  items: T[]
  selected: T | null
}

const props = defineProps<Props<T>>()

const emit = defineEmits<{
  (e: 'update:selected', value: T): void
  (e: 'select', item: T): void
}>()

function handleClick(item: T) {
  emit('select', item)        // ✅ item 类型与 T 一致
  emit('update:selected', item) // ✅ 同上
}
</script>

T 被推导为 User 类型时,发射事件的载荷自动约束为 User。任何传入错误类型的事件调用都会在编译时报错。

三、泛型插槽:类型透传的最后一块拼图

大多数组件库会定义具名插槽,但很少有项目为插槽作用域添加泛型约束:

❌ 反例

<!-- ParentList.vue -->
<script setup lang="ts" generic="T">
const props = defineProps<{ items: T[] }>()
</script>
<template>
  <div v-for="(item, index) in props.items">
    <slot :item="item" :index="index" />  
    <!-- item 透传到插槽后失去类型信息 -->
  </div>
</template>

使用侧:

<ParentList :items="users">
  <template #default="{ item }">
    <!-- item: any,没有自动补全 -->
    {{ item.name }}
  </template>
</ParentList>

✅ 正例:defineSlots 泛型声明

<!-- ParentList.vue -->
<script setup lang="ts" generic="T">
const props = defineProps<{ items: T[] }>()

defineSlots<{
  default(props: { item: T; index: number }): any
  header(): any
  footer(props: { total: number }): any
}>()
</script>

<template>
  <div v-for="(item, index) in props.items">
    <slot :item="item" :index="index" />
  </div>
</template>

使用侧获得完整推导:

<ParentList :items="users">
  <template #default="{ item }">
    <!-- item: User,自动补全 name, id... -->
    {{ item.name }}       
  </template>
</ParentList>

四、实战:一个端到端类型安全的 Select 组件

以下是完整实现,涵盖 Props 泛型、Emits 约束、插槽透传:

<script setup lang="ts" generic="T extends Record<string, any>, K extends keyof T">
import { computed } from 'vue'

interface SelectProps<T, K extends keyof T> {
  options: T[]
  valueKey: K
  labelKey: K
  modelValue: T | null
  placeholder?: string
}

const props = withDefaults(
  defineProps<SelectProps<T, K>>(),
  { placeholder: '请选择' }
)

const emit = defineEmits<{
  (e: 'update:modelValue', value: T): void
  (e: 'option-click', item: T): void
}>()

defineSlots<{
  default(props: { item: T; isSelected: boolean }): any
  empty(): any
}>()

const selectedValue = computed<T | null>({
  get: () => props.modelValue,
  set: (val) => {
    if (val) emit('update:modelValue', val)
  }
})

function handleSelect(item: T) {
  selectedValue.value = item
  emit('option-click', item)
}
</script>

<template>
  <div class="select-wrapper">
    <button
      v-for="item in options"
      :key="item[valueKey]"
      :class="{ active: modelValue?.[valueKey] === item[valueKey] }"
      @click="handleSelect(item)"
    >
      <slot :item="item" :is-selected="modelValue?.[valueKey] === item[valueKey]">
        {{ item[labelKey] }}
      </slot>
    </button>
    <div v-if="options.length === 0">
      <slot name="empty">暂无数据</slot>
    </div>
  </div>
</template>

使用示例:

<GenericSelect
  v-model="currentUser"
  :options="[
    { id: 1, name: 'Alice', role: 'admin' },
    { id: 2, name: 'Bob', role: 'editor' }
  ]"
  value-key="id"
  label-key="name"
>
  <template #default="{ item, isSelected }">
    <span :class="{ bold: isSelected }">
      {{ item.name }} — {{ item.role }}
    </span>
  </template>
</GenericSelect>

TypeScript 全程护航:item 推导为 {id: number, name: string, role: string}valueKey 只能是 'id' | 'name' | 'role'v-model 绑定类型完全匹配。

五、注意事项与局限性

  1. generic 属性仅限 <script setup>:Options API 无法使用此语法。若需兼容,应在 defineComponent 中借助类型断言。
  2. 泛型组件与 withDefaults:当 Props 包含泛型参数时,withDefaults 的类型推导可能受限。建议手动标注默认值类型:
const props = withDefaults(
  defineProps<SelectProps<T, K>>(),
  {
    placeholder: '请选择' as const
  } as any // 当前版本有时需要类型断言
)
  1. IDE 支持差异:Volar 1.8+ 对泛型组件推导表现良好,但在复杂嵌套泛型场景下仍可能出现推断延迟。
  2. 性能无额外开销:泛型是纯编译时概念,generic 属性在构建后会被完全擦除,运行时零成本。

总结

Vue 3.3 的泛型组件机制,将 definePropsdefineEmitsdefineSlots 从"类型标注"提升为"类型约束"。关键在于三块拼图:

机制作用关键语法
defineProps<T>()Props 类型推导<script generic="T">
defineEmits<T>()事件载荷约束泛型参数联动 Props
defineSlots<T>()插槽作用域透传具名插槽参数类型

当三者配合使用时,组件在 IDE 中能获得从前到后、从内到外的完整类型推导链条,有效消灭运行时类型错误。如果你的组件库还在用 any 蒙混过关,现在正是迁移的最佳时机。

0 评论

评论区

登录 后参与评论