【Vue3 进阶👍】:如何批量导出子组件的属性和方法?从手动代理到Proxy的完整指南

最近准备写一系列关于 Vue3 相关技巧的系列文章,下面这些文章也同样精彩,感兴趣的可以关注一波。

【Vue3干货👍】template setup 和 tsx 的混合开发实践

前言

在使用 Vue3 开发的过程中,常常有父组件需要批量导出子组件的属性和方法的场景------例如跨层级传递表单校验方法、集中管理复杂组件的状态操作,或是构建高阶组件时动态透传能力。传统方案往往需要手动通过 defineExpose 逐个暴露,再通过 ref.value 逐一声明调用,这种重复性劳动不仅代码冗余,还会因组件迭代引发维护隐患。

本文将系统探讨三种渐进式优化方案:从最基础的手动代理 实现精准控制,到基于 Proxy自动化劫持 实现代码透传,再到结合 TypeScript 类型体操实现智能提示与安全校验。通过层层递进的实践,完成对这个常见需求的剖析,在实践中更深入地学习Vue3。

案例准备

假定我们拥有两个组件 Child.vueParent.vue 两个组件,他们的内容如下:

html 复制代码
// Child.vue
<template>
  <div>Child</div>
</template>

<script setup lang="ts">
defineExpose({
  hello() {
    console.log('hello')
  },
  test() {
    console.log('test')
  },
  name: 'Child',
})
</script>
html 复制代码
// Parent.vue
<template>
  <div>
    <div>Parent</div>
    <Child ref="child" />
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import Child from './Child.vue'

const child = ref<InstanceType<typeof Child>>()
</script>

Child.vue 这个组件中我们只是定义了两个方法和一个属性,而Parent.vue则是简单引用了 Child.vue

假设我们还有另外一个组件 App.vue :

html 复制代码
<template>
  <Parent ref="parentRef"></Parent>
</template>

<script setup lang="ts">
import Parent from './Parent.vue'
import { onMounted, ref } from 'vue'

defineOptions({
  name: 'App',
})

const parentRef = ref<InstanceType<typeof Parent>>()

onMounted(() => {
  parentRef.value?.hello()
  parentRef.value?.test()
  console.log('my name is ' + parentRef.value?.name)
})
</script>

在这个组件中,我们对Parent.vue中进行了引用,并且在 onMounted 函数中调用了 Child.vue 中定义的两个方法和属性,如果一切正常,我们应该能看到控制台中输出:

js 复制代码
'hello'
'test'
'my name is child'

一切准备就绪,我们开始探讨。

初级:逐一导出

我们都知道,要导出组件内部的方法得使用 defineExpose 宏,正如:

js 复制代码
defineExpose({
  hello() {
    console.log('hello')
  },
  test() {
    console.log('test')
  },
  name: 'Child',
})

而要引用一个组件的方法,需要使用 ref 定义一个变量并且在模板中绑定它,一如

html 复制代码
// Parent.vue
<template>
  <div>
    <div>Parent</div>
    <Child ref="child" />
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import Child from './Child.vue'

const child = ref<InstanceType<typeof Child>>()
</script>

所以很容易能想到,我们只需要将 Child.vue 中对应的方法和属性一一导出就行了:

js 复制代码
defineExpose({
  hello() {
    return child.value?.hello()
  },
  test() {
    return child.value?.test()
  },
  name: child.value?.name,
})

在浏览器中运行过后,可以看到结果是:

js 复制代码
'hello'
'test'
'my name is undefined'  // 这里明显不对

可以看到,前面两个方法正确地导出了,但是name属性没有正确导出来,为什么呢? 因为定义这个导出对象的时候,child 这个 ref 还没有实例化呢,所以这个时候拿到的 name 属性自然是 undefined 。而且,这种方案需要我们列出所有的Child.vue中的内部方法再一一导出,非常的繁琐,如果后续在Child.vue中又增加或者减少了某个方法,就必须更改代码。 再者,更多的时候我们要引用的子组件不是我们自己写的,我们根本不知道其导出了哪些方法。

由于上面的方案有太多的弊端,所以我们有必要对这些代码进行优化。

中级:自动导出

我们需要解决两个问题:

  • 在组件实例化以后再导出
  • 批量导出,无需一一列出

我们很容易能想到在 onMounted 生命周期钩子中去解决第一个问题, 然后再去使用 for 循环去实现动态批量添加属性,解决第二个问题,即:

