组件类型-Props-Emits-Ref

文章目录

  • 前言
  • [一、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>

这里有两个重点:

  1. count?size? 表示父组件可以不传。
  2. 数组、对象默认值建议用函数返回,避免引用共享。

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>

这里 modelValuestring,所以父组件的 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>

父组件使用时,rowindex 会有类型提示:

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 后访问。


九、易混淆点

  1. 运行时 props 写法 有 Vue 校验;泛型写法更适合 TS 复杂类型。
  2. withDefaults 用来给泛型 Props 设置默认值,数组和对象默认值建议写函数。
  3. props 普通解构要谨慎,需要响应性时用 toRefs
  4. defineEmits 不只是声明事件名,还可以约束参数。
  5. v-model 本质是 modelValue + update:modelValue
  6. DOM ref 通常写成 ref<HTMLInputElement | null>(null)
  7. 组件 ref 通常写成 ref<InstanceType<typeof Comp> | null>(null)
  8. 子组件方法需要 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 调用。