Vue3 + TS 二次封装组件库组件
刀刀
8/8/2025
0 字
0 分钟
- 来源:远方os;视频地址:vue组件二次封装-终极版
- 来源:远方os;视频地址:组件二次封装时不一样的插槽传递方式
- 来源:远方os;视频地址:h函数的使用
- 来源:远方os;视频地址:h函数的使用场景
思考
如何更好的封装一个组件库的组件呢?主要从以下三个方面考虑:
props
如何穿透过去- 插槽 如何穿透过去
- 组件内部的方法如何暴露出去
props穿透
props
穿透可以写成以下的方式:
<template>
<Son></Son>
</template>
<template>
<el-input v-bind="$attrs"></el-input>
</template>
这么做虽然可以穿透事件与方法,但是父组件没有代码提示,只能被迫手敲或者翻阅文档,这样做不利于开发,也失去了 TypeScript 的意义。因此需要转换思路。
Element Plus 组件库导出提供了相关的组件类型,我们可以借助这个类型来获取代码提示。<script lang="ts" setup>
import { type InputProps } from 'element-plus'
const props = defineProps<InputProps>()
</script>
<template>
<el-input v-bind="$attrs"></el-input>
<el-input v-bind="props"></el-input>
</template>
现在父组件使用时能看到相应的提示了,但是出现了 TypeScript 的报错,提示参数是必传的,需要使用 TypeScript 的 Partial
类型来包裹一下。 Partial
作用是将类型中的所有属性变为可选。
目前只考虑了属性,还没考虑到事件。事件都在 $attrs
中,因此可以用浅拷贝的方式把 $attrs
和 props
合并一下。
<script lang="ts" setup>
import { type InputProps } from 'element-plus'
const props = defineProps<InputProps>()
const props = defineProps<Partial<InputProps>>()
</script>
<template>
<el-input v-bind="props"></el-input>
<el-input v-bind="{ ...$attrs, ...props }"></el-input>
</template>
注意
$attrs
要写在前面,...props
要写在后面,以确保能覆盖掉 $attrs
中的属性。
插槽穿透
方法一:插槽循环
插槽写法可以 v-for
循环 $slots
,循环把插槽挂载到子组件上,代码如下所示:
<script lang="ts" setup>
import { type InputProps } from 'element-plus'
const props = defineProps<Partial<InputProps>>()
</script>
<template>
<el-input v-bind="{ ...$attrs, ...props }">
<template
v-for="(_, name) in $slots"
:key="name"
#[name]="slotProps"
>
<slot :name="name" v-bind="slotProps"></slot>
</template>
</el-input>
</template>
备注
#[name]
写法等价于 v-slot:[name]
,都是具名插槽。
但是这么做很繁琐。
方法二:h&useAttrs&useSlots
useAttrs
和 useSlots
是 Vue3 提供的钩子函数,可以获取到 $attrs
和 $slots
。
<script lang="ts" setup>
import { type InputProps } from 'element-plus'
import { ElInput, type InputProps } from 'element-plus'
import { h, useAttrs, useSlots } from 'vue'
const props = defineProps<Partial<InputProps>>()
const Comp = h(ElInput, useAttrs(), useSlots())
</script>
<template>
<el-input v-bind="{ ...attrs, ...props }">
<Comp />
</el-input>
</template>
这样写虽然优雅了,但是还是不够精简。
方法三:h函数精简版
可以转变一下思路,不直接使用 <el-input></el-input>
的方式挂载组件,而是通过 <component :is=""></component>
的形式挂载组件。is
不仅可以给一个子组件,还可以给一个 h
函数,因此可以借助 h
函数来挂载插槽。
<script lang="ts" setup>
import { ElInput, type InputProps } from 'element-plus'
import { h, useAttrs, useSlots } from 'vue'
import { h } from 'vue'
const props = defineProps<Partial<InputProps>>()
</script>
<template>
<el-input v-bind="{ ...attrs, ...props }">
<Comp />
</el-input>
<component :is="h(ElInput, { ...$attrs, ...props }, $slots)"></component>
</template>
component 组件为什么可以传入 h 函数 ?
h
函数用于创建虚拟 DMO 节点(vnode
),is 属性接收到一个函数时,也就是 h(ElInput, $attrs, $slots)
,会立即执行并返回一个 VNode
,这个 VNode
描述了如何渲染 ElInput
组件。
组件方法暴露
ref暴露
组件方法暴露可以使用 ref
来暴露方法。
<script lang="ts" setup>
import { ElInput, type InputProps } from 'element-plus'
import { h, ref } from 'vue'
const props = defineProps<Partial<InputProps>>()
const inputRef = ref()
console.log(inputRef.value)
</script>
<template>
<component :is="h(ElInput, { ...$attrs, ...props, ref: 'inputRef' }, $slots)"></component>
</template>
但是这种方法不可取,如果这个组件绑定了 v-if
,后续存在可能会变动的情况,这样就拿不到 inputRef
的值了。
exposed暴露
ref
不仅可以赋值字符串,但是还能赋值一个函数,函数的形参接收的就是组件实例。
那么怎么抛出去呢?Vue3 提供了 getCurrentInstance()
方法实例,提供了一个 exposed
方法可以暴露组件实例。因此上方代码可以改写为:
<template>
<Son model-value="111">
<template #prefix></template>
</Son>
</template>
<script lang="ts" setup>
import { ElInput, type InputProps } from 'element-plus'
import { h, getCurrentInstance } from 'vue'
const props = defineProps<Partial<InputProps>>()
const vm = getCurrentInstance()
function changeRef(inputInstance) {
vm.exposed = vm.exposeProxy = inputInstance || {}
}
</script>
<template>
<component :is="h(ElInput, { ...$attrs, ...props, ref: changeRef }, $slots)"></component>
</template>
注意
父组件拿到的不是直接拿 exposed
,而是 exposed
的 exposeProxy
代理对象属性,因此不能只修改 vm.exposed
,还需要修改 vm.exposeProxy
。
回顾:h函数的使用
基础使用
h
函数是 Vue3 提供的用于创建虚拟 DMO 节点(vnode
)的方法,vnode
是一个对象,描述了如何渲染一个组件。
h
函数第一个参数是组件,第二个参数是属性,第三个参数是插槽。
<script lang="ts" setup>
import { h } from 'vue'
const Comp = h('div', { class: 'test' }, 'hello world')
</script>
<template>
<Comp></Comp>
</template>
响应式变量
同时,它也支持传入变量动态展示内容,整体作为参数用动态组件展示。
<script lang="ts" setup>
import { h } from 'vue'
import { h, ref } from 'vue'
const msg = ref('hello world')
const Comp = h('div', { class: 'test' }, 'hello world')
const Comp = h('div', { class: 'test' }, msg.value)
setTimeout(() => {
msg.value = 'hello vue3'
}, 2000)
</script>
<template>
<Comp></Comp>
<component :is="Comp"></component>
</template>
但是过了两秒后,发现页面并没有动态更新。这是为什么呢?
这涉及到了 Vue3 的响应式原理,ref
创建的响应式对象,在 effect
副作用函数中使用,彼此才能建立依赖关系。ref
发生了改变后,会触发 effect
副作用函数重新执行,从而更新页面。
在 template
模板中使用 ref
创建的响应式对象,Vue3 底层会帮我们收集依赖建立关系,但是在 setup
中手动使用 h
函数,不会建立这层依赖,因此不会触发更新。
解决方法为修改 Comp
,不再单单赋值一个 h
函数,而是赋值一个函数,返回值是 h
函数。这样 template
在挂载 component
动态节点时,会调用这个函数,这样就会把这个函数视为副作用函数。
<script lang="ts" setup>
import { h, ref } from 'vue'
const msg = ref('hello world')
const Comp = h('div', { class: 'test' }, msg.value)
const Comp = () => h('div', { class: 'test' }, msg.value)
setTimeout(() => {
msg.value = 'hello vue3'
}, 2000)
</script>
<template>
<!-- 在使用时调用Comp函数,Comp函数就被视作副作用函数 -->
<component :is="Comp"></component>
</template>
总结
在 template
中使用函数会做依赖收集,所以可以更新;在 setup
函数里,没有进行依赖收集。
传值与插槽
作为一个组件,自然是允许使用者传入属性和插槽的。而 Comp
函数实际上可以看作是 setup
函数,第一个参数就是传值 props
,第二个参数就可以解构出插槽 { slots }
。
而 ts
类型,可以通过 Vue3 官方文档提供的 FunctionComponent
类型,来定义 props
类型,避免 ts
类型报错。
<script lang="ts" setup>
import { h, ref } from 'vue'
import { h, ref, type FunctionComponent } from 'vue'
const Comp = ((props, { slots}) => {
return h('div', { class: 'test' }, props.count)
}) as FunctionComponent<{ count: number }>
</script>
<template>
<!-- 在使用时调用Comp函数,Comp函数就被视作副作用函数 -->
<component :is="Comp" :count="1">
<div>111</div>
</component>
</template>
<script lang="ts" setup>
import { h, ref } from 'vue'
import { h, ref, type FunctionComponent } from 'vue'
const Comp = ((props, { slots}) => {
return h('div', { class: 'test' }, slots)
}) as FunctionComponent<{ count: number }>
</script>
<template>
<!-- 在使用时调用Comp函数,Comp函数就被视作副作用函数 -->
<component :is="Comp" :count="1">
<div>111</div>
</component>
</template>
但是目前只考虑到一个默认插槽的情况,如果使用多个具名插槽、作用域插槽,就会出现问题,页面上还是只展示默认插槽的内容。
那么该如何渲染多个插槽呢?前面也拿到了插槽 slots
,那么就可以通过 slots
来渲染插槽内容。
<script lang="ts" setup>
import { h, ref, type FunctionComponent } from 'vue'
const Comp = ((props, { slots}) => {
return h('div', { class: 'test' }, [
slots?.header?.(),
'我自己的内容',
slots?.default?.()
])
}) as FunctionComponent<{ count: number }>
</script>
<template>
<!-- 在使用时调用Comp函数,Comp函数就被视作副作用函数 -->
<component :is="Comp" :count="1">
<div>111</div>
<template #header>
<div>333</div>
</template>
</component>
</template>
想要实现作用于插槽的功能,即插槽内传值,使用插槽者取值,也可以轻松实现了。
<script lang="ts" setup>
import { h, ref, type FunctionComponent } from 'vue'
const Comp = ((props, { slots}) => {
const a = ref('a')
return h('div', { class: 'test' }, [
slots?.header?.(),
slots?.header?.(a.value),
'我自己的内容',
slots?.default?.()
])
}) as FunctionComponent<{ count: number }>
</script>
<template>
<!-- 在使用时调用Comp函数,Comp函数就被视作副作用函数 -->
<component :is="Comp" :count="1">
<div>111</div>
<template #header="{ a }">
<div>333 {{ a }}</div>
</template>
</component>
</template>
事件传递
事件传递就简单了,只需要在 h
函数里的第二个参数对象中,添加对应的事件即可。
<script lang="ts" setup>
import { h, ref, type FunctionComponent } from 'vue'
const Comp = ((props, { slots}) => {
const a = ref('a')
return h('div', {
class: 'test',
onClick: () => {
console.log('click')
}
}, [
slots?.header?.(a.value),
'我自己的内容',
slots?.default?.()
])
}) as FunctionComponent<{ count: number }>
</script>
<template>
<!-- 在使用时调用Comp函数,Comp函数就被视作副作用函数 -->
<component :is="Comp" :count="1">
<div>111</div>
<template #header="{ a }">
<div>333 {{ a }}</div>
</template>
</component>
</template>
总结使用
实际上组件就是一个函数,函数的第一个参数可以拿到传值 props
,第二个参数可以解构出插槽 slots
。而 ts
类型,可以通过 Vue3 官方文档提供的 FunctionComponent
类型,来定义 props
类型,避免 ts
类型报错。
想要在 setup
内使用 h
函数,需要把 h
函数作为一个函数的返回值, template
在挂载组件节点时,会调用这个函数,这样就会把这个函数视为副作用函数,后续变量变更也会触发更新。
下面写一个父组件和一个子组件,来看一下整体代码:
<script lang="ts" setup>
import { ref } from 'vue'
const a = ref('hello son')
defineProps(['msg'])
const emits = defineEmits(['onFoo'])
setTimeout(() => {
emits('onFoo', 'to father')
}, 2000)
</script>
<template>
<div>
{{ msg }}
<slot>子组件默认内容</slot>
<slot name="footer" :a="a">子组件默认footer内容</slot>
</div>
</template>
<script lang="ts" setup>
import Son from './Son.vue'
import { h, ref, type FunctionComponent } from 'vue'
const Comp = ((props, { slots }) => {
return h(Son,
{
msg: 'hello father',
onFoo: (val: string) => {
console.log('click foo', val)
}
},
{
default: slots?.default,
footer: () => {
return h('div', null, {
default: () => h('div', '我是嵌套的defalut'),
footer: ({ a }) => h('div', '我是嵌套的footer' + a),
})
}
}
)
}) as FunctionComponent
</script>
<template>
<Comp></Comp>
</template>