Vue 基础体系 · 第 29/70 篇。示例基于 Vue 3、Composition API、TypeScript 与现代 Vite 工具链;版本敏感能力会单独标注。

Vue 编译宏:defineProps、defineEmits、defineModel 与泛型组件

在 Vue 3 的 <script setup> 中,definePropsdefineEmitsdefineModelgeneric 不是普通的运行时函数,而是由 Vue 编译器识别并转换的编译宏

“编译宏”指的是:源码中出现特定形式的调用或属性,编译器在构建阶段读取它们的语义,将其转换成组件运行时需要的 props、事件、setup 参数和类型信息。它们通常不需要导入,也不能像普通函数一样在任意位置调用。

例如:

<script setup lang="ts">
const props = defineProps<{
  title: string
}>()
</script>

这段代码不是在浏览器中直接执行 defineProps。编译器会将其转换为组件的 props 声明,并让 props.title 获得 TypeScript 类型。可以把它概念化为:

export default {
  props: {
    title: {
      type: String,
      required: true
    }
  },
  setup(props) {
    // 原来的 <script setup> 代码
  }
}

上面的结果是帮助理解的近似形式,不是 Vue 编译器实际生成代码的完整文本。真正编译结果还包含模板渲染函数、事件处理和作用域变量处理。

本文示例基于 Vue 3、Composition API、TypeScript 和现代 Vite 工具链。其中:

  • 泛型组件相关能力在 Vue 3.3 引入;
  • defineModel 在 Vue 3.4 引入;
  • <script setup> 中的响应式 props 解构在 Vue 3.5 有行为变化,本文会单独说明;
  • 代码需要使用支持这些 Vue 版本的 vue-tsc 和 Vue 语言服务。

一、先理解 <script setup> 与编译宏

1. <script setup> 的代码为什么可以直接使用组件上下文

普通 setup 组件通常写成:

export default {
  props: {
    count: Number
  },
  emits: ['change'],
  setup(props, { emit }) {
    // 使用 props 和 emit
  }
}

<script setup> 将组件的 setup 函数体直接暴露为顶层代码:

<script setup lang="ts">
const props = defineProps<{ count: number }>()
const emit = defineEmits<{
  change: [value: number]
}>()
</script>

编译器会把:

  • defineProps() 转换为组件 props 声明,并绑定到 setupprops 参数;
  • defineEmits() 转换为组件 emits 声明,并绑定到 setupemit 参数;
  • 顶层声明的变量暴露给当前组件模板;
  • 顶层的 ref、计算属性和函数保留其响应式与闭包关系。

宏调用必须处于编译器可以静态分析的位置。例如,下面的写法没有意义:

if (someCondition) {
  const props = defineProps<{ id: number }>()
}

props 是组件契约的一部分,必须在编译阶段确定,不能根据运行时条件决定。

2. 宏的几个共同限制

不需要导入

正确写法:

const props = defineProps<{ name: string }>()
const emit = defineEmits<{
  submit: [value: string]
}>()

通常不应写:

import { defineProps, defineEmits } from 'vue'

在现代 Vue 工具链中,语言服务可能不会立刻把这种写法当成运行时错误,但这些名称的本质仍然是编译宏,不是应该由应用在运行时调用的普通 API。

不能保存后再调用

const macro = defineProps
const props = macro<{ id: number }>() // 不应这样写

编译器识别的是具有固定语义的宏调用形式,而不是任意函数别名。

不能把运行时值作为类型宏的参数

const defaultType = String

// 错误思路:泛型位置不是运行时表达式位置
const props = defineProps<typeof defaultType>()

TypeScript 泛型会在类型检查阶段被擦除;运行时值则需要由编译器生成 Vue 的 props 选项。二者不是同一层面的信息。


二、defineProps:声明父组件传入的数据

1. Props 的运行时含义

Props 是父组件向子组件传递的输入数据。它具有单向数据流:

父组件状态 ──传入 props──> 子组件

子组件可以读取 props,但不应直接修改它。父组件传入的值发生变化时,子组件会收到新的 props;子组件直接修改 props 不会改变父组件的源状态。

一个完整的子组件可以这样写:

<!-- UserCard.vue -->
<script setup lang="ts">
interface User {
  id: number
  name: string
  email: string
}

const props = defineProps<{
  user: User
  compact?: boolean
}>()
</script>

<template>
  <article :class="{ compact: props.compact }">
    <strong>{{ props.user.name }}</strong>
    <span>{{ props.user.email }}</span>
  </article>