js 复制代码
const exposed = {}
onMounted(() => {
  for (let key in child.value) {
    exposed[key] = child.value[key]
  }
})
defineExpose(exposed)

我们在child 实例化之后,再对 exposed 动态添加属性和方法,这样一来就一次性解决了这两个问题 在浏览器中运行过后,可以看到结果是:

js 复制代码
'hello'
'test'
'my name is child' 

同时,我们还可以在Parent.vue父组件中额外导出一些方法,例如:

js 复制代码
const exposed = {
 helloParent() {
   console.log('hello parent')
 },
}
onMounted(() => {
 for (let key in child.value) {
   exposed[key] = child.value[key]
 }
})
defineExpose(exposed)

这样一来,就能额外增强一下 Parent.vue 的功能,不过这里可以看到,如果子组件和父组件中有方法重名, 子组件的方法会覆盖父组件的方法,而理论上应该是父组件同名方法的优先级更改,所以我们要做一下特殊处理:

js 复制代码
onMounted(() => {
  for (let key in child.value) {
    // 避免重名覆盖
    if (!(key in exposed)) {
      exposed[key] = child.value[key]
    }
  }
})

还有什么没有考虑到的吗?

还真有,在上面的实现中,我们是在 onMounted 函数去实现的,但是这样写科学吗?

参考下面的代码:

html 复制代码
<template>
  <div>
    <div>Parent</div>
    <div>
      <button @click="show = !show">切换</button>
    </div>
    <Child v-if="show" ref="child" />
  </div>
</template>

<script setup lang="ts">
import { onMounted, ref } from 'vue'
import Child from './Child.vue'

const child = ref<InstanceType<typeof Child>>()
const show = ref(false)
</script>

在这个场景中,我们注意到,如果我们没有去点击切换按钮的话,那么 child 就无法实例化,我们在 onMounted 的代码就会报错,所以我们再改进一下:

js 复制代码
const exposed = {
  helloParent() {
    console.log('hello parent')
  },
}

watch(child, childInstance => {
  if (childInstance) {
    for (let key in childInstance) {
      // 避免重名覆盖
      if (!(key in exposed)) {
        exposed[key] = childInstance[key]
      }
    }
  }
})

defineExpose(exposed)

我们通过去watch去监听 child 变量,这样一来就能在它实例化的时候再去做这个功能绑定的功能, 我们看一下浏览器打印:

js 复制代码
Uncaught TypeError: parentRef.value?.hello is not a function //wtf

纳尼?发生了什么事儿?为什么会报错?

原因在于 watch 函数的回调执行时机在 app.vueonMounted 函数之后,所以在 onMounted 函数中调用相应的方法的时候,还没有给Parent.vue的实例添加Child.vue中的方法呢,所以这里只能用 setTimeout 代替 onMounted

js 复制代码
// app.vue
setTimeout(() => {
  parentRef.value?.hello()
  parentRef.value?.test()
  console.log('my name is ' + parentRef.value?.name)
})

这样一来,浏览器打印就正确了

js 复制代码
'hello'
'test'
'my name is child' 

还有改进的地方吗? 我们再看下面一个场景:

js 复制代码
// Child.vue
<template>
  <div>Child</div>
</template>

<script setup lang="ts">
const childExpose = {
  hello() {
    console.log('hello')
  },
  test() {
    console.log('test')
  },
  name: 'Child',
}

setTimeout(() => {
  childExpose.delayHello = () => {
    console.log('delayHello')
  }
}, 1000)

defineExpose(childExpose)
</script>

在这个代码里 Child.vue 在创建后 1s 再添加了 delayHello 这个方法,那么这个时候我们再 app.vue 中,添加相应的代码能访问到这个方法吗?

js 复制代码
setTimeout(() => {
  parentRef.value?.hello()
  parentRef.value?.test()
  console.log('my name is ' + parentRef.value?.name)

  setTimeout(() => {
    parentRef.value?.delayHello()
  }, 2000)
})
js 复制代码
App.vue:16  Uncaught TypeError: parentRef.value?.delayHello is not a function

可以看到,即便我们在 App.vue 中延迟了2s 再进行调用也是无济于事的, 因为这个后添加的方法根本就没有添加到 child 这个ref上.

所以我们这个方案又被否了,它有两个缺点:

  • app.vue中的onMounted钩子中无法调用对应的api;
  • 动态添加的方法和属性没有办法导出

