当前位置:首页 > 技术文章 > 正文内容

从Vue小白到高手:跟Vue团队学Vue3组件二次封装的秘诀

zonemu1周前 (07-25)技术文章11

从二次封装 el-input 开始

要解决的问题

  1. props 如何穿透出去?
  2. slots 如何穿透出去 ?
  3. 组件的方法如何暴露出去 ?

处理 props

传递 $attrs

为了保证组件原有属性和事件能被正常传递,我们可以使用 mergeProps 合并 $attrs 和重写默认属性或新增 props 对象,绑定到原有组件:


<script setup lang="ts">
import { mergeProps } from 'vue'
import type { ExtractPublicPropTypes } from "vue"
import type { InputProps } from 'element-plus'
const props = defineProps<ExtractPublicPropTypes<InputProps>>()
</script>

<template>
<!-- <el-input v-bind="{...$attrs, ...props}"></el-input> -->
<el-input v-bind="mergeProps($attrs, props)"></el-input>
</template>
  1. $attrs 包含所有传入的 props 和 emit 事件;
  2. 直接使用 $attrs 是没有 TS 类型提示的,所以我们要声明一个 props,至于 props 类型一般组件库都会导出。

ExtractPublicPropTypes 类型是什么作用 ? (点击展开)

其实,在上面的代码中,对于 props 的 TS 类型定义,一开始其实我使用的是 Partial<InputProps> ,把所有属性都变成可选,这样父组件使用时类型提示才不会报错,但是这样并不严谨,如果组件里有 props 属性是必填的,那可能不会有完备的类型提示。


对于 TypeScript 如果需要获取 Props 的类型,那就需要用到 Vue 的一个辅助类型ExtractPropTypes,而在 element-plus 源码中, 大部分组件的 props 是用 ExtractPropTypes<typeof inputProps> 抽离的(源码链接 )。


这里是我们属于二次封装组件,所以我们是外部引用 (父组件),对于外部引用,我们就使用 ExtractPublicPropTypes


参考链接

  • 『精』Vue 组件如何模块化抽离Props (强烈推荐阅读)
  • Vue 官方文档 - TypeScript 工具类型 - ExtractPublicPropTypes<T>

覆盖默认值

我们可以使用 withDefaults 给 props 设置默认值,从而达到覆盖原组件默认值的效果

<script setup lang="ts">
import { mergeProps } from 'vue'
import type { ExtractPublicPropTypes } from "vue"
import type { InputProps } from 'element-plus'
type YiInputProps = ExtractPublicPropTypes<InputProps> & {
/* 可以在此处添加新属性 */
}
const props = withDefaults(defineProps<YiInputProps>(), {
clearable: true, // 改变el-input clearable 默认值
/* 可以在此处为新属性添加默认值 */
})
</script>

<template>
<el-input v-bind="mergeProps($attrs, props)"></el-input>
</template>

处理 slots

常规版本

我们以 element-plus Input 输入框 组件为例,为了向子组件传递插槽,常规的做法 , 遍历 $slots来实现,不论是封装什么组件都可以无脑使用 v-for v-for="(_, name) in $slots",即使组件插槽相互有逻辑也不会被影响。

<script setup lang="ts">
import { mergeProps } from 'vue'
import type { ExtractPublicPropTypes } from "vue"
import type { InputProps } from 'element-plus'
type YiInputProps = ExtractPublicPropTypes<InputProps> & {}

const props = withDefaults(defineProps<YiInputProps>(),{})
</script>

<template>
<el-input v-bind="mergeProps($attrs, props)">
<template v-for="(_, name) in $slots" :key="name" #[name]="slotProps">
<slot :name="name" v-bind="slotProps"></slot>
</template>
</el-input>
</template>

#[name]="slotProps" 等同于 v-slot:[name]="slotProps"

关于遍历 $slot 写法问题

$slots 是个Proxy 对象,下面的写法均可

