前两天帮人排查一个表单联动的bug,现象很典型:子组件内部已经把校验逻辑封装好了,父组件也正确绑定了ref,结果一调用validate方法就报“not a function”。我点开子组件一看,<script setup>里写了一大堆方法,唯独没写defineExpose。另一个项目正好相反,有人用defineExpose把一组列表数据整个暴露给父组件随意修改,结果状态在多处被改来改去,最后数据源头都找不到了。这两个方向都搞反的案例,本质就是没弄明白defineProps和defineExpose的分工:一个管“收”,一个管“给”。这篇就来完整拆解这两个编译器宏的区别,包括它们各自要解决什么问题、数据流方向有什么不同、响应性表现、类型声明方式,以及实际开发里的选型经验和踩坑记录。适合刚上手Vue3组合式API的开发者,也适合被父子组件通信绕晕的同学。1. 两个宏的定位差异:一个负责“收”,一个负责“给”在Vue3的<script setup>语法里,defineProps和defineExpose都属于编译器宏,不需要import,只能在SFC顶层直接调用。先说结论:defineProps定义的是“我这个组件接受哪些外部属性”,是外部世界向组件内部输入数据的声明;defineExpose定义的是“我这个组件愿意向外部开放哪些内部能力”,是组件内部向外部输出接口的声明。一个管接收,一个管暴露,方向正好相反。1.1 defineProps:声明组件对外部世界的输入契约组件不可能是孤岛,父组件总要给它传数据。defineProps就是一个正式的“收件清单”,说明本组件接受什么数据、每个字段什么类型、哪些必填、默认值多少。最常用的有三种写法:字符串数组、对象语法、泛型语法。<script setup lang="ts"> import { withDefaults } from 'vue' // 方式一:字符串数组,只声明字段名,不做类型约束 // const props = defineProps(['title', 'list']) // 方式二:对象语法,可以声明类型、必填项和默认值 // const props = defineProps({ // title: { // type: String, // required: true // }, // count: { // type: Number, // default: 0 // } // }) // 方式三:泛型语法,Vue 3.3+ 推荐,类型更清晰 interface Props { title: string count?: number } const props = withDefaults(defineProps<Props>(), { count: 0 }) // 在 setup 里通过 props 对象访问 console.log(props.title) </script> <template> <div>{{ title }} - {{ count }}</div> </template>注意一个细节:defineProps的返回值在模板里可以直接用属性名,title和count不需要再额外定义变量就能渲染。但如果你打算在<script setup>逻辑里读取某个props属性,建议用返回的props对象去访问,比如props.title,这样能保留响应性。关于这个坑,后面第五章会专门展开。1.2 defineExpose:向父组件开放组件实例的“能力清单”<script setup>默认是封闭的。在选项式API里,data、computed、methods里的内容都会挂到组件实例上,父组件用ref拿到实例后什么都能摸到。但在组合式API的<script setup>里,setup闭包里的变量和函数只对模板可见,不会自动暴露到组件实例上。这是刻意设计:外部能访问什么,必须由组件自己明确声明,避免内部细节被随意穿透。<script setup lang="ts"> import { ref } from 'vue' const count = ref(0) const increment = () => { count.value++ } const reset = () => { count.value = 0 } // 不写这一行,父组件拿到的实例上什么都没有 defineExpose({ count, increment, reset }) </script>父组件使用时,先在模板里给子组件加ref="childRef",然后通过childRef.value.increment()就能调用子组件的方法。defineExpose不返回任何值,它做的事情很单纯:把你传入对象里的每个key挂到组件实例的对外代理上。你可以把它理解成组件对外的“服务菜单”——菜单上没写的菜,顾客点不了。2. 从数据流方向看本质区别:单向流动与逆向调用上一章说的是“收”和“给”,但很多同学真正困惑的是数据到底怎么流。defineProps和defineExpose虽然都涉及父子通信,但一个属于数据传递机制,一个属于能力暴露机制,两者在数据流方向上完全不同。2.1 props的数据流:自上而下的单向数据流props的数据流向非常明确:父组件 → 子组件。父组件更新数据,子组件拿到新的值并重新渲染;子组件不能直接修改props属性,一旦尝试赋值,Vue会在开发环境打出警告。这条约束不是限制你,而是为了可维护性。试想一下,如果子组件拿到props后随手就能改,那当数据错乱的时候,你根本不知道是哪个组件改的。单向数据流让问题追踪变得容易:所有状态的源头都在上面,顺着数据流往上追就行。举一个非常常见的例子:商品卡片组件。父组件传入product对象和showPrice开关,子组件只负责展示;用户点击“加入购物车”按钮后,子组件并不自己去改product,而是通过emit抛事件,让父组件去修改并重新下发。<ProductCard :product="product" :show-price="true" @add-cart="handleAddCart" />这种模式下,defineProps声明的是“我要什么数据”,showPrice这种是配置项,product这种是要展示的内容。子组件永远不拥有这些数据的修改权。2.2 expose的数据流:父组件通过ref拿子组件实例后的逆向调用defineExpose并不是一种“数据传递机制”,它的核心是“操作句柄”。父组件通过模板ref拿到子组件实例后,才有能力调用子组件暴露的方法。从调用方向看,是父组件主动发起调用,在父组件的事件回调里执行子组件的方法。这时候数据本身可能还在子组件内部,只是触发动作的控制权交到了父组件手上。典型的例子是登录表单。校验用户名密码的逻辑在子组件内部,但用户点击“保存”按钮发生在父组件的布局里。父组件不能靠props触发校验,因为props只是被动接收数据;也不能只靠emit,因为emit是子组件主动上报,父组件无法指定调用时机。这时候就需要defineExpose把validate方法开放出来,让父组件在需要的时候主动调它。数据流依然是单向的(父组件触发子组件方法,子组件内部处理数据),但控制流是逆向的。这也是为什么expose适合做命令式API的原因。2.3 二者并非二选一:组合使用才能写出优雅组件defineProps和defineExpose不是互斥关系,同一个组件完全可以同时使用。以日期范围选择组件为例:props接收minDate、maxDate、disabled这些配置项,约束组件的行为;defineExpose则暴露setRange、clear、focus等方法,让父组件可以在外部事件中主动操作内部状态。这样设计的好处是:配置数据走声明式通道,行为操作走命令式通道,两个通道各司其职,不会搅在一起。3. 实际场景中如何选择:表单校验、弹窗控制与数据呈现场景选型才是实践里真正卡人的地方。不少初学者拿到需求后,第一反应就是“这个组件要跟父组件交互,那到底用props还是expose?”其实没有那么复杂,判断依据基本可以归纳成一句话:父组件需要的是数据,还是操作?3.1 用props的场景:数据呈现和配置项下发如果父子组件之间流动的主要是“数据”,比如列表、文案、配置项,优先用props。这类场景下,子组件是一个“展示型组件”,它的渲染结果完全由父组件传入的数据决定。props还有两个额外好处:一是接收变化数据的响应式同步是自动的,父组件改一个值,子组件视图立刻更新;二是props值在子组件内是只读的,不会出现多个来源同时修改一份数据的混乱。我见过一个反面案例:某项目里的表格子组件想从父组件拿筛选条件,但作者没用props,而是在子组件里定义了一个reactive对象,然后通过defineExpose暴露给父组件直接修改,美其名曰“这样不用写emit”。结果页面里有两处逻辑都会改这份筛选条件,一处来自父组件的搜索按钮,一处来自子组件内部的清空功能,两边改来改去,最后查数据错乱时都不知道该看哪里。这种场景老老实实用props + emit反而最清晰。3.2 用expose的场景:命令式调用子组件方法当父组件需要在某个特定时机“主动叫子组件做事”时,expose才是正确选择。典型的命令式API主要有:表单的validate/reset、弹窗的open/close、地图组件的setCenter/addMarker、富文本编辑器的insertContent/getHtml、表格组件的reload/clearSelection。这类方法的特点是:它依赖子组件内部状态,但触发的时机由外部决定,用props没法表达“在点击按钮后立刻执行某个内部逻辑”这种需求。这里给一个表单校验的最小可运行示例。子组件:<script setup lang="ts"> import { ref } from 'vue' const formData = ref({ username: '', password: '' }) const validate = async () => { if (!formData.value.username || !formData.value.password) { throw new Error('用户名和密码不能为空') } return true } const reset = () => { formData.value = { username: '', password: '' } } defineExpose({ validate, reset }) </script>父组件:<script setup lang="ts"> import { ref } from 'vue' import LoginForm from './LoginForm.vue' // 利用 InstanceType 推导子组件实例类型,拿到 validate 和 reset 的类型提示 const loginFormRef = ref<InstanceType<typeof LoginForm>>() const handleSave = async () => { try { await loginFormRef.value?.validate() // 校验通过,执行保存逻辑 } catch (e) { console.error(e) } } </script> <template> <LoginForm ref="loginFormRef" /> <button @click="handleSave">保存</button> </template>这里有个小提示:父组件ref的泛型建议写InstanceType<typeof LoginForm>,这样IDE能自动提示子组件暴露了哪些属性和方法。如果只写ref(),那么loginFormRef.value就会退化成通用组件实例类型,IDE看不到validate方法,开发体验差很多。3.3 同一个组件里二者协同:弹窗组件实例弹窗类组件是理解二者协同的好教材。控制弹窗开关的visible数据,通常会设计成props,由父组件动态控制,比如v-model:visible;而弹窗的openModal、closeModal、refreshData这些操作则适合通过defineExpose暴露,这样父组件可以在某个操作完成后立即刷新弹窗数据,或者在特定的页面流转中主动打开弹窗。数据驱动UI展示,方法驱动行为逻辑,两者互不干扰,代码读起来也顺。4. 响应性、类型声明与编译时行为的细节差异排除定位和场景之后,再深入一层看技术细节。defineProps和defineExpose在响应性、类型声明和编译行为上都有明显区别,这些细节直接影响你写代码的方式。4.1 props的响应性与只读约束defineProps返回的props对象是响应式的,父组件更新值后,子组件模板会在下次渲染时同步更新。同时props在子组件内部是只读的,不能直接赋值。当你确实需要基于props派生数据时,应该用computed;当你需要修改外部状态时,应该用emit通知父组件。还有一个经典细节:如果对defineProps的返回值做了解构,比如const { title } = defineProps(['title']),那么title这个变量会失去响应性。原因是解构发生时,你拿到的只是当前值的一份拷贝,后续父组件更新props时,解构出去的变量不会跟着变。稳妥的写法是直接用props.title,或者用toRefs把每个字段转换成ref再解构。4.2 expose暴露对象的响应性表现defineExpose暴露出去的对象,最终会挂到组件实例的对外代理上。这里有一个容易困惑的点:暴露的ref在父组件访问时会自动解包。也就是说,子组件里定义const count = ref(0)并通过defineExpose({ count })暴露后,父组件拿到的是childRef.value.count这个直接数值,不需要再写.value。同时,这是一个活引用——父组件对childRef.value.count赋值,会直接写回子组件的count,子组件视图同步更新。这既带来了便利,也埋了隐患。便利在于定义命令式API时很自然;隐患在于如果暴露的是可变状态对象,父组件就能绕过子组件的内部控制逻辑直接改数据。我一般建议defineExpose多暴露方法、少暴露状态变量,就算需要暴露状态,也用readonly包一层,防止外部乱改。4.3 类型声明方式不同defineProps从Vue 3.3开始支持泛型语法,可以声明复杂的字段类型,配合withDefaults还能设置默认值,类型推导非常完整。而defineExpose没有对应的泛型声明语法,它接受一个普通对象,类型信息是依赖对象字面量推导出来的。父组件侧要获得完整类型,需要借助InstanceType<typeof ChildComponent>,这种写法会把defineExpose暴露的属性和方法都推导出来,比手写一个接口省事很多。下面用一张表把它们在核心维度的差异汇总起来:对比维度definePropsdefineExpose核心作用声明组件接收的外部属性声明组件对外开放的实例接口数据流向父组件 → 子组件父组件通过在ref上的调用触发子组件方法调用时机组件创建时声明,父组件更新属性会响应父组件拿到实例后,按需主动调用响应性props是响应式对象,子组件内只读暴露的ref/reactive保持响应性,但外部可写默认状态声明哪些字段,父组件就只能传哪些字段不声明时,组件实例对外不暴露任何内部绑定类型方式支持泛型语法+withDefaults无泛型语法,父组件用InstanceType推导适用场景数据展示、配置下发命令式API、外部主动触发内部逻辑5. 实操踩坑记录:解构、遗漏暴露和滥用expose这一章全是真金白银换来的经验。我见过的defineProps和defineExpose相关bug,基本逃不出下面三类:解构丢响应性、忘记暴露、滥用暴露。5.1 解构props丢失响应性的经典坑有段时间我排查一个列表筛选失效的问题,现象是父组件更新了筛选条件,子组件的列表纹丝不动。排查链路如下:先在子组件模板里分别输出两个值,一个是用props.searchKeyword,一个是解构出来的searchKeyword。结果显示props.searchKeyword有值变化,而解构出来的那个始终是初始值。问题出得很典型——代码里写的是:const { searchKeyword } = defineProps(['searchKeyword'])这里的searchKeyword在赋值时就脱离了props的响应式联系,变成了一个普通字符串。修复办法很简单:要么在模板里直接使用这句props声明,要么在setup里用const { searchKeyword } = toRefs(props)。如果是Vue 3.5以上版本且团队认可新特性,也可以尝试响应式解构,但传统写法兼容性最好,长期项目里我仍然推荐toRefs。5.2 忘记defineExpose导致父组件拿不到方法这就是文章开头那个bug的标准场景。父组件明明绑了ref,子组件也写了const validate = () => {...},但一调用就报TypeError: validate is not a function。排查链路值得背下来:先在父组件把loginFormRef.value打印出来,看看实例上到底有哪些属性。如果打印出来只有一个空对象,基本可以断定子组件没写defineExpose。检查子组件defineExpose调用位置,必须写在<script setup>顶层,不能放在某个函数内部或条件判断里,否则编译宏不会生效。检查字段名拼写。defineExpose({ validate })暴露的是validate,父组件却写loginFormRef.value.validateForm(),自然找不到。确认访问时机。ref在组件挂载前是拿不到值的,要在onMounted或事件回调里访问,不要在setup同步阶段直接访问。这个坑之所以常见,是因为习惯了选项式API的人会默认组件实例上什么都有。到了<script setup>里,必须改变思维:不暴露,就不存在。5.3 滥用expose打破单向数据流的下场滥用expose比忘记它更危险,因为它不会立刻报错,而是埋下长期维护的雷。典型操作是把本应由props下发的共享数据,定义在子组件内部后用defineExpose暴露给父组件,让父组件直接赋值。表面上看省了事件和重传,实际上一份状态出现了多个修改入口。项目周期一长,你根本分不清这个值到底是父组件改的,还是子组件内部逻辑改的,调试成本成倍上升。我的建议是给“暴露可变状态”设置一个底线:如果expose里要暴露一个ref或reactive,先想清楚外部是否有资格直接改它。更多时候,暴露一个内部方法比暴露状态本身更合理。比如暴露setFilter(params)方法,而不是直接暴露filter对象。方法可以校验参数、合并默认值、做统一处理,对象只能被粗鲁地覆盖。6. 我的选型建议与排查经验文章最后聊点自己的习惯。这套东西用多了之后,我选型时基本靠三个问题就能快速决策,也分享给大家。6.1 判断该用props还是expose的三个问题父组件需要传递的是“数据”还是“操作”?数据用props,操作用expose。数据的所有权属于谁?如果状态本来就该归父组件管,用props下发;如果状态归子组件管,但外部需要触发动作,用expose暴露方法。是否需要严格单向数据流维护?如果希望数据源唯一、改动路径清晰,props + emit是更稳的组合;只有在父组件确确实实需要命令式调度子组件时,才动用expose。这三个问题问完,大多数场景其实都落在props上。对于命令式API组件,我也给自己定了一个小规矩:所有defineExpose的内容集中放在<script setup>文件末尾,并且逐个写注释。因为这些东西一旦暴露出去,就等于对外承诺了接口,改一次名字、改一个参数,所有用到该组件的页面都可能跟着改。把暴露的接口当成公共API来维护,能少踩很多突发事故。6.2 结合TypeScript和DevTools的调试建议排查这类问题,光靠console打印效率还是低了点。我惯用的方式有这么几个:第一,父组件ref泛型尽量写成ref<InstanceType<typeof ChildComponent>>(),这样IDE提示和编译校验都会自动暴露类型问题;第二,打开Vue DevTools,选中组件后查看右侧面板,props分组和exposed分组能直观显示当前组件接收了哪些props、对外暴露了哪些内容,这比反复打印实例对象快得多;第三,团队里统一约定,子组件里的对外方法一律用defineExpose包裹,出现“父组件拿不到方法”类问题,先怀疑封装遗漏,不要先怀疑是Vue的bug。最后再分享一个小技巧:defineExpose暴露的方法,建议尽量返回明确的布尔值或数据结果,而不是让父组件依赖异常来判断。比如validate方法内部如果校验失败,返回false或抛出带业务语义的异常,比直接让父组件“看控制台”友好得多。接口清晰了,组件之间的协作关系自然就顺了。
前两天帮人排查一个表单联动的bug,现象很典型:子组件内部已经把校验逻辑封装好了,父组件也正确绑定了ref,结果一调用validate方法就报“not a function”。我点开子组件一看,<script setup>里写了一大堆方法,唯独没写defineExpose。另一个项目正好相反,有人用defineExpose把一组列表数据整个暴露给父组件随意修改,结果状态在多处被改来改去,最后数据源头都找不到了。这两个方向都搞反的案例,本质就是没弄明白defineProps和defineExpose的分工:一个管“收”,一个管“给”。这篇就来完整拆解这两个编译器宏的区别,包括它们各自要解决什么问题、数据流方向有什么不同、响应性表现、类型声明方式,以及实际开发里的选型经验和踩坑记录。适合刚上手Vue3组合式API的开发者,也适合被父子组件通信绕晕的同学。1. 两个宏的定位差异:一个负责“收”,一个负责“给”在Vue3的<script setup>语法里,defineProps和defineExpose都属于编译器宏,不需要import,只能在SFC顶层直接调用。先说结论:defineProps定义的是“我这个组件接受哪些外部属性”,是外部世界向组件内部输入数据的声明;defineExpose定义的是“我这个组件愿意向外部开放哪些内部能力”,是组件内部向外部输出接口的声明。一个管接收,一个管暴露,方向正好相反。1.1 defineProps:声明组件对外部世界的输入契约组件不可能是孤岛,父组件总要给它传数据。defineProps就是一个正式的“收件清单”,说明本组件接受什么数据、每个字段什么类型、哪些必填、默认值多少。最常用的有三种写法:字符串数组、对象语法、泛型语法。<script setup lang="ts"> import { withDefaults } from 'vue' // 方式一:字符串数组,只声明字段名,不做类型约束 // const props = defineProps(['title', 'list']) // 方式二:对象语法,可以声明类型、必填项和默认值 // const props = defineProps({ // title: { // type: String, // required: true // }, // count: { // type: Number, // default: 0 // } // }) // 方式三:泛型语法,Vue 3.3+ 推荐,类型更清晰 interface Props { title: string count?: number } const props = withDefaults(defineProps<Props>(), { count: 0 }) // 在 setup 里通过 props 对象访问 console.log(props.title) </script> <template> <div>{{ title }} - {{ count }}</div> </template>注意一个细节:defineProps的返回值在模板里可以直接用属性名,title和count不需要再额外定义变量就能渲染。但如果你打算在<script setup>逻辑里读取某个props属性,建议用返回的props对象去访问,比如props.title,这样能保留响应性。关于这个坑,后面第五章会专门展开。1.2 defineExpose:向父组件开放组件实例的“能力清单”<script setup>默认是封闭的。在选项式API里,data、computed、methods里的内容都会挂到组件实例上,父组件用ref拿到实例后什么都能摸到。但在组合式API的<script setup>里,setup闭包里的变量和函数只对模板可见,不会自动暴露到组件实例上。这是刻意设计:外部能访问什么,必须由组件自己明确声明,避免内部细节被随意穿透。<script setup lang="ts"> import { ref } from 'vue' const count = ref(0) const increment = () => { count.value++ } const reset = () => { count.value = 0 } // 不写这一行,父组件拿到的实例上什么都没有 defineExpose({ count, increment, reset }) </script>父组件使用时,先在模板里给子组件加ref="childRef",然后通过childRef.value.increment()就能调用子组件的方法。defineExpose不返回任何值,它做的事情很单纯:把你传入对象里的每个key挂到组件实例的对外代理上。你可以把它理解成组件对外的“服务菜单”——菜单上没写的菜,顾客点不了。2. 从数据流方向看本质区别:单向流动与逆向调用上一章说的是“收”和“给”,但很多同学真正困惑的是数据到底怎么流。defineProps和defineExpose虽然都涉及父子通信,但一个属于数据传递机制,一个属于能力暴露机制,两者在数据流方向上完全不同。2.1 props的数据流:自上而下的单向数据流props的数据流向非常明确:父组件 → 子组件。父组件更新数据,子组件拿到新的值并重新渲染;子组件不能直接修改props属性,一旦尝试赋值,Vue会在开发环境打出警告。这条约束不是限制你,而是为了可维护性。试想一下,如果子组件拿到props后随手就能改,那当数据错乱的时候,你根本不知道是哪个组件改的。单向数据流让问题追踪变得容易:所有状态的源头都在上面,顺着数据流往上追就行。举一个非常常见的例子:商品卡片组件。父组件传入product对象和showPrice开关,子组件只负责展示;用户点击“加入购物车”按钮后,子组件并不自己去改product,而是通过emit抛事件,让父组件去修改并重新下发。<ProductCard :product="product" :show-price="true" @add-cart="handleAddCart" />这种模式下,defineProps声明的是“我要什么数据”,showPrice这种是配置项,product这种是要展示的内容。子组件永远不拥有这些数据的修改权。2.2 expose的数据流:父组件通过ref拿子组件实例后的逆向调用defineExpose并不是一种“数据传递机制”,它的核心是“操作句柄”。父组件通过模板ref拿到子组件实例后,才有能力调用子组件暴露的方法。从调用方向看,是父组件主动发起调用,在父组件的事件回调里执行子组件的方法。这时候数据本身可能还在子组件内部,只是触发动作的控制权交到了父组件手上。典型的例子是登录表单。校验用户名密码的逻辑在子组件内部,但用户点击“保存”按钮发生在父组件的布局里。父组件不能靠props触发校验,因为props只是被动接收数据;也不能只靠emit,因为emit是子组件主动上报,父组件无法指定调用时机。这时候就需要defineExpose把validate方法开放出来,让父组件在需要的时候主动调它。数据流依然是单向的(父组件触发子组件方法,子组件内部处理数据),但控制流是逆向的。这也是为什么expose适合做命令式API的原因。2.3 二者并非二选一:组合使用才能写出优雅组件defineProps和defineExpose不是互斥关系,同一个组件完全可以同时使用。以日期范围选择组件为例:props接收minDate、maxDate、disabled这些配置项,约束组件的行为;defineExpose则暴露setRange、clear、focus等方法,让父组件可以在外部事件中主动操作内部状态。这样设计的好处是:配置数据走声明式通道,行为操作走命令式通道,两个通道各司其职,不会搅在一起。3. 实际场景中如何选择:表单校验、弹窗控制与数据呈现场景选型才是实践里真正卡人的地方。不少初学者拿到需求后,第一反应就是“这个组件要跟父组件交互,那到底用props还是expose?”其实没有那么复杂,判断依据基本可以归纳成一句话:父组件需要的是数据,还是操作?3.1 用props的场景:数据呈现和配置项下发如果父子组件之间流动的主要是“数据”,比如列表、文案、配置项,优先用props。这类场景下,子组件是一个“展示型组件”,它的渲染结果完全由父组件传入的数据决定。props还有两个额外好处:一是接收变化数据的响应式同步是自动的,父组件改一个值,子组件视图立刻更新;二是props值在子组件内是只读的,不会出现多个来源同时修改一份数据的混乱。我见过一个反面案例:某项目里的表格子组件想从父组件拿筛选条件,但作者没用props,而是在子组件里定义了一个reactive对象,然后通过defineExpose暴露给父组件直接修改,美其名曰“这样不用写emit”。结果页面里有两处逻辑都会改这份筛选条件,一处来自父组件的搜索按钮,一处来自子组件内部的清空功能,两边改来改去,最后查数据错乱时都不知道该看哪里。这种场景老老实实用props + emit反而最清晰。3.2 用expose的场景:命令式调用子组件方法当父组件需要在某个特定时机“主动叫子组件做事”时,expose才是正确选择。典型的命令式API主要有:表单的validate/reset、弹窗的open/close、地图组件的setCenter/addMarker、富文本编辑器的insertContent/getHtml、表格组件的reload/clearSelection。这类方法的特点是:它依赖子组件内部状态,但触发的时机由外部决定,用props没法表达“在点击按钮后立刻执行某个内部逻辑”这种需求。这里给一个表单校验的最小可运行示例。子组件:<script setup lang="ts"> import { ref } from 'vue' const formData = ref({ username: '', password: '' }) const validate = async () => { if (!formData.value.username || !formData.value.password) { throw new Error('用户名和密码不能为空') } return true } const reset = () => { formData.value = { username: '', password: '' } } defineExpose({ validate, reset }) </script>父组件:<script setup lang="ts"> import { ref } from 'vue' import LoginForm from './LoginForm.vue' // 利用 InstanceType 推导子组件实例类型,拿到 validate 和 reset 的类型提示 const loginFormRef = ref<InstanceType<typeof LoginForm>>() const handleSave = async () => { try { await loginFormRef.value?.validate() // 校验通过,执行保存逻辑 } catch (e) { console.error(e) } } </script> <template> <LoginForm ref="loginFormRef" /> <button @click="handleSave">保存</button> </template>这里有个小提示:父组件ref的泛型建议写InstanceType<typeof LoginForm>,这样IDE能自动提示子组件暴露了哪些属性和方法。如果只写ref(),那么loginFormRef.value就会退化成通用组件实例类型,IDE看不到validate方法,开发体验差很多。3.3 同一个组件里二者协同:弹窗组件实例弹窗类组件是理解二者协同的好教材。控制弹窗开关的visible数据,通常会设计成props,由父组件动态控制,比如v-model:visible;而弹窗的openModal、closeModal、refreshData这些操作则适合通过defineExpose暴露,这样父组件可以在某个操作完成后立即刷新弹窗数据,或者在特定的页面流转中主动打开弹窗。数据驱动UI展示,方法驱动行为逻辑,两者互不干扰,代码读起来也顺。4. 响应性、类型声明与编译时行为的细节差异排除定位和场景之后,再深入一层看技术细节。defineProps和defineExpose在响应性、类型声明和编译行为上都有明显区别,这些细节直接影响你写代码的方式。4.1 props的响应性与只读约束defineProps返回的props对象是响应式的,父组件更新值后,子组件模板会在下次渲染时同步更新。同时props在子组件内部是只读的,不能直接赋值。当你确实需要基于props派生数据时,应该用computed;当你需要修改外部状态时,应该用emit通知父组件。还有一个经典细节:如果对defineProps的返回值做了解构,比如const { title } = defineProps(['title']),那么title这个变量会失去响应性。原因是解构发生时,你拿到的只是当前值的一份拷贝,后续父组件更新props时,解构出去的变量不会跟着变。稳妥的写法是直接用props.title,或者用toRefs把每个字段转换成ref再解构。4.2 expose暴露对象的响应性表现defineExpose暴露出去的对象,最终会挂到组件实例的对外代理上。这里有一个容易困惑的点:暴露的ref在父组件访问时会自动解包。也就是说,子组件里定义const count = ref(0)并通过defineExpose({ count })暴露后,父组件拿到的是childRef.value.count这个直接数值,不需要再写.value。同时,这是一个活引用——父组件对childRef.value.count赋值,会直接写回子组件的count,子组件视图同步更新。这既带来了便利,也埋了隐患。便利在于定义命令式API时很自然;隐患在于如果暴露的是可变状态对象,父组件就能绕过子组件的内部控制逻辑直接改数据。我一般建议defineExpose多暴露方法、少暴露状态变量,就算需要暴露状态,也用readonly包一层,防止外部乱改。4.3 类型声明方式不同defineProps从Vue 3.3开始支持泛型语法,可以声明复杂的字段类型,配合withDefaults还能设置默认值,类型推导非常完整。而defineExpose没有对应的泛型声明语法,它接受一个普通对象,类型信息是依赖对象字面量推导出来的。父组件侧要获得完整类型,需要借助InstanceType<typeof ChildComponent>,这种写法会把defineExpose暴露的属性和方法都推导出来,比手写一个接口省事很多。下面用一张表把它们在核心维度的差异汇总起来:对比维度definePropsdefineExpose核心作用声明组件接收的外部属性声明组件对外开放的实例接口数据流向父组件 → 子组件父组件通过在ref上的调用触发子组件方法调用时机组件创建时声明,父组件更新属性会响应父组件拿到实例后,按需主动调用响应性props是响应式对象,子组件内只读暴露的ref/reactive保持响应性,但外部可写默认状态声明哪些字段,父组件就只能传哪些字段不声明时,组件实例对外不暴露任何内部绑定类型方式支持泛型语法+withDefaults无泛型语法,父组件用InstanceType推导适用场景数据展示、配置下发命令式API、外部主动触发内部逻辑5. 实操踩坑记录:解构、遗漏暴露和滥用expose这一章全是真金白银换来的经验。我见过的defineProps和defineExpose相关bug,基本逃不出下面三类:解构丢响应性、忘记暴露、滥用暴露。5.1 解构props丢失响应性的经典坑有段时间我排查一个列表筛选失效的问题,现象是父组件更新了筛选条件,子组件的列表纹丝不动。排查链路如下:先在子组件模板里分别输出两个值,一个是用props.searchKeyword,一个是解构出来的searchKeyword。结果显示props.searchKeyword有值变化,而解构出来的那个始终是初始值。问题出得很典型——代码里写的是:const { searchKeyword } = defineProps(['searchKeyword'])这里的searchKeyword在赋值时就脱离了props的响应式联系,变成了一个普通字符串。修复办法很简单:要么在模板里直接使用这句props声明,要么在setup里用const { searchKeyword } = toRefs(props)。如果是Vue 3.5以上版本且团队认可新特性,也可以尝试响应式解构,但传统写法兼容性最好,长期项目里我仍然推荐toRefs。5.2 忘记defineExpose导致父组件拿不到方法这就是文章开头那个bug的标准场景。父组件明明绑了ref,子组件也写了const validate = () => {...},但一调用就报TypeError: validate is not a function。排查链路值得背下来:先在父组件把loginFormRef.value打印出来,看看实例上到底有哪些属性。如果打印出来只有一个空对象,基本可以断定子组件没写defineExpose。检查子组件defineExpose调用位置,必须写在<script setup>顶层,不能放在某个函数内部或条件判断里,否则编译宏不会生效。检查字段名拼写。defineExpose({ validate })暴露的是validate,父组件却写loginFormRef.value.validateForm(),自然找不到。确认访问时机。ref在组件挂载前是拿不到值的,要在onMounted或事件回调里访问,不要在setup同步阶段直接访问。这个坑之所以常见,是因为习惯了选项式API的人会默认组件实例上什么都有。到了<script setup>里,必须改变思维:不暴露,就不存在。5.3 滥用expose打破单向数据流的下场滥用expose比忘记它更危险,因为它不会立刻报错,而是埋下长期维护的雷。典型操作是把本应由props下发的共享数据,定义在子组件内部后用defineExpose暴露给父组件,让父组件直接赋值。表面上看省了事件和重传,实际上一份状态出现了多个修改入口。项目周期一长,你根本分不清这个值到底是父组件改的,还是子组件内部逻辑改的,调试成本成倍上升。我的建议是给“暴露可变状态”设置一个底线:如果expose里要暴露一个ref或reactive,先想清楚外部是否有资格直接改它。更多时候,暴露一个内部方法比暴露状态本身更合理。比如暴露setFilter(params)方法,而不是直接暴露filter对象。方法可以校验参数、合并默认值、做统一处理,对象只能被粗鲁地覆盖。6. 我的选型建议与排查经验文章最后聊点自己的习惯。这套东西用多了之后,我选型时基本靠三个问题就能快速决策,也分享给大家。6.1 判断该用props还是expose的三个问题父组件需要传递的是“数据”还是“操作”?数据用props,操作用expose。数据的所有权属于谁?如果状态本来就该归父组件管,用props下发;如果状态归子组件管,但外部需要触发动作,用expose暴露方法。是否需要严格单向数据流维护?如果希望数据源唯一、改动路径清晰,props + emit是更稳的组合;只有在父组件确确实实需要命令式调度子组件时,才动用expose。这三个问题问完,大多数场景其实都落在props上。对于命令式API组件,我也给自己定了一个小规矩:所有defineExpose的内容集中放在<script setup>文件末尾,并且逐个写注释。因为这些东西一旦暴露出去,就等于对外承诺了接口,改一次名字、改一个参数,所有用到该组件的页面都可能跟着改。把暴露的接口当成公共API来维护,能少踩很多突发事故。6.2 结合TypeScript和DevTools的调试建议排查这类问题,光靠console打印效率还是低了点。我惯用的方式有这么几个:第一,父组件ref泛型尽量写成ref<InstanceType<typeof ChildComponent>>(),这样IDE提示和编译校验都会自动暴露类型问题;第二,打开Vue DevTools,选中组件后查看右侧面板,props分组和exposed分组能直观显示当前组件接收了哪些props、对外暴露了哪些内容,这比反复打印实例对象快得多;第三,团队里统一约定,子组件里的对外方法一律用defineExpose包裹,出现“父组件拿不到方法”类问题,先怀疑封装遗漏,不要先怀疑是Vue的bug。最后再分享一个小技巧:defineExpose暴露的方法,建议尽量返回明确的布尔值或数据结果,而不是让父组件依赖异常来判断。比如validate方法内部如果校验失败,返回false或抛出带业务语义的异常,比直接让父组件“看控制台”友好得多。接口清晰了,组件之间的协作关系自然就顺了。