Vue3父组件怎么调用子组件方法?一文说清ref、defineExpose、ts类型支持全方案
Vue3和Vue2父调子有什么不一样的底层逻辑
刚从Vue2转来的朋友,第一反应肯定是找$refs对吧?但你会发现,直接用模板里的ref绑定,父组件拿到的子组件实例居然是空的!这可不是bug,是Vue3 Composition API的一个设计优化——默认关闭了子组件内部方法的对外暴露,避免了父组件滥用子组件私有逻辑的问题,让组件的封装性更强。 简单说,Vue2是“默认公开,靠自觉不瞎用”,Vue3是“默认保密,你要啥我主动给你啥”,这个转变很重要,是组件化开发更规范的体现,得先记牢。
普通JS项目用ref+defineExpose,最基础的通用版
不管你是用<script setup>还是传统的setup()函数,这个组合都是最常用的,先拿大家用得最多的<script setup>来举例子吧——毕竟这个语法糖现在是Vue3的官方推荐了。
假设我们做了一个子组件,叫UserForm.vue,里面有两个功能:一个是“重置表单”的resetForm方法,一个是“获取表单数据但不提交”的previewFormData方法,一开始父组件肯定拿不到这些,对吧?
首先处理子组件:得用Vue3提供的defineExpose宏(注意这个是<script setup>里的专属宏,不需要导入哦),把需要对外暴露的东西包裹进去,比如我们只允许父组件调用resetForm和获取formRef.value(因为有时候父组件可能需要直接触发表单的validate方法),那previewFormData就别暴露,让它当子组件的私有逻辑。
<!-- UserForm.vue -->
<template>
<el-form ref="formRef" :model="form" label-width="80px">
<el-form-item label="姓名" prop="name">
<el-input v-model="form.name"></el-input>
</el-form-item>
<el-form-item label="邮箱" prop="email">
<el-input v-model="form.email"></el-input>
</el-form-item>
</el-form>
</template>
<script setup>
import { ref, reactive } from 'vue'
const formRef = ref(null)
const form = reactive({ name: '', email: '' })
const resetForm = () => {
formRef.value?.resetFields() // 重置表单并清除校验
form.name = ''
form.email = ''
}
const previewFormData = () => {
console.log('当前表单内容是', JSON.parse(JSON.stringify(form)))
}
// 核心步骤:暴露需要对外的内容
defineExpose({
resetForm,
formRef
})
</script>
接下来是父组件:在模板里给UserForm加一个ref属性,比如叫userFormRef,然后在<script setup>里用ref(null)声明一个同名的变量(注意大小写要完全一致!这点很多新手容易踩坑),拿到的就是子组件的暴露实例了,最后在父组件的按钮点击事件里直接调用就行。
<!-- Parent.vue -->
<template>
<div>
<h2>用户管理页面</h2>
<UserForm ref="userFormRef" />
<div style="margin-top: 20px;">
<el-button type="primary" @click="submitForm">提交表单</el-button>
<el-button @click="resetUserForm">重置表单</el-button>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import UserForm from './UserForm.vue'
// 声明子组件的ref,注意模板里的ref和这里的变量名必须完全一致
const userFormRef = ref(null)
const submitForm = async () => {
// 调用暴露出来的formRef的validate方法
const valid = await userFormRef.value?.formRef.validate()
if (valid) {
alert('表单校验通过,准备提交')
// 这里写提交逻辑
userFormRef.value?.resetForm() // 提交成功后重置
}
}
const resetUserForm = () => {
// 直接调用暴露出来的resetForm方法
userFormRef.value?.resetForm()
}
</script>
对了,加个可选链很有必要,因为组件可能还没挂载完成,这时候userFormRef.value是null,直接调用会报错,如果是在挂载完成之后才会触发的方法里调用,也可以不用,但加了更安全。
传统setup()函数怎么用?不用宏但原理一样
有些老项目可能还没完全切换到<script setup>,或者开发者习惯用传统的setup()函数,那对应的方案是什么呢?其实原理完全一样,只是写法有点区别——defineExpose变成了context.expose,模板里的ref也不需要和变量名完全一致,不过保持一致还是方便点。
还是拿刚才的UserForm.vue改一下,看看传统写法:
<!-- 传统写法的UserForm.vue -->
<template>
<!-- 模板和刚才一样 -->
</template>
<script>
import { ref, reactive } from 'vue'
export default {
setup(props, context) {
const formRef = ref(null)
const form = reactive({ name: '', email: '' })
const resetForm = () => {
formRef.value?.resetFields()
form.name = ''
form.email = ''
}
const previewFormData = () => {
console.log('当前表单内容是', JSON.parse(JSON.stringify(form)))
}
// 传统写法的暴露:context.expose
context.expose({
resetForm,
formRef
})
// 注意:传统setup()必须return模板里用到的东西
return {
formRef,
form
}
}
}
</script>
父组件的传统写法也是类似的:
<!-- 传统写法的Parent.vue -->
<template>
<!-- 模板和刚才一样,ref还是userFormRef -->
</template>
<script>
import { ref } from 'vue'
import UserForm from './UserForm.vue'
export default {
components: { UserForm },
setup() {
const userFormRef = ref(null)
const submitForm = async () => {
const valid = await userFormRef.value?.formRef.validate()
if (valid) {
alert('表单校验通过,准备提交')
userFormRef.value?.resetForm()
}
}
const resetUserForm = () => {
userFormRef.value?.resetForm()
}
return {
userFormRef,
submitForm,
resetUserForm
}
}
}
</script>
TypeScript项目怎么加类型支持?再也不用担心报错点错了
这是很多Vue3 TypeScript项目用户的刚需了——刚才的普通写法虽然能用,但在父组件里调用userFormRef.value的时候,TS会提示unknown类型,不能自动补全方法和属性,手滑打错字也不会报错,开发体验很差,那怎么解决呢?
这里有个官方推荐的最佳实践:先在子组件里定义一个ExposeType接口,把要暴露的内容写进去,然后用typeof expose赋值给它(或者直接写接口),再在父组件里用Ref<ExposeType | null>或者用InstanceType<typeof UserForm>——不过用InstanceType的前提是子组件必须是用<script setup>或者defineComponent包裹的正确组件,而且暴露的内容会被包含在实例类型里吗?不对不对,<script setup>里默认InstanceType拿到的是空类型,因为默认不暴露,所以得用专门的类型定义方式。
还是拿刚才的<script setup>版本来加TS类型支持,这个是最常用的场景:
首先在子组件里,把要暴露的内容单独提出来,或者先写一个接口,然后用defineExpose的同时(或者之后)导出这个类型:
<!-- TS版本的UserForm.vue -->
<template>
<!-- 模板还是一样 -->
</template>
<script setup lang="ts">
import { ref, reactive, type FormInstance } from 'element-plus' // 假设用的是Element Plus,引入它的FormInstance类型
// 定义要暴露的内容的接口
export interface UserFormExpose {
resetForm: () => void
formRef: FormInstance | null
}
const formRef = ref<FormInstance | null>(null)
const form = reactive({ name: '', email: '' })
const resetForm = () => {
formRef.value?.resetFields()
form.name = ''
form.email = ''
}
const previewFormData = () => {
console.log('当前表单内容是', JSON.parse(JSON.stringify(form)))
}
同时这里可以不用显式给defineExpose加类型,因为后面导出了UserFormExpose
defineExpose({
resetForm,
formRef
})
</script>
然后在父组件里,导入刚才的UserFormExpose接口,给声明的userFormRef加类型:
<!-- TS版本的Parent.vue -->
<template>
<!-- 模板还是一样 -->
</template>
<script setup lang="ts">
import { ref, type Ref } from 'vue'
import UserForm, { type UserFormExpose } from './UserForm.vue'
// 给ref加类型:Ref<UserFormExpose | null>
const userFormRef: Ref<UserFormExpose | null> = ref(null)
const submitForm = async () => {
// 这里用可选链之后,还可以用非空断言! 不过要确保是在组件挂载完成后调用,或者加if判断
// 先加if判断更安全
if (!userFormRef.value) return
const valid = await userFormRef.value.formRef?.validate()
if (valid) {
alert('表单校验通过,准备提交')
userFormRef.value.resetForm() // 这里TS会自动补全resetForm,手滑打错reset会报错
}
}
const resetUserForm = () => {
userFormRef.value?.resetForm()
}
</script>
这样是不是舒服多了?TS会帮你检查有没有打错方法名,还会自动补全参数和返回值,开发效率和代码质量都能提高不少。
有没有不用ref的方案?provide/inject也能凑合用但不推荐
有些朋友可能会想,既然Vue3有provide/inject来跨组件传递数据和方法,那能不能用这个来父调子呢?其实理论上是可以的,但强烈不推荐这样做,为什么呢? 先举个简单的例子看看:父组件provide一个空的ref,子组件inject之后把自己的方法赋值给这个ref,然后父组件就可以调用了。
<!-- 用provide/inject的Parent.vue(不推荐) -->
<template>
<div>
<h2>用户管理页面</h2>
<UserForm />
<div style="margin-top: 20px;">
<el-button type="primary" @click="submitForm">提交表单</el-button>
<el-button @click="resetUserForm">重置表单</el-button>
</div>
</div>
</template>
<script setup>
import { ref, provide } from 'vue'
import UserForm from './UserForm.vue'
// provide一个空的ref
const userFormMethods = ref(null)
provide('userFormMethods', userFormMethods)
const submitForm = async () => {
if (!userFormMethods.value) return
const valid = await userFormMethods.value.formRef?.validate()
if (valid) {
alert('表单校验通过,准备提交')
userFormMethods.value.resetForm()
}
}
const resetUserForm = () => {
userFormMethods.value?.resetForm()
}
</script>
<!-- 用provide/inject的UserForm.vue(不推荐) -->
<template>
<!-- 模板还是一样 -->
</template>
<script setup>
import { ref, reactive, inject } from 'vue'
// inject刚才的ref
const userFormMethods = inject('userFormMethods')
const formRef = ref(null)
const form = reactive({ name: '', email: '' })
const resetForm = () => {
formRef.value?.resetFields()
form.name = ''
form.email = ''
}
const previewFormData = () => {
console.log('当前表单内容是', JSON.parse(JSON.stringify(form)))
}
// 挂载完成之后把方法赋值给inject的ref
onMounted(() => {
userFormMethods.value = {
resetForm,
formRef
}
})
</script>
看起来能跑,但为什么不推荐呢?主要有三个问题:
第一,耦合度太高了——provide/inject本来是用来跨多层组件传递数据的,用在父子组件之间有点大材小用,而且父组件需要知道子组件inject的key是什么,子组件也需要知道父组件provide的ref结构是什么,双方都依赖对方的内部实现,封装性被破坏了。
第二,时序问题——刚才的例子里我们加了onMounted,因为如果子组件刚inject就赋值,父组件可能还没渲染完成(当然一般父子组件是父先挂载到一半子先挂载,但为了保险还是得加),如果忘了加,或者时序有变化,就会出现调用失败的情况。
第三,类型支持麻烦——用provide/inject加类型支持需要用InjectionKey,比刚才的ref+接口的方式复杂多了。
除非是非常特殊的情况(比如父组件和子组件之间隔了一层第三方组件,不能直接传ref),否则还是老老实实的用ref+defineExpose的方案吧。
最后说几个常见的坑,帮你避避雷
- 模板里的ref和父组件里的变量名不一致:这个是普通JS项目
<script setup>写法里最常见的坑,刚才已经提醒过了,必须完全一致,包括大小写。 - 没有加可选链或者非空判断:组件挂载完成之前,
userFormRef.value是null,直接调用会报错,所以一定要加。 - 暴露了太多私有内容:Vue3默认不暴露是为了封装性,所以别什么方法和属性都往外塞,只暴露父组件真正需要的就行。
- TypeScript里用了InstanceType:刚才也说了,
<script setup>里默认InstanceType<typeof UserForm>拿到的是空类型,因为默认不暴露,所以得用专门的接口。 - 子组件是异步组件:如果子组件是用
defineAsyncComponent或者动态导入的方式加载的,那userFormRef.value会在组件加载完成之后才会有值,所以最好加一个isAsyncComponentLoaded的状态变量,或者用nextTick,或者在异步组件的onMounted里通知父组件。
Vue3父组件调用子组件方法的核心就是“ref绑定+defineExpose暴露”,不管是普通JS还是TypeScript,不管是<script setup>还是传统写法,原理都是一样的,只是细节有点区别,掌握了这个核心,再避开刚才说的几个坑,你就不会再在这个问题上踩雷啦。
版权声明
本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。
code前端网


