最近准备写一系列关于 Vue3
相关技巧的系列文章,下面这些文章也同样精彩,感兴趣的可以关注一波。
【Vue3干货👍】template setup 和 tsx 的混合开发实践
前言
在使用 Vue3 开发的过程中,常常有父组件需要批量导出子组件的属性和方法的场景------例如跨层级传递表单校验方法、集中管理复杂组件的状态操作,或是构建高阶组件时动态透传能力。传统方案往往需要手动通过
defineExpose
逐个暴露,再通过ref.value
逐一声明调用,这种重复性劳动不仅代码冗余,还会因组件迭代引发维护隐患。本文将系统探讨三种渐进式优化方案:从最基础的手动代理 实现精准控制,到基于
Proxy
的自动化劫持 实现代码透传,再到结合 TypeScript 类型体操实现智能提示与安全校验。通过层层递进的实践,完成对这个常见需求的剖析,在实践中更深入地学习Vue3。
案例准备
假定我们拥有两个组件 Child.vue
和 Parent.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.vue
中 onMounted
函数之后,所以在 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
这个变量的 has
和 get
操作,这样就完美解决了问题, 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.vue
和Child2.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
的参数部分,它定义了两个泛型参数 R
和 T
, 分别是父组件暴露出的类型和剩余参数的类型, 最后我们返回的时候,把返回值通过 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
去排除掉 null
和 undefined
, 最终提取到 V
的类型,它就是 Child1
和 Child2
实例的联合类型。
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
// }
也就是说, 在上一步我们获取到的是 Child1
和 Child2
实例的联合类型, 它的类型大概是类似于
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.vue
和 Child2.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
相关技巧的系列文章,下面这些文章也同样精彩,感兴趣的可以关注一波。