</template>

父组件:

<script setup lang="ts">
import UserCard from './UserCard.vue'

const user = {
  id: 1,
  name: 'Lin',
  email: 'lin@example.com'
}
</script>

<template>
  <UserCard :user="user" compact />
</template>

这里:

  • user 是必需 props,因为类型中没有 ?
  • compact 是可选 props;
  • :user="user" 传递对象表达式;
  • compact 是布尔属性简写,等价于 :compact="true"

TypeScript 会检查父组件传入的对象是否符合 User,但这不等于运行时永远安全。TypeScript 类型会被擦除,因此来自接口、网络或 JavaScript 调用方的数据仍然可能不符合约定。


2. 类型声明方式与运行时声明方式

defineProps 有两种主要写法。

类型声明方式

interface Props {
  id: number
  title?: string
}

const props = defineProps<Props>()

这种写法的优点是类型表达自然,适合 TypeScript 项目。Vue 会尝试根据类型生成运行时 props 信息,但这种转换是基于编译时静态分析,不是完整的 TypeScript 类型解释器。

运行时声明方式

const props = defineProps({
  id: {
    type: Number,
    required: true
  },
  title: {
    type: String,
    default: 'Untitled'
  }
})

这种写法直接提供 Vue 运行时所需的 props 选项,因此运行时校验和默认值更明确。

两种方式不能混用:

// 不正确:同一个 defineProps 同时提供运行时参数和类型参数
const props = defineProps<Props>({
  id: Number
})

如果同时需要复杂的运行时校验和精确的 TypeScript 类型,通常应优先使用运行时声明,再让 TypeScript 从组件使用方式中检查传入值;不要把类型参数和运行时参数强行合并到一次调用中。


3. 类型到运行时的转换边界

Vue 编译器可以处理常见的类型:

interface Props {
  name: string
  count: number
  enabled?: boolean
}

const props = defineProps<Props>()

它可以将这些类型大致转换为:

{
  name: { type: String, required: true },
  count: { type: Number, required: true },
  enabled: { type: Boolean, required: false }
}

但是,TypeScript 的类型系统比 Vue 运行时 props 能表达的规则更复杂。例如:

type Props = {
  value: string extends SomeCondition ? string : number
}

Vue 运行时无法真正执行“条件类型”这种编译期类型逻辑。即使某些版本的编译器可以接受部分复杂类型,也不意味着浏览器运行时能够根据这些类型完成同等强度的校验。

因此要区分两个目标:

  • TypeScript 检查:在开发和构建阶段检查组件调用者的类型;
  • Vue 运行时校验:组件真正运行时,对传入值做有限的类型和必需性检查。

复杂业务约束,例如“字符串必须匹配某个正则”“数组中的对象必须满足多个关联条件”,通常需要显式运行时校验,而不能只依靠 defineProps 的类型参数。


4. Props 默认值:withDefaults

可选属性只是表示调用者可以不传,不代表组件内部一定已经得到业务默认值。

interface Props {
  labels?: string[]
}

const props = defineProps<Props>()

此时 props.labels 仍然可能是 undefined。如果希望在组件内部得到默认值,可以使用 withDefaults

interface Props {
  labels?: string[]
  pageSize?: number
}

const props = withDefaults(defineProps<Props>(), {
  labels: () => ['全部'],
  pageSize: 20
})

这里:

  • pageSize 的默认值是普通原始值;
  • labels 是数组,使用工厂函数可以让每个组件实例获得独立数组;
  • withDefaults 会相应收窄类型,使默认值覆盖的属性在使用时不再被视为可选。

错误示例:

const props = withDefaults(defineProps<Props>(), {
  labels: ['全部']
})

在某些类型配置下这可能被接受,但共享可变数组会造成实例之间意外共享状态的风险。对于对象和数组默认值,使用工厂函数是更稳妥的方式。

Vue 3.5 还支持响应式 props 解构:

<script setup lang="ts">
const { title = '默认标题' } = defineProps<{
  title?: string
}>()
</script>

<template>
  <h1>{{ title }}</h1>
</template>

在 Vue 3.5 及更新版本中,编译器会处理这种解构变量的响应式读取。对于需要兼容 Vue 3.4 及更早版本的代码,应使用 props.title,或继续使用 withDefaults 后访问 props

const props = withDefaults(defineProps<{ title?: string }>(), {
  title: '默认标题'
})

这一区别很重要:普通 JavaScript 解构通常会把当前值复制出来,而 Vue 3.5 的 <script setup> 响应式解构是编译器提供的特殊行为。


