当前位置: 首页 > news >正文

组件类型-Props-Emits-Ref

文章目录

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

这里有两个重点:

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

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>

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

元素类型
inputHTMLInputElement
textareaHTMLTextAreaElement
selectHTMLSelectElement
divHTMLDivElement
formHTMLFormElement

口诀:模板 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>

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

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


九、易混淆点

  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 }

解析:

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 调用。
http://www.jsqmd.com/news/1113150/

相关文章:

  • 一次性讲清楚 Node.js 事件循环(Event Loop)
  • Selenium自动化测试与动态网页爬虫实战指南
  • 二十年只为超越,ROG玩家国度与蜘蛛侠共赴英雄新章
  • 搭建微信电商小程序要多少钱:定制和SaaS商城怎么选更适合实体店
  • ThinkPad风扇控制终极指南:TPFanCtrl2实现128级无级调速与智能温控
  • DALL·E Mini本地部署实战:轻量级文本生成图像入门指南
  • CPPM注册职业采购经理怎么报名?报考条件、费用和证书查询一次说清
  • 梯度下降工程实践:从GPU训练到嵌入式微调的全栈调试指南
  • 2025-2026中国高端门窗十大品牌解析:核心实力与行业发展指南
  • 自动驾驶量产落地的11个关键节点与三大非热点机会
  • 智慧校园运维升级实战:IoT智能锁通断电联动+身份核验解决方案落地
  • 云服务器别只看CPU:一篇讲透带宽、计费与长期成本的实用指南
  • 支付系统重复收费难题:幂等键依赖的四个假设及应对之策
  • 5步快速掌握Godot逆向工程工具:资源提取与脚本反编译终极指南
  • 3分钟掌握BurpCrypto插件:实战DES加密登录接口自动化测试
  • 驾照翻译多少钱?怎么办理?费用明细+正规办理流程保姆级攻略
  • 机器学习生产化落地:四层健康探针实战指南
  • 从提示词工程到 Harness Engineering:打造坚实可靠的 AI 开发系统
  • ZCode对接商汤免费模型全流程教程
  • Python登录小程序开发教程
  • 为什么AI语音机器人要分营销和客服两种
  • 固定式与手持式RFID阅读器选型:工业RFID系统架构与部署分析
  • 国产大模型编码能力实测:DeepSeek-Coder、GLM-4-Code与Kimi-Math-Code工程对比
  • 别再每次重复写提示词了!OpenCode Skills 一招让你的 AI 编程效率翻倍
  • Kiran-Flameshot故障排除:常见问题解决方案大全
  • Java 必看:如何正确重写 hashCode() 和 equals() 方法?
  • IOT平台怎么选?制造业数字化转型指南
  • 用余弦相似度做客户流失预测:轻量、可解释、实时响应的实战方案
  • 华硕ROG性能控制革命:GHelper轻量级工具完全掌控指南
  • Octo 平台:打破 Agent 协作困境,重塑企业 AI 协作新范式