声明:本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!
前言
承接上文,我们已经知道了无头组件库 Headless UI
是什么,以及有什么样的作用,包括怎么去实现一个最基本 Headless UI
无头组件库框架;
如果你还不知道也没关系,看看《Headless UI》这个免费的专栏就行了;
正所谓坐而论道、夸夸其谈、纸上谈兵 的人比比皆是,我们要做的不仅仅要能论其道、夸其谈 ,也要行胜于言,更要实战,真正做到知行合一。
那么接下来,就来到了第二篇的敲代码实战环节 ~
一、分析对比
1)分析传统 UI 组件库的 Popover 组件需要什么?
我相信大家多多少少都使用过 Ant-Design
、Element
、Vant
等等传统 UI 组件库中的其中一种,而且几乎都使用过了其中的 Popover 组件
功能,那么我们分析一下他们的共同点:
1.1 传统 UI 组件库的 Popover 组件基本使用:
1. Element:

2. Ant-Design:

1.2 其它更多功能:
- 触发方式:点击、聚焦、悬浮等等
- 位置显示
- 自定义内容
我相信这是大部分 Popover 组件所需的功能点,如果你能全部实现,那基本就算是一个合格的组件了。
我们可以根据此来抽离其中的样式,来实现一个仅需要交互逻辑的无头 Popover 组件;
2)分析一个无头组件库 Popover 组件需要什么?
其实,一个只有交互逻辑的无头 Popover 组件与传统组件的功能是大差不差的,一些基本使用等等都是必须要具备的;
只是在样式这块我们不需要下更多的功夫;
那么我们实现的功能点就主要包括以下:
- 基本使用
- 触发方式
- 位置显示
- 自定义内容
分析完之后,那我们开始实战 ~
二、实战之「基本使用」
因为我们在上一篇文章中已经搭建好了基本项目框架的架子,所以我们基于此来进行开发吧!
1)创建
bash
# 来到 package/vue 目录下
cd package/vue
# 新建 Popover 目录
mkdir src/Popover
2)实现基本 PopoverRoot
根组件
2.1. 基本配置
新建 PopoverRoot.vue
文件
ts
<script lang="ts">
import type { Ref } from 'vue'
import { createContext } from '@yi-ui/shared'
// 暴露的三个参数
export interface PopoverRootProps {
/**
* 打开状态,当它最初被渲染时。当您不需要控制其打开状态时使用。
*/
defaultOpen?: boolean
/**
* 控制当前组件的打开状态
*/
open?: boolean
/**
* popover的模式。当设置为true时,将禁用与外部元素的交互,并且只有弹出式的内容对屏幕阅读器可见。
*
* @defaultValue false
*/
modal?: boolean
}
// 暴露的事件
export type PopoverRootEmits = {
/**
* 打开状态事件回调
*/
'update:open': [value: boolean]
}
// 组件上下文传递时所需的参数
export interface PopoverRootContext {
triggerElement: Ref<HTMLElement | undefined>
contentId: string
open: Ref<boolean>
modal: Ref<boolean>
onOpenChange(value: boolean): void
onOpenToggle(): void
hasCustomAnchor: Ref<boolean>
}
// 传递组件上下文
export const [injectPopoverRootContext, providePopoverRootContext]
= createContext<PopoverRootContext>('PopoverRoot')
</script>
这段代码很简,我相信大家基本都看得懂,只是其中有一个 createContext函数
的出处需要解释一下;
createContext函数
是一个写在yi-ui/shared
子包中的公共方法;主要作用就是让整个组件上下文建立联系,方便整个父子组件的联调;
2.2 核心代码
老规矩,先看代码
ts
<script setup lang="ts">
import { ref, toRefs } from 'vue'
import { useVModel } from '@vueuse/core'
import { PopperRoot } from '../Popper'
// 设置组件的默认属性值
const props = withDefaults(defineProps<PopoverRootProps>(), {
defaultOpen: false,
open: undefined,
modal: false,
})
// 事件
const emit = defineEmits<PopoverRootEmits>()
const { modal } = toRefs(props)
// vueuse 的一个双向绑定 hook
// vue 3.4+ 可以使用 defineModel:https://cn.vuejs.org/api/sfc-script-setup.html#definemodel
const open = useVModel(props, 'open', emit, {
defaultValue: props.defaultOpen,
passive: (props.open === undefined) as false,
}) as Ref<boolean>
const triggerElement = ref<HTMLElement>()
const hasCustomAnchor = ref(false)
// 暴露给子组件的属性与方法
providePopoverRootContext({
contentId: '',
modal,
open,
onOpenChange: (value) => {
open.value = value
},
onOpenToggle: () => {
open.value = !open.value
},
triggerElement,
hasCustomAnchor,
})
</script>
<template>
<PopperRoot>
<slot />
</PopperRoot>
</template>
其实整个代码也是很简单的,大家几乎都能看的懂,语义化是很明显的;
其中可能有疑问的地方,应该就只有 PopperRoot
了;
PopperRoot
是一个基于@floating-ui/vue
库实现的基本组件;主要作用是为浮动元素提供锚点定位,并且将其位置定位在当前参考元素旁边的库;
几乎绝大多数的组件库都有使用到类似的库,这里就不展开讲了,大家明白就行了 ~
到这里这个最基本的 PopoverRoot
根组件就实现完毕了,是不是很简单呢,对的,就是这么简单,如果你还没看懂,建议多看几遍哦~
2.3 导出
新建 index.ts
ts
export {
default as PopoverRoot,
type PopoverRootProps,
type PopoverRootEmits,
} from './PopoverRoot.vue'
到这基本就完成了;
那么既然开发完毕了,那么咱们下一步就是开始去调试了
2.4 playground 调试
基本使用:
进入目录:
ts
cd playground/vue3
新增 Popover.vue 组件:
ts
<template>
<PopoverRoot :default-open="true">
<div>Your popover content here</div>
</PopoverRoot>
</template>
<script setup lang="ts">
import { PopoverRoot } from '@yi-ui/vue';
</script>
<style scoped>
</style>
渲染效果:

结合参数使用:
ts
<template>
<PopoverRoot v-model:open="toggleState" >
<!-- PopoverTrigger -->
<button @click="handleVisible">点击显示/隐藏</button>
<!-- PopoverContent -->
<ul v-if="toggleState">
<li>popover content: 11111</li>
<li>popover content: 22222</li>
<li>popover content: 33333</li>
<li>popover content: 44444</li>
<li>popover content: 55555</li>
</ul>
</PopoverRoot>
</template>
<script setup lang="ts">
import { PopoverRoot } from '@yi-ui/vue';
import { ref } from 'vue';
const toggleState = ref(false);
const handleVisible = () => {
toggleState.value = !toggleState.value;
};
</script>
我们能看到点击按钮会显示与隐藏:

其实代码中的实现与现在的 PopoverRoot 根组件
关联性不是很大;
所以接下来我们要做的就是把这个 PopoverTrigger 组件
和 PopoverContent 组件
嵌入到 PopoverRoot 根组件
中去,这样使用的时候才会事半功倍;
3)实现 PopoverTrigger 子组件
3.1 功能介绍
PopoverTrigger
子组件的功能其实很简单,那就是切换弹出 Popover
的一个触发器功能,默认情况下,它将 PopoverContent
子组件(也就是主要的内容部分)定位在当前触发器上。
3.2 核心代码
ts
<script lang="ts">
import type { PrimitiveProps } from '@/Primitive'
import { useForwardExpose } from '@yi-ui/shared'
export interface PopoverTriggerProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { onMounted } from 'vue'
import { injectPopoverRootContext } from './PopoverRoot.vue'
import { Primitive } from '@/Primitive'
import { PopperAnchor } from '@/Popper'
const props = withDefaults(defineProps<PopoverTriggerProps>(), {
as: 'button',
})
const rootContext = injectPopoverRootContext()
const { forwardRef, currentElement: triggerElement } = useForwardExpose()
onMounted(() => {
rootContext.triggerElement.value = triggerElement.value
})
</script>
<template>
<component
:is="rootContext.hasCustomAnchor.value ? Primitive : PopperAnchor"
as-child
>
<Primitive
:ref="forwardRef"
:type="as === 'button' ? 'button' : undefined"
aria-haspopup="dialog"
:aria-expanded="rootContext.open.value"
:aria-controls="rootContext.contentId"
:data-state="rootContext.open.value ? 'open' : 'closed'"
:as="as"
:as-child="props.asChild"
@click="rootContext.onOpenToggle"
>
<slot />
</Primitive>
</component>
</template>
其实它的代码就只需要这么多,我们简单解释一下其中的核心函数:
useForwardExpose:使其可以处理自己的 Ref;
Primitive:一个通用的基本元素组件;
PopperAnchor:瞄点定位元素;
3.3 导出
ts
export {
default as PopoverTrigger,
type PopoverTriggerProps,
} from './PopoverTrigger.vue'
3.4 playground 调试
编辑 Popover.vue 组件:
ts
<script setup lang="ts">
import { PopoverRoot, PopoverTrigger } from '@yi-ui/vue'
import { ref } from 'vue'
const toggleState = ref(false)
// function handleVisible() {
// toggleState.value = !toggleState.value
// }
</script>
<template>
<PopoverRoot v-model:open="toggleState">
<!-- PopoverTrigger -->
<!-- <button @click="handleVisible">点击显示/隐藏</button> -->
<PopoverTrigger>
点击显示/隐藏
</PopoverTrigger>
<!-- PopoverContent -->
<ul v-if="toggleState">
<li>popover content: 11111</li>
<li>popover content: 22222</li>
<li>popover content: 33333</li>
<li>popover content: 44444</li>
<li>popover content: 55555</li>
</ul>
</PopoverRoot>
</template>
根据与上面 PopoverRoot根组件
调试的对比,我们很明显的少了 handleVisible 函数
,这是因为我们已经在 PopoverTrigger
子组件的 onOpenToggle
中实现了。