5. Props 的解构与响应式丢失

在不依赖 Vue 3.5 响应式解构语义的情况下,下面的写法会失去后续更新:

const props = defineProps<{ count: number }>()

const { count } = props

如果父组件后来把 count1 改成 2,普通解构得到的 count 不会自动跟踪更新。

需要保留响应式时,可以使用:

import { toRefs } from 'vue'

const props = defineProps<{ count: number }>()
const { count } = toRefs(props)

或者直接在模板和计算属性中使用 props.count

import { computed } from 'vue'

const props = defineProps<{ count: number }>()

const doubled = computed(() => props.count * 2)

toRefs 的结果是 Ref<number>,而不是普通的 number。它通过 getter/setter 代理继续指向原始 props,因此不会复制出一个静态快照。


三、defineEmits:声明子组件发出的事件

1. Emits 与 Props 的方向相反

Emits 是子组件向父组件通知事件的机制:

父组件状态 ──props──> 子组件
子组件 ──emit 事件──> 父组件处理函数

事件本身不是共享状态,也不会自动修改父组件数据。子组件只是发出一个带名称和参数的通知,父组件是否修改状态由父组件决定。

子组件:

<script setup lang="ts">
const emit = defineEmits<{
  save: [title: string]
  cancel: []
}>()

function save() {
  emit('save', '新的标题')
}

function cancel() {
  emit('cancel')
}
</script>

<template>
  <button @click="save">保存</button>
  <button @click="cancel">取消</button>
</template>

父组件:

<Editor
  @save="handleSave"
  @cancel="handleCancel"
/>

对应的处理函数:

function handleSave(title: string) {
  console.log('保存:', title)
}

function handleCancel() {
  console.log('取消')
}

TypeScript 会检查:

emit('save', '新的标题') // 正确
emit('save')             // 错误,缺少 title
emit('cancel', 1)       // 错误,cancel 不接受参数

2. defineEmits 的类型写法

Vue 3.3 及更新版本支持命名元组写法:

const emit = defineEmits<{
  change: [id: number]
  update: [value: string, source: 'user' | 'system']
}>()

它表示:

emit('change', 10)
emit('update', '标题', 'user')

旧一些的常见写法是调用签名:

const emit = defineEmits<{
  (event: 'change', id: number): void
  (event: 'update', value: string, source: 'user' | 'system'): void
}>()

两者都可以表达事件名称和参数类型。命名元组通常更紧凑,也更容易阅读。


3. 运行时事件声明与校验器

如果需要在运行时声明事件:

const emit = defineEmits({
  submit: (title: string) => {
    return title.length > 0
  },
  cancel: null
})

这里 submit 的函数是事件校验器:

  • 返回 true 表示参数满足校验;
  • 返回 false 时,Vue 在开发环境中发出警告;
  • 校验器不是阻止事件的安全边界,事件调用仍然是应用代码主动执行的行为;
  • 生产构建中开发警告通常会被移除或弱化。

这与 TypeScript 类型检查不同。TypeScript 只在开发和构建阶段工作,运行时校验器才可能检查来自 JavaScript、网络数据或第三方调用的值。

如果事件既要有完整类型,又要有复杂运行时校验,可以在事件处理函数内部显式校验,而不是误以为 TypeScript 会保护浏览器运行时:

const emit = defineEmits<{
  submit: [payload: { title: string }]
}>()

function submit(payload: unknown) {
  if (
    typeof payload !== 'object' ||
    payload === null ||
    !('title' in payload) ||
    typeof payload.title !== 'string'
  ) {
    return
  }

  emit('submit', { title: payload.title })
}

这里 unknown 强迫代码先验证数据,再把它收窄为可发出的类型。


4. 事件声明不会自动形成业务状态

下面的代码只声明并发出了事件:

const emit = defineEmits<{
  change: [value: number]
}>()

function increase() {
  emit('change', 2)
}

它不会自动修改任何 ref,也不会改变父组件的变量。父组件必须处理事件:

<script setup lang="ts">
import Counter from './Counter.vue'
import { ref } from 'vue'

const count = ref(0)

function handleChange(value: number) {
  count.value = value
}
</script>

<template>
  <Counter @change="handleChange" />
  <p>{{ count }}</p>
</template>

如果父组件没有监听 change,事件可以被发出,但不会产生应用层面的后续效果。


四、defineModel:把 prop 与 update 事件组合成双向绑定