讨论到这里, 我们可以发现这个方案最大的问题在于, 我们始终需要通过去将子组件暴露出来的方法和属性添加到父组件定义的 exposed 这个对象上 ,才能完成我们想要的效果,只要某一环节我们没有考虑到, 那么就会导致Parent.vue 并没有完全导出 Child.vue 的实力和方法。 所以我们需要一种不需要维护这种关系的方案。

高级:基于Proxy 代理

翻阅vue3的源码我们会发现当我们去调用 类似于parentRef.value?.hello() 这样的代码的时候,源码内部其实是去调用下面这个方法

js 复制代码
function getComponentPublicInstance(instance) {
  if (instance.exposed) {
    return instance.exposeProxy || (instance.exposeProxy = new Proxy(proxyRefs(markRaw(instance.exposed)), {
      get(target, key) {
        if (key in target) {
          return target[key];
        } else if (key in publicPropertiesMap) {
          return publicPropertiesMap[key](instance);
        }
      },
      has(target, key) {
        return key in target || key in publicPropertiesMap;
      }
    }));
  } else {
    return instance.proxy;
  }
}

我们调用 defineExpose 其实就是去给 组件实例上挂载了 exposed 这么个属性,所以这里会走第一个if分支, 而内部的核心逻辑就是:

js 复制代码
  get(target, key) {
    // 下面两行就是核心
    if (key in target) {
      return target[key];
    }
  }

为啥我说这两行是核心呢,因为 key in target 触发了 has 操作,而 return target[key] 触发了 get操作 ,而这两个操作我们是可以通过 Proxy 去拦截的。

顺着这个思路,我们可以写出这样的代码:

html 复制代码
<template>
  <div>
    <div>Parent</div>
    <Child ref="child" />
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import Child from './Child.vue'

const child = ref<InstanceType<typeof Child>>()

const exposed = {
  helloParent() {
    console.log('hello parent')
  },
}

const exposeProxy = new Proxy(exposed, {
  has(target, key) {
    const has = key in target
    if (has) {
      return true
    }
    if (child.value && key in child.value) {
      return true
    }
    return false
  },
  get(target, key) {
    if (key in target) {
      return target[key]
    }
    if (child.value && key in child.value) {
      return child.value[key]
    }
  },
})

defineExpose(exposeProxy)
</script>

在这里我们拦截了对 exposed 这个变量的 hasget 操作,这样就完美解决了问题, exposeProxy 在这里只做代理转发, 不像之前那样需要去同步 Child.vue 的方法, 正是基于这个机制, 不管是在什么时候、通过什么机制给 Child.vue 添加新的方法和属性, 我们的代理也能给我们做转发,故而调用到对应的方法和属性,而之前由于 watch 动态添加导致无法在 onMounted 中调用的弊端也一并给解决了。

app.vue中调用一下:

js 复制代码
onMounted(() => {
  parentRef.value?.hello()
  parentRef.value?.test()
  console.log('my name is ' + parentRef.value?.name)

  parentRef.value?.helloParent()

  setTimeout(() => {
    parentRef.value?.delayHello()
  }, 2000)
})

控制台打印:

js 复制代码
hello
test
my name is Child
hello parent
delayHello

可以看到,各种问题都随之而解决。

封装API

基于上面的实现,我们将其封装成一个工具函数 createExposeProxy

js 复制代码
export function createExposeProxy(expose, ...refs) {
  const exposeProxy = new Proxy(expose, {
    has(target, key) {
      const has = key in target
      if (has) {
        return true
      }
      for (const item of refs) {
        if (item.value && key in item.value) {
          return true
        }
      }
      return false
    },
    get(target, key) {
      if (key in target) {
        return target[key]
      }
      for (const item of refs) {
        if (item.value && key in item.value) {
          return item.value[key]
        }
      }
    },
  })
  return exposeProxy
}

我们可以这样使用它:

js 复制代码
import { createExposeProxy } from './createExposeProxy'

const child = ref<InstanceType<typeof Child>>()

defineExpose(
  createExposeProxy(
    {
      helloParent() {
        console.log('hello parent')
      },
    },
    child,
  ),
)

该函数的第一个参数用来定义父组件自定义导出的方法和属性,剩下的不定参数最后形成一个 ref 数组,这样就可以支持一次性代理多个子组件的方法,像这样:

js 复制代码
defineExpose(
  createExposeProxy(
    {
      helloParent() {
        console.log('hello parent')
      },
    },
    child,
    child2,
  ),
)

当然,上面只是基于js的封装,随着ts的不断流行,我们也希望在app.vue中使用的时候,可以有对应的类型提示,这是js版本无法做到的。

