文章目录
- 前言
- [一、Props 类型声明](#一、Props 类型声明)
-
- [1.1 运行时对象写法](#1.1 运行时对象写法)
- [1.2 泛型写法](#1.2 泛型写法)
- [1.3 withDefaults 默认值](#1.3 withDefaults 默认值)
- [1.4 Props 类型导出给父组件](#1.4 Props 类型导出给父组件)
- [二、Props 解构与响应性](#二、Props 解构与响应性)
-
- [2.1 不建议直接普通解构](#2.1 不建议直接普通解构)
- [2.2 使用 toRefs 保留响应性](#2.2 使用 toRefs 保留响应性)
- [三、Emits 类型声明](#三、Emits 类型声明)
-
- [3.1 数组写法](#3.1 数组写法)
- [3.2 函数重载写法](#3.2 函数重载写法)
- [3.3 命名元组写法](#3.3 命名元组写法)
- [3.4 父组件监听事件](#3.4 父组件监听事件)
- [四、v-model 的类型](#四、v-model 的类型)
-
- [4.1 modelValue + update:modelValue](#4.1 modelValue + update:modelValue)
- [4.2 多个 v-model](#4.2 多个 v-model)
- [五、模板 Ref 类型](#五、模板 Ref 类型)
-
- [5.1 DOM ref](#5.1 DOM ref)
- [5.2 组件 ref](#5.2 组件 ref)
- [六、defineExpose 暴露方法类型](#六、defineExpose 暴露方法类型)
- [七、Slot 类型简单了解](#七、Slot 类型简单了解)
- 八、面试聚焦
-
- [8.1 defineProps 泛型写法为何推荐?](#8.1 defineProps 泛型写法为何推荐?)
- [8.2 Props 解构会不会丢失响应性?](#8.2 Props 解构会不会丢失响应性?)
- [8.3 defineEmits 如何限制参数?](#8.3 defineEmits 如何限制参数?)
- [8.4 模板 ref 为什么要写 null?](#8.4 模板 ref 为什么要写 null?)
- 九、易混淆点
- 十、思考与练习
- 总结
前言
在 Vue 3 + TypeScript 项目中,组件类型是最常写、也最容易踩坑的一块。Props 写得不清楚,父组件传参容易出错;Emits 没有约束,事件名和参数容易写散;模板 ref 没有类型,调用 DOM 或子组件方法时经常只能 any 或非空断言硬顶。
本篇围绕组件开发中最高频的三类类型展开:
defineProps:父传子参数如何声明类型与默认值defineEmits:子传父事件如何约束事件名与参数ref/ 模板 ref:如何拿到 DOM、组件实例与暴露方法的类型
一、Props 类型声明
1.1 运行时对象写法
Vue 仍支持传统运行时对象写法,适合需要运行时校验、默认值、必填项的场景:
vue
<script setup lang="ts">
const props = defineProps({
title: {
type: String,
required: true
},
count: {
type: Number,
default: 0
},
disabled: {
type: Boolean,
default: false
}
})
console.log(props.title)
</script>
这种写法的优点是保留 Vue 的运行时 props 校验;缺点是复杂对象、联合类型、字面量类型表达起来不够自然。
1.2 泛型写法
在 <script setup lang="ts"> 中,更推荐用泛型声明 Props:
vue
<script setup lang="ts">
interface UserCardProps {
id: number
name: string
avatar?: string
role: 'admin' | 'user' | 'guest'
}
const props = defineProps<UserCardProps>()
console.log(props.name)
</script>
泛型写法更接近 TS 的类型系统,适合:
- 复杂对象类型
- 联合类型
- 字面量类型
- 从其他文件导入类型
- 组件之间复用 Props 类型
typescript
// types/user.ts
export interface User {
id: number
name: string
email?: string
}
vue
<script setup lang="ts">
import type { User } from '@/types/user'
defineProps<{
user: User
size?: 'small' | 'medium' | 'large'
}>()
</script>
注意 :import type 只导入类型,编译后不会进入运行时代码,推荐在 TS 项目中养成这个习惯。
1.3 withDefaults 默认值
泛型写法没有运行时对象里的 default 字段,如果要设置默认值,需要配合 withDefaults:
vue
<script setup lang="ts">
interface Props {
title: string
count?: number
size?: 'small' | 'medium' | 'large'
tags?: string[]
}
const props = withDefaults(defineProps<Props>(), {
count: 0,
size: 'medium',
tags: () => []
})
</script>
这里有两个重点:
count?、size?表示父组件可以不传。- 数组、对象默认值建议用函数返回,避免引用共享。
withDefaults 后,组件内部读取 props.count 时,TS 会知道它已经有默认值,不再是 number | undefined。
1.4 Props 类型导出给父组件
如果某个组件的 Props 会被父组件、配置项或测试复用,可以导出类型:
vue
<script setup lang="ts">
export interface UserCardProps {
id: number
name: string
role?: 'admin' | 'user'
}
withDefaults(defineProps<UserCardProps>(), {
role: 'user'
})
</script>
父组件或其他模块可以复用:
typescript
import type { UserCardProps } from '@/components/UserCard.vue'
const defaultUser: UserCardProps = {
id: 1,
name: '张三',
role: 'admin'
}
这种写法在中后台项目中很实用,尤其是表格列配置、弹窗表单配置和组件测试。
二、Props 解构与响应性
2.1 不建议直接普通解构
defineProps 返回的是响应式 props 对象。普通解构在一些写法中容易丢失响应性:
vue
<script setup lang="ts">
const props = defineProps<{
keyword: string
}>()
// 推荐:通过 props.keyword 使用
console.log(props.keyword)
</script>
如果你需要在逻辑里频繁使用某个字段,优先保持 props.xxx,语义清楚,也不容易误判响应性。
2.2 使用 toRefs 保留响应性
需要解构时,可以用 toRefs:
vue
<script setup lang="ts">
import { toRefs, watch } from 'vue'
const props = defineProps<{
keyword: string
page: number
}>()
const { keyword, page } = toRefs(props)
watch(keyword, (val) => {
console.log('keyword changed:', val)
})
console.log(page.value)
</script>
toRefs(props) 得到的是 Ref,所以在 <script> 中需要 .value,模板中自动解包。
三、Emits 类型声明
3.1 数组写法
最简单的写法只限制事件名:
vue
<script setup lang="ts">
const emit = defineEmits(['close', 'submit'])
emit('close')
emit('submit')
</script>
这种写法能限制事件名,但不能限制事件参数。比如 submit 到底要不要传表单数据,TS 并不知道。
3.2 函数重载写法
传统类型写法可以用函数重载描述不同事件:
vue
<script setup lang="ts">
interface FormData {
username: string
password: string
}
const emit = defineEmits<{
(e: 'close'): void
(e: 'submit', data: FormData): void
(e: 'change', value: string | number): void
}>()
emit('close')
emit('submit', { username: 'admin', password: '123456' })
emit('change', 'enabled')
</script>
优点是兼容性好,很多老项目和库类型里都能看到这种写法。
3.3 命名元组写法
Vue 3.3+ 支持更简洁的写法:
vue
<script setup lang="ts">
interface FormData {
username: string
password: string
}
const emit = defineEmits<{
close: []
submit: [data: FormData]
change: [value: string | number]
}>()
emit('close')
emit('submit', { username: 'admin', password: '123456' })
emit('change', 1)
</script>
这类写法更像"事件名 → 参数列表"的映射,读起来直观,也适合团队统一规范。
3.4 父组件监听事件
子组件:
vue
<!-- UserForm.vue -->
<script setup lang="ts">
interface UserForm {
name: string
age: number
}
const emit = defineEmits<{
submit: [form: UserForm]
cancel: []
}>()
const onSubmit = () => {
emit('submit', {
name: '张三',
age: 18
})
}
</script>
<template>
<button @click="onSubmit">提交</button>
<button @click="emit('cancel')">取消</button>
</template>
父组件:
vue
<script setup lang="ts">
import UserForm from './UserForm.vue'
const handleSubmit = (form: { name: string; age: number }) => {
console.log(form.name, form.age)
}
</script>
<template>
<UserForm @submit="handleSubmit" @cancel="console.log('cancel')" />
</template>
在 IDE 中,事件名、参数数量、参数类型都能得到提示。
四、v-model 的类型
4.1 modelValue + update:modelValue
Vue 3 中,组件上的 v-model 本质是:
- 父传子:
modelValue - 子传父:
update:modelValue
vue
<!-- BaseInput.vue -->
<script setup lang="ts">
defineProps<{
modelValue: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
</script>
<template>
<input
:value="modelValue"
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
/>
</template>
父组件:
vue
<script setup lang="ts">
import { ref } from 'vue'
import BaseInput from './BaseInput.vue'
const keyword = ref('')
</script>
<template>
<BaseInput v-model="keyword" />
</template>
这里 modelValue 是 string,所以父组件的 keyword 也应是 Ref<string>。
4.2 多个 v-model
多个 v-model 会变成不同的 prop 与事件:
vue
<!-- SearchPanel.vue -->
<script setup lang="ts">
defineProps<{
keyword: string
page: number
}>()
const emit = defineEmits<{
'update:keyword': [value: string]
'update:page': [value: number]
}>()
</script>
<template>
<input
:value="keyword"
@input="emit('update:keyword', ($event.target as HTMLInputElement).value)"
/>
<button @click="emit('update:page', page + 1)">下一页</button>
</template>
父组件:
vue
<template>
<SearchPanel v-model:keyword="keyword" v-model:page="page" />
</template>
五、模板 Ref 类型
5.1 DOM ref
访问 DOM 节点时,要把初始值写成 null,并显式标注元素类型:
vue
<script setup lang="ts">
import { onMounted, ref } from 'vue'
const inputRef = ref<HTMLInputElement | null>(null)
onMounted(() => {
inputRef.value?.focus()
})
</script>
<template>
<input ref="inputRef" />
</template>
常见 DOM 类型:
| 元素 | 类型 |
|---|---|
input |
HTMLInputElement |
textarea |
HTMLTextAreaElement |
select |
HTMLSelectElement |
div |
HTMLDivElement |
form |
HTMLFormElement |
口诀 :模板 ref 初始化为 null,使用时可选链 ?.。
5.2 组件 ref
如果 ref 指向子组件,可以用 InstanceType<typeof Comp>:
vue
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import UserDialog from './UserDialog.vue'
const dialogRef = ref<InstanceType<typeof UserDialog> | null>(null)
onMounted(() => {
dialogRef.value?.open()
})
</script>
<template>
<UserDialog ref="dialogRef" />
</template>
这要求子组件通过 defineExpose 暴露方法,否则父组件拿不到。
六、defineExpose 暴露方法类型
子组件默认是关闭的,父组件不能随便访问内部变量。需要暴露给父组件的方法,用 defineExpose:
vue
<!-- UserDialog.vue -->
<script setup lang="ts">
import { ref } from 'vue'
const visible = ref(false)
const open = () => {
visible.value = true
}
const close = () => {
visible.value = false
}
defineExpose({
open,
close
})
</script>
<template>
<div v-if="visible">用户弹窗</div>
</template>
父组件:
vue
<script setup lang="ts">
import { ref } from 'vue'
import UserDialog from './UserDialog.vue'
const dialogRef = ref<InstanceType<typeof UserDialog> | null>(null)
const showDialog = () => {
dialogRef.value?.open()
}
</script>
<template>
<button @click="showDialog">打开弹窗</button>
<UserDialog ref="dialogRef" />
</template>
如果想让暴露接口更清晰,也可以单独声明类型:
typescript
export interface UserDialogExpose {
open: () => void
close: () => void
}
vue
<script setup lang="ts">
import type { UserDialogExpose } from './types'
const exposed: UserDialogExpose = {
open: () => {},
close: () => {}
}
defineExpose(exposed)
</script>
七、Slot 类型简单了解
组件类型除了 Props、Emits、Ref,还有一个常见点是 Slot。Vue 3.3+ 可用 defineSlots 描述插槽参数:
vue
<script setup lang="ts">
interface Row {
id: number
name: string
}
defineProps<{
list: Row[]
}>()
defineSlots<{
default(props: { row: Row; index: number }): any
empty(): any
}>()
</script>
<template>
<div v-if="list.length">
<slot
v-for="(row, index) in list"
:key="row.id"
:row="row"
:index="index"
/>
</div>
<slot v-else name="empty" />
</template>
父组件使用时,row 与 index 会有类型提示:
vue
<template>
<UserList :list="users">
<template #default="{ row, index }">
{{ index }} - {{ row.name }}
</template>
<template #empty>
暂无数据
</template>
</UserList>
</template>
Slot 类型不是本文重点,但在封装表格、列表、弹窗 footer 时很常见,建议知道 defineSlots 这个入口。
八、面试聚焦
8.1 defineProps 泛型写法为何推荐?
泛型写法更贴合 TypeScript,复杂类型表达更自然,可复用外部类型,也能获得更完整的 IDE 推导。运行时对象写法适合需要 Vue 运行时校验的场景,二者按需求选择。
8.2 Props 解构会不会丢失响应性?
直接普通解构容易让后续代码失去对 props 更新的感知。需要解构并保持响应性时,使用 toRefs(props);简单场景优先直接使用 props.xxx。
8.3 defineEmits 如何限制参数?
可以用函数重载写法,也可以用 Vue 3.3+ 的命名元组写法:
typescript
const emit = defineEmits<{
submit: [data: FormData]
close: []
}>()
这样事件名、参数数量、参数类型都能被 TS 检查。
8.4 模板 ref 为什么要写 null?
组件挂载前 DOM 或子组件实例还不存在,所以初始值应为 null:
typescript
const inputRef = ref<HTMLInputElement | null>(null)
使用时通过 ?. 或在 onMounted 后访问。
九、易混淆点
- 运行时 props 写法 有 Vue 校验;泛型写法更适合 TS 复杂类型。
withDefaults用来给泛型 Props 设置默认值,数组和对象默认值建议写函数。props普通解构要谨慎,需要响应性时用toRefs。defineEmits不只是声明事件名,还可以约束参数。v-model本质是modelValue+update:modelValue。- DOM ref 通常写成
ref<HTMLInputElement | null>(null)。 - 组件 ref 通常写成
ref<InstanceType<typeof Comp> | null>(null)。 - 子组件方法需要
defineExpose后,父组件 ref 才能访问。
十、思考与练习
1. defineProps 的运行时对象写法和泛型写法有什么区别?
解析:运行时对象写法有 Vue 的运行时校验,适合简单类型和默认值;泛型写法更适合复杂对象、联合类型、外部类型复用,TS 推导更自然。
2. 泛型 Props 如何设置默认值?
解析:使用 withDefaults(defineProps<Props>(), defaults),数组和对象默认值建议用函数返回。
3. 为什么不建议随手写 const { title } = defineProps<Props>()?
解析:普通解构可能带来响应性误用。需要保持响应性时用 toRefs(props),否则优先 props.title。
4. 如何声明一个 submit 事件,参数为 { name: string; age: number }?
解析:
typescript
const emit = defineEmits<{
submit: [form: { name: string; age: number }]
}>()
5. 父组件如何拿到子组件暴露的 open 方法?
解析:子组件 defineExpose({ open }),父组件用 ref<InstanceType<typeof Child> | null>(null) 获取组件实例,并通过 childRef.value?.open() 调用。
总结
- Props :推荐泛型写法,复杂类型更清晰;默认值用
withDefaults。 - Props 解构 :优先
props.xxx;需要响应性解构时用toRefs。 - Emits:用类型约束事件名和参数,避免事件写散。
- v-model:本质是 prop + update 事件,类型要同时约束。
- 模板 ref :DOM ref 写元素类型,组件 ref 写
InstanceType<typeof Comp>。 - defineExpose:子组件显式暴露方法,父组件才能通过 ref 调用。