1. v-model 的真实展开

组件上的:

<CustomInput v-model="title" />

本质上近似于:

<CustomInput
  :modelValue="title"
  @update:modelValue="title = $event"
/>

因此,一个支持默认 v-model 的子组件至少需要:

  1. 接收名为 modelValue 的 prop;
  2. 发出名为 update:modelValue 的事件。

在没有 defineModel 的情况下,可以手动写出:

<script setup lang="ts">
const props = defineProps<{
  modelValue: string
}>()

const emit = defineEmits<{
  'update:modelValue': [value: string]
}>()

function updateValue(value: string) {
  emit('update:modelValue', value)
}
</script>

<template>
  <input
    :value="props.modelValue"
    @input="updateValue(($event.target as HTMLInputElement).value)"
  />
</template>

defineModel 将这对声明组合成一个可读写的 ref。


2. 基本用法

<!-- CustomInput.vue -->
<script setup lang="ts">
const model = defineModel<string>()
</script>

<template>
  <input v-model="model" />
</template>

父组件:

<script setup lang="ts">
import { ref } from 'vue'
import CustomInput from './CustomInput.vue'

const title = ref('初始标题')
</script>

<template>
  <CustomInput v-model="title" />
  <p>{{ title }}</p>
</template>

model 在子组件中是一个 ref:

  • 读取 model.value 时,相当于读取 modelValue prop;
  • 设置 model.value 时,相当于发出 update:modelValue
  • 在模板中使用 v-model="model" 时,模板会自动处理 ref 解包。

概念上:

const model = defineModel<string>()

近似产生:

const props = defineProps<{
  modelValue: string
}>()

const emit = defineEmits<{
  'update:modelValue': [value: string]
}>()

const model = computed({
  get: () => props.modelValue,
  set: value => emit('update:modelValue', value)
})

真实实现还会处理默认值、修饰符和模型选项。


3. 模型是否必需

const model = defineModel<string>({
  required: true
})

这表示:

  • modelValue 在运行时是必需 prop;
  • TypeScript 中模型值不再被视为可能为 undefined
  • 父组件使用组件时应提供 v-model 或等价的 prop。

如果省略 required: true

const model = defineModel<string>()

模型通常会被视为可选,因为父组件可能没有传入 modelValue。组件内部需要处理 undefined 的可能性,或者设置默认值。


4. 自定义模型名称

组件可以通过参数声明命名模型:

<script setup lang="ts">
const title = defineModel<string>('title')
const description = defineModel<string>('description')
</script>

<template>
  <input v-model="title" />
  <textarea v-model="description" />
</template>

父组件:

<Editor
  v-model:title="title"
  v-model:description="description"
/>

它们分别对应:

<Editor
  :title="title"
  @update:title="title = $event"
  :description="description"
  @update:description="description = $event"
/>

命名模型适合一个组件需要维护多个独立字段的情况。每次调用 defineModel 都代表一个不同的 prop/event 对。


5. v-model 修饰符与转换器

父组件可以这样使用:

<CustomInput v-model.trim="title" />

子组件可以读取修饰符:

<script setup lang="ts">
const [model, modifiers] = defineModel<string, 'trim'>({
  set(value) {
    return modifiers.trim ? value.trim() : value
  }
})
</script>

<template>
  <input v-model="model" />
</template>

这里:

  • 第一个泛型参数 string 是模型值类型;
  • 第二个泛型参数 'trim' 是允许出现的修饰符名称;
  • modifiers.trim 表示父组件是否写了 .trim
  • set 在子组件写入模型值前进行转换。

多个修饰符可以写成联合类型:

const [model, modifiers] = defineModel<
  string,
  'trim' | 'uppercase'
>({
  set(value) {
    let result = value

    if (modifiers.trim) {
      result = result.trim()
    }

    if (modifiers.uppercase) {
      result = result.toUpperCase()
    }

    return result
  }
})

使用:

<CustomInput v-model.trim.uppercase="title" />

这里的转换器只影响子组件向父组件回传的值。父组件传入的初始值不会因为 set 自动被改写。


6. defineModel 的默认值与状态不同步风险

const model = defineModel<number>({
  default: 1
})

如果父组件没有传入模型值,子组件内部可能得到默认值 1,但父组件自己的变量仍然可能是 undefined。这会导致两边初始状态不一致。

例如:

<!-- Parent.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import Rating from './Rating.vue'

const rating = ref<number>()
</script>

<template>
  <Rating v-model="rating" />
  <p>父组件:{{ rating }}</p>