进阶准备

为了演示我们接下来的功能,需要修改一下组件:

html 复制代码
// Child1.vue
<template>
  <div>Child1</div>
</template>

<script setup lang="ts">
defineProps<{
  msgChild1?: string
}>()

const childExpose = {
  helloChild1() {
    console.log('helloChild1')
  },
}
defineExpose(childExpose)
</script>
html 复制代码
// Child2.vue
<template>
  <div>Child2</div>
</template>

<script setup lang="ts">
defineProps<{
  msgChild2?: string
}>()

defineExpose({
  helloChild2() {
    console.log('helloChild2')
  },
})
</script>
html 复制代码
// Parent.vue
<template>
  <div>
    <div>Parent</div>
    <Child1 ref="child1" />
    <Child2 ref="child2" />
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import Child1 from './Child1.vue'
import Child2 from './Child2.vue'
import { createExposeProxy } from './createExposeProxy'

const child1 = ref<InstanceType<typeof Child1>>()
const child2 = ref<InstanceType<typeof Child2>>()

const proxy = createExposeProxy(
  {
    helloParent() {
      console.log('hello parent')
    },
  },
  child1,
  child2,
)

defineExpose(proxy)
</script>
html 复制代码
// app.vue
<template>
  <Parent ref="parentRef"></Parent>
</template>

<script setup lang="ts">
import Parent from './Parent.vue'
import { onMounted, ref } from 'vue'

const parentRef = ref<InstanceType<typeof Parent>>()

onMounted(() => {
  parentRef.value?.helloChild1?.()
  parentRef.value?.helloChild2?.()
  parentRef.value?.helloParent?.()
})
</script>

我们定义了两个子组件Child1.vueChild2.vue,并且在Parent.vue中通过

ts 复制代码
const proxy = createExposeProxy(
  {
    helloParent() {
      console.log('hello parent')
    },
  },
  child1,
  child2,
)

进行了批量导出,然后在app.vue中进行了调用,一切准备就绪,我们继续探索。

进阶:Typescript 版本封装

这里我们直接给出最终的版本再一步一步探究

ts 复制代码
import { type ComponentPublicInstance, type Ref } from 'vue'

type Simplify<T> = T extends any
  ? {
      [K in keyof T]: T[K]
    }
  : never

type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (
  k: infer I,
) => void
  ? I
  : never

type GetComponentType<T extends any[]> = UnionToIntersection<
  T[number] extends Ref<infer V> ? NonNullable<V> : never
>

type ExtraExposed<T extends ComponentPublicInstance> = Omit<
  T,
  keyof ComponentPublicInstance | keyof T['$props']
>

export function createExposeProxy<
  R extends {},
  T extends Ref<ComponentPublicInstance | null | undefined>[],
>(expose: R, ...refs: T) {
  const exposeProxy = new Proxy(expose, {
    has(target, key) {
      const has = key in target
      if (has) {
        return true
      }
      for (const item of refs) {
        if (item.value && key in item.value) {
          return true
        }
      }
      return false
    },
    get(target: any, key) {
      if (key in target) {
        return target[key]
      }
      for (const item of refs) {
        if (item.value && key in item.value) {
          return (item.value as any)[key]
        }
      }
    },
  })

  // @ts-ignore
  return exposeProxy as any as Simplify<R & ExtraExposed<GetComponentType<T>>>
}

我们首先来看 createExposeProxy 的参数部分,它定义了两个泛型参数 RT, 分别是父组件暴露出的类型和剩余参数的类型, 最后我们返回的时候,把返回值通过 as any as 强制转换为了 Simplify<R & ExtraExposed<ExposeProxy<T>>> ,这里就是我们推导返回值的核心,我们由里到外一一解释:

最里边一层是 GetComponentType<T>

ts 复制代码
type GetComponentType<T extends any[]> = UnionToIntersection<
  T[number] extends Ref<infer V> ? NonNullable<V> : never
>

它的作用是用来提取T extends Ref<ComponentPublicInstance | null | undefined>[] 这个元组里面的元素的类型,通过 T[number] extends Ref<infer V> ? NonNullable<V> : never 这里的 infer V,再结合NonNullable 去排除掉 nullundefined, 最终提取到 V 的类型,它就是 Child1Child2 实例的联合类型。

UnionToIntersection 是一个经典的工具类型, 它的作用就是把联合类型变成交叉类型,更多作用大家可以去问问Deepseek,这里我们简单举个例子:

ts 复制代码
type A = {
    count:number;
}
type B = {
    demo:number;
}

type C = A | B;

type D = UnionToIntersection<A | B>
// type D = {
//   count: number
//   demo: number
// }

也就是说, 在上一步我们获取到的是 Child1Child2 实例的联合类型, 它的类型大概是类似于

ts 复制代码
{
    ...
    helloChild1:()=>void;
    ...
} | {
    ...
    helloChild2:()=>void;
    ...
}

经过 UnionToIntersection 转化为了

ts 复制代码
{
    ...
    helloChild1:()=>void;
    helloChild2:()=>void;
    ...
}

到这里,我们如果用下面这个例子提取一下类型的话,会看到这样

TS 复制代码
type A = GetComponentType<[typeof child1, typeof child2]>

可以看到里面多了一些类似于 $ ,$props 这样的属性,这些是属于 ComponentPublicInstance 的,所以我们用 Omit 将它排除掉

ts 复制代码
type ExtraExposed<T extends ComponentPublicInstance> = Omit<
  T,
  keyof ComponentPublicInstance 
  // | keyof T['$props'] 暂时注释
>

type A = GetComponentType<[typeof child1, typeof child2]>

type B = ExtraExposed<A>

可以看到里面有我们之前在子组件中定义的props msgChild1 msgChild2,所以我们需要进一步的把它给排除,而这部分可以通过 T[$props] 获取到

ts 复制代码
type ExtraExposed<T extends ComponentPublicInstance> = Omit<
  T,
  keyof ComponentPublicInstance 
  | keyof T['$props'] // 新增
>

到这里我们已经可以提取到 Child1.vueChild2.vue 导出的类型了,最后我们再通过 R & ExtraExposed<GetComponentType<T>> 和父组件的exposed 的对象的类型合并一下,就能得到最终的类型

ts 复制代码
const target = {
  helloParent() {
    console.log('hello parent')
  },
}
const proxy = createExposeProxy(target, child1, child2)

type A = GetComponentType<[typeof child1, typeof child2]>

type B = ExtraExposed<A>

type C = B & typeof target

这里可以看到C的类型被定义为了

ts 复制代码
B & {  
helloParent(): void;  
}

由于ts的类型默认是懒加载的,所以显示效果不是那么一目了然, 我们借用下面这个工具把它们强制平铺

ts 复制代码
type Simplify<T> = T extends any
  ? {
      [K in keyof T]: T[K]
    }
  : never

最终就得到了一个可读性很高的类型

这也就是我们函数中 exposeProxy as any as Simplify<R & ExtraExposed<GetComponentType<T>>> 的实现原理

需要注意的是,这里我单独验算的时候是没有问题的,但是放在函数中就显示有bug

所以我用了@ts-ignore去规避它,但是这个函数推导的结果是对的,不影响使用,如果有大神知道为啥这里会报错的话,还望告知

最终效果如下:

至此,我们就完成了一个功能完善类型安全的一个函数。

总结

本文系统探讨了Vue3中实现组件跨层级方法暴露的三种渐进式方案,通过实际案例演示了从基础到进阶的技术推导过程,最终形成了类型安全、动态响应的高效解决方案。

不论是逻辑的实现部分合适最后的类型推导流程,都是适合初中级前端学习不错文章,如果感觉有帮助那就点个赞👍👍👍吧

最近准备写一系列关于 Vue3 相关技巧的系列文章,下面这些文章也同样精彩,感兴趣的可以关注一波。

【Vue3干货👍】template setup 和 tsx 的混合开发实践

相关推荐
Captaincc1 小时前
这款堪称编程界的“自动驾驶”利器,集开发、调试、提 PR、联调、部署于一体
前端·ai 编程
我是小七呦1 小时前
万字血书!TypeScript 完全指南
前端·typescript
睡觉zzz1 小时前
vue3中的组件通信
vue.js
simple丶1 小时前
Webpack 基础配置与懒加载
前端·架构
褪色的笔记簿1 小时前
Vue 2 中动态新增属性丢失响应性原因探究
vue.js
褪色的笔记簿1 小时前
探索 Vue.js 中 El-Form 的 resetFields 方法重置数据
vue.js
simple丶1 小时前
领域模型 模板引擎 dashboard应用列表及配置接口实现
前端·架构
冰夏之夜影1 小时前
【css酷炫效果】纯css实现液体按钮效果
前端·css·tensorflow
1 小时前
告别手写Codable!Swift宏库ZCMacro让序列化更轻松
前端