Vue 基础体系 · 第 29/70 篇。示例基于 Vue 3、Composition API、TypeScript 与现代 Vite 工具链;版本敏感能力会单独标注。
Vue 编译宏:defineProps、defineEmits、defineModel 与泛型组件
在 Vue 3 的 <script setup> 中,defineProps、defineEmits、defineModel 和 generic 不是普通的运行时函数,而是由 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 声明,并绑定到setup的props参数;defineEmits()转换为组件 emits 声明,并绑定到setup的emit参数;- 顶层声明的变量暴露给当前组件模板;
- 顶层的
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
如果父组件后来把 count 从 1 改成 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 的子组件至少需要:
- 接收名为
modelValue的 prop; - 发出名为
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时,相当于读取modelValueprop; - 设置
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
关键路径是:
- 父组件把
users和disabled作为只读输入传入; - 父组件把
keyword通过命名v-model传入; - 子组件修改
keyword时,defineModel发出update:keyword; - 子组件点击用户时,
defineEmits发出select; - 父组件决定如何处理两个事件。
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,又要支持 User、Product 或其他类型,就可能退化成:
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属性; items是T[];selected是T;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.name和item.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 可以是 string、number 或没有 id 的对象。泛型只表示“类型稍后确定”,不表示“类型具有任意属性”。
正确做法是添加约束:
<script setup lang="ts" generic="T extends { id: string | number }">
const props = defineProps<{
items: T[]
}>()
</script>
泛型约束的推导过程是:
T代表某种具体类型;- 模板需要访问
T.id; - 要让访问成立,必须保证所有可能的
T都有id; T extends { id: string | number }表达这个保证;- 使用者传入
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. 泛型会被擦除
浏览器运行时不会知道 T 是 User 还是 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[] 才同时具备类型层和运行时层的依据。
八、把 defineProps、defineEmits 与 defineModel 放进一个可运行组件
下面实现一个支持以下功能的搜索选择器:
- 泛型选项
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) // 参数类型错误
诊断时先检查三件事:
defineEmits中是否声明了完全相同的事件名称;emit调用的参数数量是否正确;- 父组件监听名称是否对应,例如
@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
而不是 value 和 change。
可以改为手动的默认模型契约:
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 自动检查数值是否处于 0 到 100。如果运行时数据不可信,需要显式验证:
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 完整学习路线:从响应式与组件到工程化、SSR 和生产交付
- 上一篇:Vue 渲染函数与 JSX:VNode、h、Slots、事件和适用边界
- 下一篇:Vue 自定义指令:生命周期、DOM 行为、清理和组件替代边界
官方资料
本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论