</template>
<!-- Rating.vue -->
<script setup lang="ts">
const rating = defineModel<number>({
  default: 1
})
</script>

<template>
  <button @click="rating = (rating ?? 0) + 1">
    {{ rating }}
  </button>
</template>

子组件初始显示 1,但父组件的 rating 仍可能是 undefined。只有子组件首次写入模型并发出更新事件后,父组件才会收到值。

如果业务要求父子初始状态一致,应在父组件初始化:

const rating = ref(1)

或者不要在子组件的 defineModel 上设置默认值,而是让缺省状态由父组件统一决定。


五、四种声明组合成什么组件契约

考虑下面的组件:

<script setup lang="ts">
interface User {
  id: number
  name: string
}

const props = defineProps<{
  users: User[]
  disabled?: boolean
}>()

const emit = defineEmits<{
  select: [user: User]
}>()

const keyword = defineModel<string>('keyword')
</script>

<template>
  <div>
    <input
      v-model="keyword"
      :disabled="props.disabled"
      placeholder="搜索用户"
    />

    <button
      v-for="user in props.users"
      :key="user.id"
      :disabled="props.disabled"
      @click="emit('select', user)"
    >
      {{ user.name }}
    </button>
  </div>
</template>

它的外部契约可以理解为:

type ComponentContract = {
  props: {
    users: User[]
    disabled?: boolean
    keyword?: string
  }
  events: {
    'update:keyword': [value: string]
    select: [user: User]
  }
}

数据流如下:

flowchart LR
  P[父组件状态] -->|users / disabled| C[用户选择组件]
  P -->|keyword| C
  C -->|update:keyword| P
  C -->|select(user)| P

关键路径是:

  1. 父组件把 usersdisabled 作为只读输入传入;
  2. 父组件把 keyword 通过命名 v-model 传入;
  3. 子组件修改 keyword 时,defineModel 发出 update:keyword
  4. 子组件点击用户时,defineEmits 发出 select
  5. 父组件决定如何处理两个事件。

defineModel 只负责模型 prop 与更新事件的同步,不会取代普通业务事件。select 仍然需要使用 defineEmits 明确声明。

父组件使用方式:

<script setup lang="ts">
import { ref } from 'vue'
import UserPicker from './UserPicker.vue'

const keyword = ref('')
const users = ref([
  { id: 1, name: 'Alice' },
  { id: 2, name: 'Bob' }
])
const selected = ref<number | null>(null)

function handleSelect(user: { id: number; name: string }) {
  selected.value = user.id
}
</script>

<template>
  <UserPicker
    v-model:keyword="keyword"
    :users="users"
    @select="handleSelect"
  />

  <p>当前搜索:{{ keyword }}</p>
  <p>当前选择:{{ selected }}</p>
</template>

六、泛型组件:让组件的输入、输出共享同一个类型参数

1. 为什么需要泛型组件

非泛型组件通常只能写出固定类型:

interface Props {
  items: string[]
  selected: string
}

如果同一个列表组件既要支持 string,又要支持 UserProduct 或其他类型,就可能退化成:

interface Props {
  items: unknown[]
  selected: unknown
}

这会损失关键约束。更理想的关系是:

items: T[]
selected: T
select 事件: T

其中 T 是组件使用者决定的类型参数。

如果 T = User,组件就应当变成:

items: User[]
selected: User
select: User

如果 T = Product,则所有相关位置都应切换为 Product


2. 用 generic 声明组件类型参数

<!-- SelectList.vue -->
<script setup lang="ts" generic="T extends { id: string | number }">
const props = defineProps<{
  items: T[]
  selected?: T
}>()

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

<template>
  <ul>
    <li
      v-for="item in props.items"
      :key="item.id"
      @click="emit('select', item)"
    >
      <slot name="item" :item="item">
        {{ item.id }}
      </slot>
    </li>
  </ul>
</template>

generic="T extends { id: string | number }" 的含义是:

  • T 是一个类型变量;
  • T 不是任意类型;
  • 传入的类型必须至少具有 id 属性;
  • itemsT[]
  • selectedT
  • select 事件携带的参数也是 T

这相当于 TypeScript 中的泛型函数:

function selectList<T extends { id: string | number }>(
  items: T[],
  selected?: T
): T {
  // ...
}

但这里的泛型函数不是由应用代码手动调用,而是由 Vue 组件使用位置推断。


3. 泛型组件的完整使用示例

父组件:

