实现一个vue3组件库 - partial挑选(抽象组件)
pid: 109362778
引言
当我们在实现某个组件需要获取到外部传入的slot的引用、或者希望slot是单个元素时,通常会包裹一层元素(比如div)通过给外层元素打上ref标记来间接获取,例如:
<template>
<divider ref="container">
<slot></slot>
</divider>
</template>
缺点很明显,加了一层元素破坏了原本的html结构
假设slot本身就是单个元素或者希望slot是单个元素时,如何在组件内部不添加外层元素的情况下,拿到slot单元素的引用呢? 比如:
<s-partial ref="default">
<slot></slot>
</s-partial>
通过default标记,我们可以拿到slot中的第一个合法元素,且s-partial不会添加任何dom元素。
举个具体的例子,比如在使用Popconfirm 气泡确认框 | Element Plus 时,就会默认使得reference slot是单个元素.
但是当我们传入多个元素时, ELPopconfirm也能在不添加外层div的情况下,实现效果:
<el-popconfirm title="Are you sure to delete this?">
<template #reference>
<el-button>Delete</el-button>
<el-button>Delete</el-button>
</template>
</el-popconfirm>
可以看到我们传入的reference元素时两个按钮,但是实际只渲染了第一个元素,也就是从传入的slot中挑选出了第一个元素作为reference
为此,我们可以尝试实现一个抽象组件partial来实现上述功能。
前置芝士
何谓抽象组件?
在 Vue 中,抽象组件(Abstract Components)是一种特殊的组件,它们不会渲染成任何实际的 DOM 元素。抽象组件通常用于包装其他组件,以提供一些额外的逻辑、功能或样式,而不在页面上生成额外的 DOM 结构。
和vue文件写法不一样,抽象组件一般不携带template模块,抽象组件更像是由一堆js/ts组成的逻辑代码。最基本的抽象组件代码如下:
export default defineComponent({
setup(props, context) {
return () => {
return h(??)
}
},
})
其中setup函数的参数,props为你理解的props, context为当前组件的上下文。通过上下文,你可以拿到许多有用的信息,比如slots
, attrs
, 当然对于很多信息vue也提供相应的hook
来获取他们,比如useSlots
, useAttrs
其中setup函数的返回值被称为render
函数, 用作vue的渲染过程,
render函数的返回值应该是一个h函数,也被称为createElement
函数, 此函数用作vue中创建虚拟节点VNode
。
h(createElement)函数
简单来说: h函数
可以接受三个参数h( tag
: string|VNode, props
: {}, children
: [] )
其中tag
表示虚拟节点的类型:
- 当它是string时,取值可以是
div
span
这些普通的dom节点名称, 也可以是你定义或全局组件的名称 - 当它是VNode类型时, 返回它本身
props表示给这个虚拟节点加上的属性,注意,并不是组件的props,而是像是attribute,比如class, style, data-x这些给节点加上的属性
children表示这个虚拟节点的子节点, 可以是h函数的返回值(返回VNode),也可以是现成的VNode(比如你传入的插槽)
如何使用slots?
当我们在使用某个组件时,难免会需要传入一些slot, 其实这些slot都是有名字的,也就是具名插槽
默认的名字为default
因此我们可以通过slots.default(slotName)
来获取对应的插槽。
需要注意的是, slots.default(slotName)是一个函数,这些函数的返回值才是你真正传入的模板生成的VNodeList
因此,当我们写一个抽象组件,想要给传入的模板套一层div再渲染时,可以写成这样:
<script lang="ts">
import {defineComponent, h, useAttrs, useSlots, warn} from "vue";
export default defineComponent({
setup(props, context) {
const slots = context.slots;
const attrs = context.attrs;
/**
* 或者写成
* const slots = useSlots();
* const attrs = useAttrs();
*/
return () => {
return h('div', {...attrs}, [slots.default()])
}
}
})
</script>
实现思路
再来想想我们需要的效果, 获取传入slot.default中模板的第一个元素节点。
需要注意的是,vue会把注释节点、文本节点、template、slot节点都渲染成VNode
这些节点对于我们来说就是干扰项,需要排除。
如何区分不同的VNode呢?
我们先写一段代码来看看:
<script lang="ts">
import {defineComponent, h, VNode} from "vue";
export default defineComponent({
setup(__, context) {
const slots = context.slots;
const attrs = context.attrs;
const VNodeList:VNode[] = slots.default && slots.default();
for (let i = 0; i < VNodeList.length; i++) {
const VNode = VNodeList[i];
console.log(VNode,"type: " ,VNode.type);
}
return () => {
return h('div', {...attrs}, [slots.default()])
}
},
name:'SPlay'
})
</script>
上述代码只是打印出slot.default下虚拟节点的类型
测试:
<s-play>
<template #default>
我是文本节点
<!--我是注释节点-->
<s-input placeholder="我是组件节点"></s-input>
<input placeholder="我是普通dom节点"/>
<slot>我是slot节点</slot>
</template>
</s-play>
我们不管渲染效果是什么,打印的结果是:
可以看到对于特殊的节点,它们的type都是symbol类型的。这是一个很重要的区分标志。
代码实现
满足我们需求的节点有: 文本节点, 组件节点,普通dom节点。明确这点之后,我们就很容易写出以下代码:
<script lang="ts">
import {defineComponent, h, useAttrs, VNode, warn} from "vue";
export default defineComponent({
setup(__, context) {
const slots = context.slots;
const attrs = useAttrs();
// slots.default为空, 抛出警告
if (!slots.default) {
warn('SPartial has empty slot');
return void 0
}
const VNodes: VNode[] = slots.default();
let dft: any; //default, 目标节点
let old: any; //目标节点的父节点
// 遍历default函数生成的虚拟节点列表, 找到第一个非注释节点
for (let i = 0; i < VNodes.length; i++) {
const VNode = VNodes[i];
if (VNode.type !== Symbol.for('v-cmt')) {
dft = VNode;
break;
}
}
// 经过遍历后,如果值为空,说明没有满足情况的节点,抛出警告
if (!dft) {
warn("is a empty nodeList!");
return void 0
}
// 只有当节点类型是template、slot、txt、comment时, type才会是symbol
// 这里是为了找到第一个满足情况的节点
while (dft && typeof dft!.type === 'symbol') {
let index = 0;
// 当前是文本节点,满足要求, 为当前文本节点套上一层span
if (dft!.type === Symbol.for('v-txt')) {
dft = h('span', {...attrs}, [dft]);
break;
}
// 当前节点是注释节点,则不断获取该节点的下一个节点
while (dft!.type === Symbol.for('v-cmt') && index < old.children.length) {
dft = (old as any)!.children[++index];
}
// 没有经历上面的过程则index不变, 此时只剩template和slot的情况
// 获取当前节点的第一个子节点
if (index === 0) {
old = dft;
dft = (dft as any)!.children[0];
}
}
// 如果到最后也没有合法节点,则抛出警告
if (!dft) {
warn("There are no available VNodes for partial");
return
}
// 最终渲染该节点
return () => {
return h(dft as any, {...attrs})
}
},
name: 'SPartial',
})
</script>
最终效果
-
egs1:
<s-partial> <!-- cmt 1--> <!-- cmt 2--> <slot> <!-- cmt 3--> <s-input></s-input> </slot> </s-partial>
-
egs2:
<s-partial> <!-- cmt 1--> hello world! <!-- cmt 2--> <slot> <!-- cmt 3--> <s-input></s-input> </slot> </s-partial>
写在最后
这里只是提供引言中所述问题的一个解决方案, 代码可能不全面, 欢迎在评论区指出,一起掉头发(
这个项目的地址是:lastertd/sss-ui-plus: 适用于vue3的组件库 (github.com)在这里求一个star✨
感谢看到最后💟💟💟
转载自:https://juejin.cn/post/7278583672402935862