v-for="(_, name) in $slots"
v-for="(_, name) of $slots"
v-for="(_, name) Object.keys($slots)"

示例:在父组件使用, 并传递 prependappend 插槽:

<template>
<div>
<h3> 父组件</h3>
<YiInput ref="inputRef" v-model="msg" placeholder="请输入内容">
<template #append>
<el-icon><Search /></el-icon>
</template>
<template #suffix>
<el-icon><User /></el-icon>
</template>
</YiInput>
</div>
</template>
<script lang="ts" setup>
import { Search, User } from '@element-plus/icons-vue'
import type { InputInstance } from 'element-plus'
const inputRef = ref<InputInstance>()
const msg = ref('Hello world')
setTimeout(() => {
inputRef.value?.focus() // 自动聚焦
}, 3000)
</script>

使用 h 函数 (花活版)

<script setup lang="ts">
import { ElInput } from "element-plus"

import type { ExtractPublicPropTypes } from "vue"
import type { InputProps } from 'element-plus'
type YiInputProps = ExtractPublicPropTypes<InputProps> & {}
const props = withDefaults(defineProps<YiInputProps>(),{})
</script>

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

使用 Vue 3.5 新增加辅助函数 ( 花活版 )

在 Vue 中,我们可以在模板中直接通过 $slots$attrs 来访问它们、 在 Vue 3.4 版本之后,可以分别用 useSlotsuseAttrs 两个辅助函数:

<script setup lang="ts">
import { h, mergeProps, useAttrs, useSlots } from 'vue'
import { ElInput } from "element-plus"

import type { ExtractPublicPropTypes } from "vue"
import type { InputProps } from 'element-plus'
type YiInputProps = ExtractPublicPropTypes<InputProps> & {}
const props = withDefaults(defineProps<YiInputProps>(),{})
const attrs = useAttrs()
const slots = useSlots()
const $props = mergeProps(attrs, props)
</script>

<template>
<component :is="h(ElInput, $props, slots)" />
</template>

component 组件为什么可以传入 h 函数 ? (点击展开)

h 函数用于创建虚拟 DMO 节点(vnode),is 属性接收到一个函数时,也就是h(ElInput, $attrs, $slots) ,会立即执行并返回一个 VNode,这个 VNode 描述了如何渲染 ElInput 组件。

处理 ref

问题: 封装时怎么如何导出原组件实例方法?

在二次封装子组件时,为了让父组件能够获取子组件的 ref, 并能够调用一些原有的方法,我们还需要将子组件的方法暴露出去。

对于这个需求,网上方法五花八门,但是在 Vue3 的 setup 模板中,我个人认为,其实并没有特别优雅的方式

1. 向父组件暴露 ref 函数

思路:创建一个 getRef 的函数,把 ref 暴露出去, 父组件调用 getRef 方法后在执行子组件方法的调用:

<script setup lang="ts">
import { ref } from 'vue'
const inputRef = ref()
const getRef = () => inputRef.value
defineExpose({ getRef })
</script>

<template>
<el-input ref="rawRef" v-bind="{...$attrs, ...props}" />
</template>

2. 使用 Proxy 代理

另一个思路,我们可以使用 Proxy 代理暴露出去的方法

<script setup lang="ts">
import { ref } from 'vue'
const rawRef = ref()
defineExpose(
new Proxy({},
{
get: (_target, key) => rawRef.value?.[key],
// 因为代理的是一个空对象,用 has 判断一下,访问的属性是否存在
has: (_target, key) => key in (rawRef.value || {})
}
)
)
</script>

<template>
<el-input ref="rawRef" v-bind="{...$attrs, ...props}" />
</template>
  • new Proxy().has | MDN

3. 使用 vm.exposed