<script setup lang="ts">
import { ref } from 'vue'
import SelectList from './SelectList.vue'

interface User {
  id: number
  name: string
  role: 'admin' | 'member'
}

const users = ref<User[]>([
  { id: 1, name: 'Alice', role: 'admin' },
  { id: 2, name: 'Bob', role: 'member' }
])

const selectedUser = ref<User>()

function handleSelect(user: User) {
  selectedUser.value = user
}
</script>

<template>
  <SelectList
    :items="users"
    :selected="selectedUser"
    @select="handleSelect"
  >
    <template #item="{ item }">
      <strong>{{ item.name }}</strong>
      <small>({{ item.role }})</small>
    </template>
  </SelectList>
</template>

当模板语言服务能够从 :items="users" 推断出 T = User 时:

  • item 被推断为 User
  • 插槽中的 item.nameitem.role 可以获得类型检查;
  • @select 的参数被推断为 User
  • selected 需要与 User 对应。

如果错误地传入不满足约束的对象:

const invalidItems = [
  { name: 'No ID' }
]
<SelectList :items="invalidItems" />

TypeScript 应报告它缺少泛型约束要求的 id 属性。


4. 泛型约束的必要性

下面这个泛型组件无法安全地使用 item.id

<script setup lang="ts" generic="T">
const props = defineProps<{
  items: T[]
}>()
</script>

<template>
  <div v-for="item in props.items" :key="item.id">
    {{ item }}
  </div>
</template>

因为 T 可以是 stringnumber 或没有 id 的对象。泛型只表示“类型稍后确定”,不表示“类型具有任意属性”。

正确做法是添加约束:

<script setup lang="ts" generic="T extends { id: string | number }">
const props = defineProps<{
  items: T[]
}>()
</script>

泛型约束的推导过程是:

  1. T 代表某种具体类型;
  2. 模板需要访问 T.id
  3. 要让访问成立,必须保证所有可能的 T 都有 id
  4. T extends { id: string | number } 表达这个保证;
  5. 使用者传入 User 时,只要 User.id 满足约束即可。

5. 泛型类型参数可以声明默认值

generic 属性使用 TypeScript 泛型语法,因此可以声明多个参数、约束和默认类型。例如:

<script setup lang="ts"
  generic="T extends { id: string | number }, K extends keyof T = 'id'">
const props = defineProps<{
  items: T[]
  trackBy?: K
}>()
</script>

这里:

  • T 是列表元素类型;
  • K 必须是 T 的键;
  • 如果调用方没有推断出 K,默认使用 'id'

但要注意,类型参数默认值不会在浏览器运行时生成新的逻辑。它只影响 TypeScript 检查,运行时的 trackBy 是否存在仍要由代码显式处理。


6. 推断失败时显式指定泛型

大多数情况下,模板可以从 props 推断泛型。如果输入数据类型过于宽泛,推断结果可能变成 unknown 或无法满足约束。

Vue 提供了模板注释形式来显式指定泛型:

<!-- @vue-generic {import('@/types').User} -->
<SelectList
  :items="users"
  @select="handleSelect"
/>

也可以指定带路径的类型:

<!-- @vue-generic {import('@/api').ApiUser} -->
<ApiSelect :items="apiUsers" />

这依赖 Vue 语言工具对该语法的支持,编辑器和 vue-tsc 版本应与项目中的 Vue 版本保持兼容。它是类型检查提示,不是会进入浏览器的运行时指令。


七、泛型组件在运行时的边界

1. 泛型会被擦除

浏览器运行时不会知道 TUser 还是 Product

<script setup lang="ts" generic="T">
const props = defineProps<{
  items: T[]
}>()
</script>

编译后不存在一个可以运行时读取的 T 构造器。泛型主要服务于:

  • 编辑器类型提示;
  • vue-tsc 检查;
  • 组件调用方的 props、事件和插槽类型关联。

因此不能根据泛型参数在运行时自动执行:

// 不存在这样的运行时能力
if (T === User) {
  // ...
}

如果运行时必须区分数据类型,需要传入显式策略:

<script setup lang="ts" generic="T">
const props = defineProps<{
  items: T[]
  getKey: (item: T) => string | number
}>()
</script>

父组件:

<SelectList
  :items="users"
  :get-key="user => user.id"
/>

getKey 是真实的 JavaScript 函数,因此运行时可以调用;T 只是帮助检查这个函数参数和 items 的对应关系。


2. 泛型无法替代运行时数据校验

接口数据:

interface User {
  id: number
  name: string
}