好了,到这里我们已经有了触发器 PopoverTrigger 子组件
,接下来就是要实现 PopoverContent 子组件
主要内容了。
4)实现 PopoverContent 子组件
4.1 功能介绍
PopoverContent 子组件
主要就是在 PopoverTrigger 子组件
触发器在弹出窗口时的组件内容。
4.2 核心代码
老规矩,先看代码
ts
<script lang="ts">
export interface PopoverContentProps {
forceMount?: boolean
}
</script>
<script setup lang="ts">
import { injectPopoverRootContext } from './PopoverRoot.vue'
import { useForwardExpose } from '@yi-ui/shared'
import { Presence } from '@/Presence'
const rootContext = injectPopoverRootContext()
const { forwardRef } = useForwardExpose()
</script>
<template>
<Presence :ref="forwardRef" :present="rootContext.open.value">
<slot />
</Presence>
</template>
其实一看,PopoverContent 子组件
实现起来是不是贼简单;
是的,的确贼简单;
我相信上面的代码,大家也就对 Presence
会比较陌生,咱们简单介绍下:
主要作用就是有条件的显示和隐藏当前的组件,就类似于 vue 的 v-if 功能。
4.3 导出
新建 index.ts
ts
export {
default as PopoverContent,
type PopoverContentProps,
} from './PopoverContent.vue'
4.4 playground 调试
编辑 Popover.vue 组件:
ts
<script setup lang="ts">
import { PopoverRoot, PopoverTrigger, PopoverContent } from '@yi-ui/vue'
</script>
<template>
<PopoverRoot>
<PopoverTrigger>
点击显示/隐藏
</PopoverTrigger>
<PopoverContent>
<ul>
<li>popover content: 11111</li>
<li>popover content: 22222</li>
<li>popover content: 33333</li>
<li>popover content: 44444</li>
<li>popover content: 55555</li>
</ul>
</PopoverContent>
</PopoverRoot>
</template>
根据与上面 PopoverRoot根组件
调试的对比,我们又少了 toggleState
数据,这是因为我们已经用 rootContext.open
来进行关联了。

