Code前端首页关于Code前端联系我们

Vue3父组件怎么调用子组件方法?一文说清ref、defineExpose、ts类型支持全方案

terry 3小时前 阅读数 40 #Vue

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的方案吧。

最后说几个常见的坑,帮你避避雷

  1. 模板里的ref和父组件里的变量名不一致:这个是普通JS项目<script setup>写法里最常见的坑,刚才已经提醒过了,必须完全一致,包括大小写。
  2. 没有加可选链或者非空判断:组件挂载完成之前,userFormRef.value是null,直接调用会报错,所以一定要加。
  3. 暴露了太多私有内容:Vue3默认不暴露是为了封装性,所以别什么方法和属性都往外塞,只暴露父组件真正需要的就行。
  4. TypeScript里用了InstanceType:刚才也说了,<script setup>里默认InstanceType<typeof UserForm>拿到的是空类型,因为默认不暴露,所以得用专门的接口。
  5. 子组件是异步组件:如果子组件是用defineAsyncComponent或者动态导入的方式加载的,那userFormRef.value会在组件加载完成之后才会有值,所以最好加一个isAsyncComponentLoaded的状态变量,或者用nextTick,或者在异步组件的onMounted里通知父组件。

Vue3父组件调用子组件方法的核心就是“ref绑定+defineExpose暴露”,不管是普通JS还是TypeScript,不管是<script setup>还是传统写法,原理都是一样的,只是细节有点区别,掌握了这个核心,再避开刚才说的几个坑,你就不会再在这个问题上踩雷啦。

版权声明

本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。

热门