✈️ Vue 3 调用深层组件方法,这 3 招够用了!

1,244 阅读3分钟

大家好,我是前端架构师,关注微信公众号【@程序员大卫】免费领取前端精品资料。

背景

假设有 A、B、C 三个组件,A 组件想要调用最内层 C 组件的方法。由于组件之间是逐层嵌套的,A 并不能直接访问 C,这就需要我们通过一些手段将 C 的方法“暴露”到 A。

下面介绍三种常见的实现方式。

方法一:传统方式 —— defineExpose 层层传递

通过 defineExpose 将方法逐层向上传递,让 A 组件最终可以访问到 C 的方法。

C 组件

<script setup lang="ts">
import { ref } from "vue";

const num = ref(0);

defineExpose({
  increment: () => num.value++,
});
</script>

<template>
  <h1>C: {{ num }}</h1>
</template>

B 组件

<script setup lang="ts">
import { shallowRef } from "vue";
import C from "./C.vue";

const cRef = shallowRef<InstanceType<typeof C>>();

defineExpose({
  increment: () => cRef.value?.increment(),
});
</script>

<template>
  <h1>B</h1>
  <C ref="cRef" />
</template>

A 组件

<script setup lang="ts">
import { shallowRef } from "vue";
import B from "./B.vue";

const bRef = shallowRef<InstanceType<typeof B>>();
</script>

<template>
  <h1 @click="bRef?.increment">A</h1>
  <B ref="bRef" />
</template>

✅ 优点:写法直观,类型推导良好。
❌ 缺点:每层组件都要处理 refdefineExpose,不够灵活。

方法二:使用 Proxy 自动透传

通过封装 useRefExpose 工具方法,将内部组件实例通过 Proxy 代理出去,无需每层手动暴露方法。

A 组件和 C 组件不变。

B 组件

<script setup lang="ts">
import { ref } from "vue";
import C from "./C.vue";
import { useMergeExpose } from "@/hooks/useMergeExpose";

const cRef = ref<InstanceType<typeof C>>();

defineExpose(useMergeExpose(cRef));
</script>

<template>
  <h1>B</h1>
  <C ref="cRef" />
</template>

⚠️注意:如果 B 组件还想暴露自己的方法(比如:otherMethod),那么它可以这么写:

defineExpose(
  useMergeExpose(cRef, {
    otherMethod: () => {
      console.log("xxxxxx");
    },
  }),
);

useMergeExpose 实现

import { Ref } from "vue";

export function useMergeExpose<T extends object, K extends object>(
  ref: Ref<T | undefined | null>,
  extObj: K = {} as K,
) {
  return new Proxy<T & K>({} as T & K, {
    get(_target, prop) {
      if (prop in extObj) {
        return extObj[prop as keyof K];
      }
      return ref.value?.[prop as keyof T];
    },
    // 不能去掉 has 方法,否则会报错,因为 Vue 内部 expose 对象中使用 'prop' in target 进行存在性判断。
    has(_target, prop) {
      if (prop in extObj) return true;
      return ref.value ? prop in ref.value : false;
    },
  });
}

✅ 优点:无需一层层暴露,只需要封装一次代理逻辑。

方法三:使用注册回调的方式传递实例

通过事件注册的方式,将 C 组件实例通过 B 传递给 A。

C 组件不变。

B 组件

<script setup lang="ts">
import { ComponentPublicInstance } from "vue";
import C from "./C.vue";

type CInstance = InstanceType<typeof C>;

const emit = defineEmits<{
  (e: "register", el: CInstance): void;
}>();

const setRef = (c: Element | ComponentPublicInstance | null) => {
  emit("register", c as CInstance);
};
</script>

<template>
  <h1>B</h1>
  <C :ref="setRef" />
</template>

可以看到 B 组件的 ts 类型不够友好,setRef 函数使用了 c as CInstance,所以 B 组件可以改成:

<script setup lang="ts">
import { shallowRef, watchEffect } from "vue";
import C from "./C.vue";

type CInstance = InstanceType<typeof C>;

const emit = defineEmits<{
  (e: "register", el: CInstance): void;
}>();

const cRef = shallowRef<CInstance | null>(null);

watchEffect(() => {
  if (cRef.value) {
    emit("register", cRef.value);
  }
});
</script>

<template>
  <h1>B</h1>
  <C ref="cRef" />
</template>

A 组件

A 组件还需要引入 C 的类型,所以 register 的这种方式,感觉不够好。

<script setup lang="ts">
import { shallowRef } from "vue";
import B from "./B.vue";
import type C from "./C.vue";

type CInstance = InstanceType<typeof C>;

const cInstance = shallowRef<CInstance | null>(null);

const register = (el: CInstance) => {
  cInstance.value = el;
};
</script>

<template>
  <h1 @click="cInstance?.increment">A</h1>
  <B @register="register" />
</template>

✅ 优点:组件解耦,不需要使用 defineExpose
❌ 缺点:A 需要依赖 C 的类型,B 组件的 ref 类型也需手动指定,TS 写法略显繁琐。

总结

方法优点缺点适用场景
方法一 defineExpose简单直观,TS 类型友好每层都要处理 refexpose层级不深或可控时
方法二 Proxy极简,封装后复用性高组件链复杂,想省事时
方法三 register解耦组件间依赖类型书写繁琐,耦合事件逻辑ref 管理和注册有需求时

根据项目复杂度和团队偏好选择合适方式,简单场景优先用 defineExpose,复杂嵌套推荐 Proxy 方案。