到这里我们一个最最最基本的 Popover 组件
就已经实现了,但是我们从上面分析得来的,一个完整的 Popover 组件
离不开四个大的功能:
- 基本使用
- 触发方式
- 位置显示
- 自定义内容
显然我们已经实现了基本使用 和触发方式 功能,那么接下就是位置显示 和自定义内容了
三、实战之「位置显示」
1)思考&分析
根据我们上面实现的基本使用,咱们思考一下,位置显示我们应该在哪个组件中实现呢?
很明显还是在 PopoverContent 子组件
中,毕竟咱们现实主要内容就是它;
在上面,咱们讲过了 @floating-ui/vue
库是干嘛的了,之所以用到它,还有一个就是,它能自定义位置;
接下来咱们就基于 @floating-ui/vue
库来实现吧 ~
2)@floating-ui/vue
的简单实用
2.1 安装
ts
pnpm install @floating-ui/vue
2.2 基本使用
ts
<template>
<button ref="reference">参考元素</button>
<ul ref="floating" :style="floatingStyles">
<li>111浮动元素内容</li>
<li>222浮动元素内容</li>
<li>333浮动元素内容</li>
<li>444浮动元素内容</li>
<li>555浮动元素内容</li>
</ul>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { useFloating } from '@floating-ui/vue';
// 用于定位的参考(或锚点)元素
const reference = ref(null);
// 用于浮动的元素
const floating = ref(null);
// 用于控制浮动元素的样式
const { floatingStyles } = useFloating(reference, floating);
</script>
渲染效果如下:

默认情况下,
浮动元素内容
将定位在参考元素
的底部中心,所以看到效果是这样的。 那么接下来,就是要更改位置的进阶使用了。
2.3 进阶使用
ts
<template>
<div class="content">
<div>
<button ref="reference">参考元素向下</button>
<ul ref="floating" :style="floatingStyles">
<li>浮动元素内容</li>
<li>浮动元素内容</li>
<li>浮动元素内容</li>
<li>浮动元素内容</li>
<li>浮动元素内容</li>
</ul>
</div>
<div>
<button ref="referenceTop">参考元素向上</button>
<ul ref="floatingTop" :style="floatingTopStyles">
<li>浮动元素内容</li>
<li>浮动元素内容</li>
<li>浮动元素内容</li>
<li>浮动元素内容</li>
<li>浮动元素内容</li>
</ul>
</div>
<div>
<button ref="referenceLeft">参考元素向左</button>
<ul ref="floatingLeft" :style="floatingLeftStyles">
<li>浮动元素内容</li>
<li>浮动元素内容</li>
<li>浮动元素内容</li>
<li>浮动元素内容</li>
<li>浮动元素内容</li>
</ul>
</div>
<div>
<button ref="referenceRight">参考元素向右</button>
<ul ref="floatingRight" :style="floatingRightStyles">
<li>浮动元素内容</li>
<li>浮动元素内容</li>
<li>浮动元素内容</li>
<li>浮动元素内容</li>
<li>浮动元素内容</li>
</ul>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { useFloating, shift, flip, offset } from '@floating-ui/vue';
// 用于定位的参考(或锚点)元素
const reference = ref(null);
const referenceTop = ref(null);
const referenceLeft = ref(null);
const referenceRight = ref(null);
// 用于浮动的元素
const floating = ref(null);
const floatingTop = ref(null);
const floatingLeft = ref(null);
const floatingRight = ref(null);
// 中间件
const middleware = [shift(), flip(), offset(10)];
// 用于控制浮动元素的样式
const { floatingStyles } = useFloating(reference, floating, {
// 指定初始化浮动位置
placement: "bottom",
middleware,
});
const { floatingStyles: floatingTopStyles } = useFloating(referenceTop, floatingTop, {
// 指定初始化浮动位置
placement: "top",
middleware,
});
const { floatingStyles: floatingLeftStyles } = useFloating(referenceLeft, floatingLeft, {
// 指定初始化浮动位置
placement: "left",
middleware,
});
const { floatingStyles: floatingRightStyles } = useFloating(referenceRight, floatingRight, {
// 指定初始化浮动位置
placement: "right",
middleware,
});
</script>
<style scoped>
.content {
display: flex;
width: 100vw;
height: 1000px;
padding-top: 300px;
}
.content > div {
width: 20%;
height: 500px;
}
ul, li {
list-style: none;
margin: 0;
padding: 0;
}
ul {
border: 1px solid #ccc;
text-align: center;
}
</style>
咱们直接看渲染结果:

到这里,咱们已经熟悉了@floating-ui/vue 的基本使用了,当然还有超多复杂的API 使用,这里就不赘述了,感兴趣的同学可以自行了解官方文档:传送门。
那么接下来就是如何代入到我们的无头组件库中去了。
3)封装 Popper 共用组件
如果直接按照上面的使用,我想这不是一个合格的组件库,因为咱们考量的还有很多,比如代码解耦、代码复用等等。
所以咱们需要根据上面来简单封装一个 Popper 共用组件
。
该组件包括:
PopperRoot
: 根组件PopperContent
:主要内容组件PopperArrow
:箭头PopperAnchor
:触发器
其中核心的 PopperContent
代码(抽取其中部分,主要为了实现位置显示):
ts
<script lang="ts">
import type {
Placement,
} from '@floating-ui/vue'
import { createContext, useForwardExpose } from '@yi-ui/shared'
import type {
Align,
Side,
} from './utils'
export const PopperContentPropsDefaultValue = {
side: 'bottom' as Side,
sideOffset: 0,
align: 'center' as Align,
alignOffset: 0,
}
export interface PopperContentProps {
/**
* 位置
*
* @defaultValue "top"
*/
side?: Side
/**
* 距离触发器的距离
*
* @defaultValue 0
*/
sideOffset?: number
/**
* 对齐方式相对于触发器
*
* @defaultValue "center"
*/
align?: Align
/**
* 偏移量
*
* @defaultValue 0
*/
alignOffset?: number
}
export interface PopperContentContext {}
export const [injectPopperContentContext, providePopperContentContext]
= createContext<PopperContentContext>('PopperContent')
</script>
<script setup lang="ts">
import { computed, ref } from 'vue'
import {
useFloating,
} from '@floating-ui/vue'
import { injectPopperRootContext } from './PopperRoot.vue'
import {
Primitive,
} from '../Primitive'
const props = withDefaults(defineProps<PopperContentProps>(), {
...PopperContentPropsDefaultValue,
})
const rootContext = injectPopperRootContext()
const { forwardRef } = useForwardExpose()
const floatingRef = ref<HTMLElement>()
const desiredPlacement = computed(
() =>
(props.side
+ (props.align !== 'center' ? `-${props.align}` : '')) as Placement,
)
const { floatingStyles } = useFloating(
rootContext.anchor,
floatingRef,
{
strategy: 'fixed',
placement: desiredPlacement,
},
)
</script>
<template>
<div
ref="floatingRef"
:style="{
...floatingStyles,
}"
>
<Primitive
:ref="forwardRef"
>
<slot />
</Primitive>
</div>
</template>
我相信通过上面的 @floating-ui/vue
的简单实用,大家都能看的懂其中的代码;
还有更多的细节和相关组件这里就不做赘述介绍了,当然一个完整的 Popper 共用组件
远不止于此,感兴趣的同学可以看看源代码:传送门
4)完善 PopoverContent 子组件
根据上面的已经实现了的 PopoverContent 子组件
来完善
ts
<script lang="ts">
import type { PopperContentProps } from '@/Popper'
import type { PrimitiveProps } from '@/Primitive'
export interface PopoverContentProps extends PopperContentProps, PrimitiveProps {
forceMount?: boolean
}
</script>
<script setup lang="ts">
import { injectPopoverRootContext } from './PopoverRoot.vue'
import { useForwardExpose, useForwardPropsEmits } from '@yi-ui/shared'
import { PopperContent } from '@/Popper'
import { Presence } from '@/Presence'
const props = defineProps<PopperContentProps>()
const emits = defineEmits()
const rootContext = injectPopoverRootContext()
const forwarded = useForwardPropsEmits(props, emits)
const { forwardRef } = useForwardExpose()
</script>
<template>
<Presence :ref="forwardRef" :present="rootContext.open.value">
<PopperContent
v-bind="forwarded"
:ref="forwardRef"
>
<slot />
</PopperContent>
</Presence>
</template>
是的,这样就实现了,基本的位置了
5)playground 调式
5.1 vue 项目中调试
ts
<script setup lang="ts">
import { PopoverContent, PopoverRoot, PopoverTrigger } from '@yi-ui/vue'
</script>
<template>
<PopoverRoot>
<PopoverTrigger>
点击显示/隐藏左边的内容
</PopoverTrigger>
<PopoverContent side="left">
<ul>
<li>popover content: 11111</li>
<li>popover content: 22222</li>
<li>popover content: 33333</li>
<li>popover content: 44444</li>
<li>popover content: 55555</li>
</ul>
</PopoverContent>
</PopoverRoot>
<PopoverRoot>
<PopoverTrigger>
点击显示/隐藏右边的内容
</PopoverTrigger>
<PopoverContent side="right">
<ul>
<li>popover content: 11111</li>
<li>popover content: 22222</li>
<li>popover content: 33333</li>
<li>popover content: 44444</li>
<li>popover content: 55555</li>
</ul>
</PopoverContent>
</PopoverRoot>
</template>
5.2 渲染效果