<script setup lang="ts">
const props = defineProps()
const vm = getCurrentInstance()
const changeRef = (inputInstance) => {
vm!.exposed = inputInstance || {}
// 其实父组件不是直接拿到这个 exposed 的,拿的是子组件的代理对象,
// 不能只改变 exposed 的值,还要改变 exposeProxy 的值
vm!.exposeProxy = inputInstance || {}
// 上面代码也可以直接写成: vm!.exposeProxy = vm!.exposed = inputInstance || {}
}
</script>

<template>
<el-input ref="changeRef" v-bind="{...$attrs, ...props}" />
</template>

Why ?

我们添加一个 defineExpose 导出 { a: 1, b: 2 },然后打印 vm.exposed 和 changeRef 方法中返回的 value

<script setup lang="ts">
import { getCurrentInstance } from 'vue'
const vm = getCurrentInstance()
const props = defineProps()
console.log('vm===>',vm.exposed)
const changeRef = (inputInstance) => {
console.log('value===>',inputInstance)
vm.exposed = inputInstance ?? {}
}
defineExpose({a: 1, b: 2})
</script>
<template>
<ElInput :ref="changeRef" v-bind="{...$attrs, ...props}" >
<template v-for="(_, name) of $slots" #[name]="scop">
<slot :name="name" v-bind="scop"></slot>
</template>
</ElInput>
</template>

我们看看控制台打印是什么




  • 在子组件的 ref 传递一个函数 changeRef ,在这个函数中,可以拿到原先组件(el-input)的对外暴露的对象(方法);
  • getCurrentInstance 获取的是当前组件的实例, vm.exposed 拿到的是 defineExpose 导出的 { a: 1, b: 2 }

也就是说!vm.exposed 其实就是当前组件 defineExpose({}) 对外抛出的对象,所以我们只要在 changeRef 函数中,设置 vm.exposed = inputInstance ,就可以再次把 el-input 对外暴露的方法暴露给父组件。

推荐阅读:巧妙使用 Vue.extend 继承组件实现 el-table 双击可编辑 | 知乎 ,回顾一下 Vue 2 中实例的高可玩性。

终极版本 (TS 类型完备)

基础版

使用 proxy 暴露方法 :

<template>
<el-input v-bind="mergeProps($attrs, props)" class="e-input" ref="rawRef">
<template v-for="(_, name) in $slots" #[name]="scope">
<slot :name="name" v-bind="scope"></slot>
</template>
</el-input>
</template>
<script setup lang="ts">
import type { InputProps } from 'element-plus'
import type { InputInstance } from 'element-plus'
import { mergeProps } from 'vue'
type ElInputType = InputInstance & {/* 可以在此处添加新的组件实例方法 */}
type YiInputProps = ExtractPublicPropTypes<InputProps> & {/* 可以在此处添加新属性 */}
const props = withDefaults(defineProps<YiInputProps>(), {
clearable: true, // 改变el-input clearable 默认值
/* 可以在此处为新属性添加默认值 */
})
const rawRef = ref<ElInputType>()
defineExpose<ElInputType>(
new Proxy(
{},
{
get: (_target, key) => rawRef.value?.[key as keyof ElInputType],
has: (_target, key) => key in (rawRef.value || {}),
},
) as ElInputType
)
</script>
<style lang="scss" scoped>
.e-input {
min-width: 190px; // 添加新样式
// :deep(xxx) {} 覆盖原有样式
}
</style>

简化版

使用 vm.exposed

<template>
<el-input v-bind="mergeProps($attrs, props)" class="e-input" :ref="changeRef">
<template v-for="(_, name) in $slots" #[name]="scope">
<slot :name="name" v-bind="scope"></slot>
</template>
</el-input>
</template>
<script setup lang="ts">
import type { InputProps } from 'element-plus'
import type { InputInstance } from 'element-plus'
import type { ExtractPublicPropTypes } from 'vue'
import { mergeProps } from 'vue'
const vm = getCurrentInstance()
type ElInputType = InputInstance & {/* 可以在此处添加新的组件实例方法 */}
type YiInputProps = ExtractPublicPropTypes<InputProps> & {/* 可以在此处添加新属性 */}
const props = withDefaults(defineProps<YiInputProps>(), {
clearable: true // 改变el-input clearable 默认值
/* 可以在此处为新属性添加默认值 */
})
const changeRef = (inputInstance: ElInputType) => {
vm!.exposeProxy = vm!.exposed = inputInstance || {}
}
// 解决父组件使用插槽时没有类型提示问题 ( 也许是Vscode 的问题 ?)
defineExpose<ElInputType>()
</script>