并不能保证下面的数据在运行时真的符合 User

const users = await fetch('/api/users').then(response => response.json()) as User[]

as User[] 只是告诉 TypeScript“把它当作这种类型”,不会检查服务器返回值。如果服务端返回:

[
  { "id": "not-a-number", "name": null }
]

Vue 泛型组件仍可能收到错误数据。

边界明确的应用应在网络层进行运行时验证,例如使用手写校验函数或运行时 schema 库:

function isUser(value: unknown): value is User {
  if (typeof value !== 'object' || value === null) {
    return false
  }

  const record = value as Record<string, unknown>

  return (
    typeof record.id === 'number' &&
    typeof record.name === 'string'
  )
}

function parseUsers(value: unknown): User[] {
  if (!Array.isArray(value) || !value.every(isUser)) {
    throw new Error('接口返回的用户数据格式错误')
  }

  return value
}

这样泛型组件接收到的 User[] 才同时具备类型层和运行时层的依据。


八、把 definePropsdefineEmitsdefineModel 放进一个可运行组件

下面实现一个支持以下功能的搜索选择器:

  • 泛型选项 T
  • options 作为 props;
  • v-model 保存搜索关键词;
  • select 事件返回 T
  • 通过 label 函数渲染任意类型。
<!-- GenericSearchSelect.vue -->
<script setup lang="ts" generic="T">
const props = defineProps<{
  options: T[]
  label: (option: T) => string
  disabled?: boolean
}>()

const keyword = defineModel<string>({
  default: ''
})

const emit = defineEmits<{
  select: [option: T]
}>()

function selectOption(option: T) {
  emit('select', option)
}
</script>

<template>
  <div>
    <input
      v-model="keyword"
      :disabled="props.disabled"
      placeholder="输入关键词"
    />

    <ul>
      <li
        v-for="option in props.options"
        :key="props.label(option)"
        @click="selectOption(option)"
      >
        {{ props.label(option) }}
      </li>
    </ul>
  </div>
</template>

父组件:

<script setup lang="ts">
import { computed, ref } from 'vue'
import GenericSearchSelect from './GenericSearchSelect.vue'

interface Product {
  id: number
  name: string
  price: number
}

const keyword = ref('')
const selectedProduct = ref<Product>()

const products = ref<Product[]>([
  { id: 1, name: '键盘', price: 299 },
  { id: 2, name: '鼠标', price: 99 }
])

const visibleProducts = computed(() => {
  const value = keyword.value.trim().toLowerCase()

  if (!value) {
    return products.value
  }

  return products.value.filter(product =>
    product.name.toLowerCase().includes(value)
  )
})

function handleSelect(product: Product) {
  selectedProduct.value = product
}
</script>

<template>
  <GenericSearchSelect
    v-model="keyword"
    :options="visibleProducts"
    :label="product => `${product.name} ¥${product.price}`"
    @select="handleSelect"
  />

  <p v-if="selectedProduct">
    已选择:{{ selectedProduct.name }}
  </p>
</template>

这个例子中,类型关系如下:

options: Product[]
label: (product: Product) => string
select 事件: (product: Product) => void
v-model: string

T 只用于选项相关的数据流,关键词模型单独是 string。如果把 defineModel<string>() 错写成 defineModel<T>(),就会把搜索关键词错误地声明成产品对象,导致组件契约与模板行为不一致。

需要注意示例中的 :key="props.label(option)" 只是为了演示,实际使用中如果标签可能重复,应提供独立的 getKey: (option: T) => string | number,避免重复 key:

const props = defineProps<{
  options: T[]
  label: (option: T) => string
  getKey: (option: T) => string | number
}>()
<li
  v-for="option in props.options"
  :key="props.getKey(option)"
>
  {{ props.label(option) }}
</li>

九、常见错误及其诊断路径

1. 把 props 当成本地可变状态

错误:

const props = defineProps<{ count: number }>()

function reset() {
  props.count = 0
}

props 是只读的。开发环境通常会出现类似“不要直接修改 prop”的警告,或者 TypeScript 直接报错。

如果组件需要本地副本:

import { ref } from 'vue'

const props = defineProps<{ count: number }>()
const localCount = ref(props.count)

但这只建立了初始复制关系。父组件以后修改 props.count 时,localCount 不会自动同步。需要同步时应明确写出规则:

import { ref, watch } from 'vue'

const props = defineProps<{ count: number }>()
const localCount = ref(props.count)

watch(
  () => props.count,
  value => {
    localCount.value = value
  }
)