四、实战之「自定义内容」
自定义内容,其实在上面就已经实现了,只是没有具体的 demo 展示,咱们根据上面实现了的来自定义一下内容
1)demo 展示:
ts
<script setup lang="ts">
import { PopoverContent, PopoverRoot, PopoverTrigger } from '@yi-ui/vue'
</script>
<template>
<PopoverRoot>
<PopoverTrigger>
切换县市
</PopoverTrigger>
<PopoverContent>
<ul>
<li>北京市</li>
<li>上海市</li>
<li>广州市</li>
<li>深圳市</li>
</ul>
</PopoverContent>
</PopoverRoot>
</template>
<style scoped>
ul, li {
list-style: none;
margin: 0;
padding: 0;
}
ul {
border: 1px solid #ccc;
text-align: center;
width: 100px;
background: #ffffff;
border-radius: 4px;
box-shadow: 0px 3px 16px 2px rgba(0,0,0,0.04), 0px 7px 14px 1px rgba(0,0,0,0.08), 0px 5px 5px -3px rgba(0,0,0,0.08);
}
ul li {
height: 40px;
line-height: 40px;
font-size: 14px;
cursor: pointer;
color: #333333;
}
li:hover {
background: #f2f8ff;
color: #006aff;
}
</style>
2)渲染效果:

五、小结
如果你有实现过一个传统组件库,我相信这个无头组件库的实现方式肯定会让你眼前一亮;
因为它的确少了很多东西,就单独拿 style 样式来说,你不仅要把一些样式抽离出来,还要写成可共用的样式,怎么恶心怎么来;
不说了,说多了都是泪~
当然我们一个完整的 Popover 无头组件
绝不仅限于此,为了更好的突出其功能的自定义,还有许多组件与功能封装,这里就不是一两篇文章能讲解的清楚的,大家只要知道其基本实现方式与概念即可。
原本想在这篇文章中把文档和单元测试都讲解了的,但是写着发现越写越多,内容越来越多,毕竟 4 千多字已经够看了。
还未完善的有:
- 文档撰写
- 单元测试
- 支持 Nuxt 调试
- 打包构建
所以做了一个功能分割取舍,这篇以核心代码为主;
下一篇再讲解文档编写和单元测试的讲解,敬请期待~
总结
当前主要实现了一个最基本的 Popover 无头组件
,包括其中的一些核心知识点,我相信无论你是要做一个无头组件库
还是传统组件库
这都会对受益匪浅的。
Headless UI 往期相关文章:
- 在 2023 年屌爆了一整年的 shadcn/ui 用的 Headless UI 到底是何方神圣?
- 实战开始 🚀 在 React 和 Vue3 中使用 Headless UI 无头组件库
- 无头组件库既然这么火 🔥 那么我们自己手动实现一个来完成所谓的 KPI 吧

如果想跟我一起讨论技术吹水摸鱼 , 欢迎加入前端学习群聊
如果扫码人数满了,可以扫码添加个人 vx 拉你:JeddyGong
感谢大家的支持,码字实在不易,其中如若有错误,望指出,如果您觉得文章不错,记得 点赞关注加收藏
哦 ~
关注我,带您一起搞前端 ~