注意:实际测试如果不添加 defineExpose<ElInputType>(),在父组件使用的时候,没有 emit 事件提示, 写 slot 插槽的时候也没有类型提示,我使用的是 Vscode 编辑器,不知道是否是 Vue 插件的问题。



h 函数版

<script setup lang="ts">
import { h } from 'vue'
import { ElInput } from 'element-plus'
import type { InputInstance, InputProps } from 'element-plus'
import type { ExtractPublicPropTypes } from 'vue'
type ElInputType = InputInstance & {}
type YiInputProps = ExtractPublicPropTypes<InputProps> & {}
const props = withDefaults(defineProps<YiInputProps>(), {})
const rawRef = ref<ElInputType>()
defineExpose<ElInputType>(new Proxy({}, {
get: (_target, key) => rawRef.value?.[key as keyof ElInputType],
has: (_target, key) => key in (rawRef.value || {}),
}) as ElInputType)
</script>
<template>
<component :is="h(ElInput, { ...$attrs, ...props, ref: rawRef }, $slots)"/>
</template>

使用示例:

<template>
<div>
<h3> 父组件</h3>
<YiInput ref="inputRef" v-model="msg" placeholder="请输入内容">
<template #append>
<el-icon><Search /></el-icon>
</template>
<template #suffix>
<el-icon><User /></el-icon>
</template>
</YiInput>
</div>
</template>
<script lang="ts" setup>
import { Search, User } from '@element-plus/icons-vue'
import type { InputInstance } from 'element-plus'
const inputRef = ref<InputInstance>()
const msg = ref('Hello world')
setTimeout(() => {
inputRef.value?.focus() // 自动聚焦
inputRef.value?.clear()
}, 3000)
</script>




相关文章

vue:组件中之间的传值(vue组件之间传参)

一、父子组件之间的传值----props/$emit1、父组件向子组件传值--props2.子组件想父组件传值-this.$emit('select',item)二、父组件向下(深层)...

GIT最佳实践,高效提升多团队协同开发效率

多个团队共同维护同一个微服务模块时,经常出现A团队已发布的功能,B团队提交测发布出现冲突或缺失,如何有效解决多团队共同维护的问题呢?常用的版本管理工具有GIT、SVN,这两种版本管理工具,各有千秋;虽...

解决GitLab报错:not allowed to force push code to a protected branch

当 force push 代码的时候,可能会遇到如下错误:You are not allowed to force push code to a protected branch on this pr...

前端学习又一大里程碑:html5+js写出歌词同步手机播放器

需要完整代码和视频请评论后加前端群470593776领取javascript进阶课题:HTML5迷你音乐播放器学习疲惫了,代码敲累了,听听自己做的的音乐播放器,放松与满足知识点:for循环语句,DOM...

15款测试html5响应式的在线工具(测试类h5)

手机、平板灯手持设备的增多,网站要顺应变化,就必须要做响应式开发,响应式网站最大的特点在于可以在不同设备下呈现不同的布局,是基于html5+css3技术,目前越来越多的网站开始采用了响应式设计,而下面...

HTML5+眼球追踪?黑科技颠覆传统手机体验

今天,iH5工具推出一个新的神秘功能——眼动追踪,可以通过摄像头捕捉观众眼球活动!为了给大家具体演示该功能的使用,我做了一个案例,供大家参考。实际效果如下:案例比较简单,就是通过眼动功能获取视觉焦点位...