如果目标是可编辑并回传给父组件,通常应使用 defineModel,而不是偷偷修改 props。


2. 事件名称或参数不匹配

const emit = defineEmits<{
  save: [title: string]
}>()

emit('saved', '标题') // 事件名称错误
emit('save')         // 参数缺失
emit('save', 123)    // 参数类型错误

诊断时先检查三件事:

  1. defineEmits 中是否声明了完全相同的事件名称;
  2. emit 调用的参数数量是否正确;
  3. 父组件监听名称是否对应,例如 @save 而不是 @saved

事件名称不会因为“看起来相近”而自动匹配。


3. 手动实现 v-model 时 prop 和事件没有成对出现

错误:

const props = defineProps<{
  value: string
}>()

const emit = defineEmits<{
  change: [value: string]
}>()

如果父组件写:

<MyInput v-model="title" />

Vue 默认寻找的是:

modelValue
update:modelValue

而不是 valuechange

可以改为手动的默认模型契约:

const props = defineProps<{
  modelValue: string
}>()

const emit = defineEmits<{
  'update:modelValue': [value: string]
}>()

或者直接:

const model = defineModel<string>()

如果使用命名模型,则父子两侧名称必须一致:

const title = defineModel<string>('title')
<MyInput v-model:title="title" />

4. 把 defineModel 当作普通本地 ref

const model = defineModel<string>()
model.value = '新的值'

这次赋值不是单纯修改组件内部变量,而是会触发 update:modelValue 事件。若父组件没有绑定对应模型,子组件发出的更新没有父状态接收。

例如:

<MyInput />

子组件仍可以写入自己的模型 ref,但没有外部 v-model 来接收更新。这种情况下,若组件需要支持“可绑定”和“未绑定”两种模式,必须明确设计未绑定时的行为,而不能假设模型一定存在于父组件中。


5. 只在类型层声明复杂约束,期待运行时自动校验

type Props = {
  percentage: number & { __range: '0-100' }
}

const props = defineProps<Props>()

这种类型并不会让 Vue 自动检查数值是否处于 0100。如果运行时数据不可信,需要显式验证:

function isPercentage(value: unknown): value is number {
  return typeof value === 'number' && value >= 0 && value <= 100
}

类型品牌、条件类型、联合类型和泛型都不能凭空生成完整的运行时验证逻辑。


6. 泛型推断失败却误以为组件运行时出错

如果编辑器中插槽参数显示为 unknown,优先检查:

  • items 是否被声明成 unknown[]
  • 数据是否经过了过宽的类型断言;
  • generic 约束是否与实际对象属性冲突;
  • Vue 语言服务和 vue-tsc 是否版本过旧;
  • 是否需要使用 @vue-generic 显式指定类型。

泛型组件的主要错误表现发生在类型检查阶段。它不会在浏览器控制台打印“泛型推断失败”,因为运行时根本不存在 T 这个类型实体。


十、如何选择四种能力

可以按组件契约的方向选择:

只接收父组件输入:defineProps

const props = defineProps<{
  data: Data
}>()

适合配置、列表数据、状态快照和只读输入。

只通知父组件:defineEmits

const emit = defineEmits<{
  submit: [data: Data]
}>()

适合保存、取消、选择、删除、加载失败等业务事件。

表达可编辑的共享值:defineModel

const value = defineModel<string>()

适合输入框、开关、选中项、分页页码、弹窗显示状态等需要父子同步的单个或多个值。

让同一个组件适配多种数据类型:泛型组件

<script setup lang="ts" generic="T">

适合列表、表格、选择器、树节点渲染和通用数据容器。泛型应当表达真实的数据关联,例如 items: T[]select: T,而不是为了让代码看起来“高级”而添加无关类型参数。

四者可以同时使用,但职责不同:

defineProps  = 父 -> 子的输入
defineEmits  = 子 -> 父的通知
defineModel  = 一组特定命名的输入与更新通知
generic      = 在类型层参数化上述契约

编译宏的价值不在于减少几个字符,而在于把组件的外部契约直接写在组件实现附近:哪些数据可以传入、哪些事件可以发出、哪些值可以双向绑定,以及这些关系是否随着泛型类型一起变化。理解这些宏最终生成的 props、事件和模型关系后,遇到类型报错、运行时警告或双向绑定不同步时,就可以沿着真实的数据流定位问题,而不是把它们当作模板语法的特殊魔法。


系列导航与关联阅读

官方资料

本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。