文章目录
- 前言
- 一、Props 类型声明
- 1.1 运行时对象写法
- 1.2 泛型写法
- 1.3 withDefaults 默认值
- 1.4 Props 类型导出给父组件
- 二、Props 解构与响应性
- 2.1 不建议直接普通解构
- 2.2 使用 toRefs 保留响应性
- 三、Emits 类型声明
- 3.1 数组写法
- 3.2 函数重载写法
- 3.3 命名元组写法
- 3.4 父组件监听事件
- 四、v-model 的类型
- 4.1 modelValue + update:modelValue
- 4.2 多个 v-model
- 五、模板 Ref 类型
- 5.1 DOM ref
- 5.2 组件 ref
- 六、defineExpose 暴露方法类型
- 七、Slot 类型简单了解
- 八、面试聚焦
- 8.1 defineProps 泛型写法为何推荐?
- 8.2 Props 解构会不会丢失响应性?
- 8.3 defineEmits 如何限制参数?
- 8.4 模板 ref 为什么要写 null?
- 九、易混淆点
- 十、思考与练习
- 总结
前言
在 Vue 3 + TypeScript 项目中,组件类型是最常写、也最容易踩坑的一块。Props 写得不清楚,父组件传参容易出错;Emits 没有约束,事件名和参数容易写散;模板 ref 没有类型,调用 DOM 或子组件方法时经常只能any或非空断言硬顶。
本篇围绕组件开发中最高频的三类类型展开:
defineProps:父传子参数如何声明类型与默认值defineEmits:子传父事件如何约束事件名与参数ref/ 模板 ref:如何拿到 DOM、组件实例与暴露方法的类型
一、Props 类型声明
1.1 运行时对象写法
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:
<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 类型
// types/user.tsexportinterfaceUser{id:numbername:stringemail?:string}<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:
<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 会被父组件、配置项或测试复用,可以导出类型:
<script setup lang="ts"> export interface UserCardProps { id: number name: string role?: 'admin' | 'user' } withDefaults(defineProps<UserCardProps>(), { role: 'user' }) </script>父组件或其他模块可以复用:
importtype{UserCardProps}from'@/components/UserCard.vue'constdefaultUser:UserCardProps={id:1,name:'张三',role:'admin'}这种写法在中后台项目中很实用,尤其是表格列配置、弹窗表单配置和组件测试。
二、Props 解构与响应性
2.1 不建议直接普通解构
defineProps返回的是响应式 props 对象。普通解构在一些写法中容易丢失响应性:
<script setup lang="ts"> const props = defineProps<{ keyword: string }>() // 推荐:通过 props.keyword 使用 console.log(props.keyword) </script>如果你需要在逻辑里频繁使用某个字段,优先保持props.xxx,语义清楚,也不容易误判响应性。
2.2 使用 toRefs 保留响应性
需要解构时,可以用toRefs:
<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 数组写法
最简单的写法只限制事件名:
<script setup lang="ts"> const emit = defineEmits(['close', 'submit']) emit('close') emit('submit') </script>这种写法能限制事件名,但不能限制事件参数。比如submit到底要不要传表单数据,TS 并不知道。
3.2 函数重载写法
传统类型写法可以用函数重载描述不同事件:
<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+ 支持更简洁的写法:
<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 父组件监听事件
子组件:
<!-- 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>父组件:
<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
<!-- 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>父组件:
<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 与事件:
<!-- 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>父组件:
<template> <SearchPanel v-model:keyword="keyword" v-model:page="page" /> </template>五、模板 Ref 类型
5.1 DOM ref
访问 DOM 节点时,要把初始值写成null,并显式标注元素类型:
<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>:
<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:
<!-- 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>父组件:
<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>如果想让暴露接口更清晰,也可以单独声明类型:
exportinterfaceUserDialogExpose{open:()=>voidclose:()=>void}<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描述插槽参数:
<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会有类型提示:
<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+ 的命名元组写法:
constemit=defineEmits<{submit:[data:FormData]close:[]}>()这样事件名、参数数量、参数类型都能被 TS 检查。
8.4 模板 ref 为什么要写 null?
组件挂载前 DOM 或子组件实例还不存在,所以初始值应为null:
constinputRef=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 }?
解析:
constemit=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 调用。