跳转到内容

Vue3 + TS 二次封装组件库组件

刀刀
0字
0分钟
2026/8/16

思考

如何更好地封装组件库组件?主要从以下三个方面考虑:

  • props 如何穿透过去
  • 插槽如何穿透过去
  • 组件内部的方法如何暴露出去

props 穿透

props 穿透可写成以下方式:

vue
<template>
  <Son></Son>
</template>
vue
<template>
  <el-input v-bind="$attrs"></el-input>
</template>

这么做虽可穿透事件与方法,但父组件没有代码提示,只能手敲或翻阅文档,不利于开发,也失去了 TypeScript 的意义。因此需要转换思路。

Element Plus 组件库导出提供了相关组件类型,可借助此类型获取代码提示。

son.vue
vue
<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 报错,提示参数是必传的,需要使用 TypeScriptPartial 类型包裹。

Partial 作用是将类型中的所有属性变为可选。

目前只考虑了属性,还没考虑到事件。事件都在 $attrs 中,因此可用扩展运算符浅拷贝的方式把 $attrsprops 合并。

son.vue
vue
<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,循环把插槽挂载到子组件上,代码如下所示:

vue
<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

useAttrsuseSlotsVue3 提供的钩子函数,可获取 $attrs$slots

vue
<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 函数来挂载插槽。

son.vue
vue
<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,描述了如何渲染 ElInput 组件。

组件方法暴露

Object.assign 暴露

组件方法暴露可使用 ref 暴露方法,通过 Object.assign 合并对象的方式暴露出去。

son.vue
vue
<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) // 可以拿到组件的实例,里面有它的方法
const inputInstance = {} 
onMounted(() => {
  Object.assign(inputInstance, inputRef.value) 
}) 
defineExpose({ inputInstance }) 
</script>

<template>
  <component
    :is="h(ElInput, { ...$attrs, ...props, ref: 'inputRef' }, $slots)"
  ></component
  >
</template>

但这种方法不可取,如果组件绑定了 v-if,后续存在变动的情况,就拿不到 inputRef 的值了。

Proxy 代理暴露

既然 defineExpose 要传一个对象,那可不可以用 new Proxy 来代理?外部使用子组件时触发代理的 get 方法,把子组件实例返回出去。

son.vue
vue
<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) // 可以拿到组件的实例,里面有它的方法

defineExpose(
  new Proxy(
    {}, 
    {
      get(target, key) {
        return inputRef.value?.[key] 
      }, 
    }, 
  ),
) 
</script>

<template>
  <component
    :is="h(ElInput, { ...$attrs, ...props, ref: 'inputRef' }, $slots)"
  ></component>
</template>

但这样还是不够,在父组件仍然拿不到子组件暴露出去的方法。这是因为 Vue 在底层封装获取代理对象时,还会判断 proxy.has(),如果 proxy.has() 返回 false,则不会获取到代理对象。

上方代码需要再修改一下。

son.vue
vue
<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) // 可以拿到组件的实例,里面有它的方法

defineExpose(
  new Proxy(
    {},
    {
      get(target, key) {
        return inputRef.value?.[key]
      },
      has(target, key) {
        return key in inputRef.value 
      }, 
    },
  ),
)
</script>

<template>
  <component
    :is="h(ElInput, { ...$attrs, ...props, ref: 'inputRef' }, $slots)"
  ></component>
</template>

exposed 暴露

ref 不仅可以赋值字符串,还能赋值一个函数,函数的形参接收的就是组件实例。

那么怎么抛出去呢?Vue3提供了 getCurrentInstance() 方法实例,提供了一个 exposed 方法可以暴露组件实例。因此上方代码可以改写为:

vue
<template>
  <Son model-value="111">
    <template #prefix></template>
  </Son>
</template>
vue
<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>

注意

  1. defineExpose({ a: 1 }) 实际上等价于 vm = getCurrentInstance(); vm.exposed = { a: 1 }
  2. 父组件拿到的不是直接拿 exposed,而是 exposedexposeProxy 代理对象属性,因此不能只修改 vm.exposed ,还需要修改 vm.exposeProxy

类型处理

现在父组件能够正常传递属性方法给子组件,也能拿到子组件暴露的属性和方法。但是在父组件书写属性和方法时,是没有提示的。

解决方法只需要加一行 defineExpose() 代码即可。

son.vue
vue
<script lang="ts" setup>
import { ElInput, type InputProps } from 'element-plus'
import { h, getCurrentInstance } from 'vue'
import type { ComponentInstance } from 'vue'
const props = defineProps<Partial<InputProps>>()

const vm = getCurrentInstance()
function changeRef(inputInstance) {
  vm.exposed = vm.exposeProxy = inputInstance || {}
}

defineExpose({} as ComponentInstance<typeof ElInput>) 
</script>

<template>
  <component
    :is="h(ElInput, { ...$attrs, ...props, ref: changeRef }, $slots)"
  ></component>
</template>

无需担心 defineExpose() 会覆盖掉 exposed ,因为 exposedref 创建的响应式对象,等到组件开始创建,才会执行 changeRef 方法;而 defineExpose() 是在组件创建前执行的,因此等组件创建的时候,vm.exposed 会覆盖掉前面默认的 defineExpose({})

defineExpose({}) 的作用是通过断言 ComponentInstance 类型,来约束 exposed 的类型,从而在父组件中,能够有提示。

注意

这个方法有局限性,只能在 VS Code 内使用,而 WebStorm 无法使用。因为这个方法是基于 Vue Language Tool 插件实现的,而 WebStorm 并没有支持这个插件。

回顾:h 函数的使用

基础使用

h 函数是Vue3提供的用于创建虚拟DMO节点(vNode)的方法,vNode 是一个对象,描述了如何渲染一个组件。

h 函数第一个参数是组件,第二个参数是属性,第三个参数是插槽。

vue
<script lang="ts" setup>
import { h } from 'vue'

const Comp = h('div', { class: 'test' }, 'hello world')
</script>

<template>
  <Comp></Comp>
</template>

响应式变量

同时,它也支持传入变量动态展示内容,整体作为参数用动态组件展示。

vue
<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 动态节点时,会调用这个函数,这样就会把这个函数视为副作用函数。

vue
<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 类型报错。

vue
<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>
vue
<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 来渲染插槽内容。

vue
<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>

想要实现作用于插槽的功能,即插槽内传值,使用插槽者取值,也可以轻松实现了。

vue
<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 函数里的第二个参数对象中,添加对应的事件即可。

vue
<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 在挂载组件节点时,会调用这个函数,这样就会把这个函数视为副作用函数,后续变量变更也会触发更新。

下面写一个父组件和一个子组件,来看一下整体代码:

vue
<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>
vue
<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>

动手实操

跳转预览:点击跳转

贡献者

The avatar of contributor named as duyidao duyidao
The avatar of contributor named as 刀刀 刀刀
The avatar of contributor named as v_duyilin v_duyilin

页面历史

刀刀博客累计访客 人;文档累计访问量共