前言: 近期在项目中遇到了一个设计需求,在 UI 给我提供的原图中有一个和 element UI 选择器功能基本一致的样式,但是由于我们是有自己的主体颜色和一些细节上的样式设计的,无法直接复用 element 组件库。所以需要自己动手实现一个下拉选择器,最开始以为很复杂,但其实在查阅了相关知识后,实现起来也谈不上很难,并且关键是自己又探索了一片空白知识区域,很开心,遂特来分享一下自己的实现思路🎁。(本文并不是解读 element 的源码,而是仿照它的功能来自己完成一个基础组件。)
(tips:本文不区分框架,无论你是 react 开发还是 vue 开发,思路是一致的)
一. 设计需求
-
由于我们项目的场景比较单一,所以 UI 让我直接参考 element 的基础多选这个效果去实现。
你可以点击链接先去官网体验一下最终效果,接下来我会一步一步讲解如何模仿这个功能和样式去实现一个精简版的下拉选择器。
🔥element Plus 基础多选 -
样式方面,在这里我使用的是
UnoCSS
,将样式內联在了标签里,如果你还不了解这种写法,你可以点击下方的文章学习。不过即使你之前从未了解过UnoCSS
,也不会影响你下面的阅读,因为样式不是本文的重点,并不影响整体阅读。
🫱手把手教你如何创建一个代码仓库
二. 实现 selector 容器框
-
首先选择器肯定有一个最基础的容器框,这个容器框最开始的时候我选择使用原生的input 框去实现,但是其实这个想法是错误的,或者说是不容易达成的,因为 input 里写的内容很难去高度自定义化。
-
那么我们就换一种思路,假设这不是一个选择器,我们就仅仅把它看作一个普通的容器,一个普通的 div 元素,总应该会实现了吧。由于是讲解思路,我们就简单的模仿一下 element 的大致样式,就不细扣样式相关的问题了。代码如下
<script setup lang="ts"></script> <template> <div class="w-100vw h-100vh text-14px text-black flex justify-center items-center"> <div class="w-300px h-40px rounded-4px border-1px border-solid border-#2ec1cc"></div> </div> </template>
很简单的一个样式,实现效果如下:
-
这里首先需要解释一下为什么最开始我会首先先到用 input 框去实现?因为选择框有一个最为基础的聚焦功能,所以我最真实的想法并不是想要 input 框这个标签本身,而是它身上自带的聚焦功能。可以测试一下,我们先用原生的 input 元素来测试一下 onFocus 事件。
-
然后再来测试 div 标签。
可以看到,虽然我已经在疯狂点击 div 元素了,但是它依旧高冷,不给我们一点反馈。很遗憾的告诉你,不是你电脑卡了,而是 div 本身是没有聚焦属性的,但是我们有一个重要属性,可以让 div 实现聚焦的功能。
-
本文的第一个重点知识:tabIndex 属性。
不要被名字吓到了,看看你键盘上常用来切换窗口的那个键,是不是叫做 tab?
-
没错,tabIndex 中的 tab 就是对应你键盘上的那个 tab 键。index 的含义代表着你可以在 tab 键切换的时候设置聚焦的优先级。比如页面有三个 input。
那么代表着按下键盘的 tab 的时候,会优先选择 tabIndex较小那个值对应的元素。
也对应了 MDN 的解释
-
接下来只需要给 div 设置
tabIndex
即可。
我们测试一下效果:
-
那么接下来的聚焦时改变 div 的 border 颜色还不是易如反掌?
-
那聚焦有了,对应的就有失焦效果,对应的事件是 onBlur 事件。和聚焦事件一样,不再过多赘述。
效果如下:
三. 实现 selectorItem 容器框
-
其实非常简单,使用上面的 isFocusing 变量,可以增加一个 div 通过设置 v-show 值来动态切换它的显示即可。这里需要在最外层增加一个 div 设置 relative 用来定位 selectorItem 容器框。
效果如下:
-
接下来做一个假数据来填充 item 容器框。
-
很简单的 v-for,节省时间,我们跳过样式的书写过程,直接看效果。
四. 实现点击选择效果
-
这里我们创建一个变量,用来容纳选择的元素。
-
然后在容器 div 里去 v-for 这个数组。
-
这里我们测试一下效果。唉🤔?我们选择的数组好像没渲染啊? v-for 失效了吗?
-
如果你和我一起书写到了这里,你可能会十分困惑地检查自己的代码到底哪里发生了问题。其实造成这个原因非常出乎你的意料。其实是由于失焦事件的触发早一步我们的点击事件造成的,让我们梳理一下过程。
-
- 当我们聚焦以后, selectorItem 框出现。
- 当我们聚焦以后, selectorItem 框出现。
-
- 我们点击 item,按照理想情况下,它会触发我们绑定的click事件。
- 我们点击 item,按照理想情况下,它会触发我们绑定的click事件。
-
- 关键就发生了在这里一步的中间过程,注意我们之前的失焦事件,当我们点击 item 的时候,导致容器 div 的失焦事件先一步触发。
- 关键就发生了在这里一步的中间过程,注意我们之前的失焦事件,当我们点击 item 的时候,导致容器 div 的失焦事件先一步触发。
-
4 . 紧接着我们的 v-show 使下面的 item 框消失,故而造成 click 事件来不及触发。
-
5.证据就是我们明明在点击的时候添加了 console 语句,但是控制台却没有正确的输出
只散落着之前的失焦&聚焦事件触发的打印。
-
-
明白了问题的所在,就知道该如何正确下手去解决这个问题。既然 onBlur 会先一步触发,那我们就先把 div 身上的失焦事件取消掉,只留下聚焦事件。
让我们看一下效果:
-
让我们快速调整一下选择后样式,接下来的又该面临新的问题,现在我们由于取消了唯一的失焦事件,那么我们该如何选择完成后取消掉这个框框呢?
-
我们观察到 element 组件的做法是点击屏幕空白处就可以取消显示,那么我们就可以模仿这个做法,直接把 onBlur 事件要做的事情直接给 document 加上。
-
到这一步你会观察到一个奇怪的现象。点击后压根什么都不显示了。
-
造成这个结果的原因也很简单,因为事件的冒泡机制,你点击这个 div 以后,由于你给 document 绑定了 onBlur 事件,所以在短时间内
isFocusing
马上就由true
变为了false
,所以我们的页面就会看似没有任何反应。 -
要阻止这个情况的发生,就要阻止冒泡事件的发生,在 vue 中,我们只需要给容器绑定一个空的 click 事件,设置一个 stop 修饰符即可。
效果如下:
五. 添加取消按钮
-
至此我们的选择器还差一个关键的功能就完成了,可以看到 element 是可以在选择完成的时候,取消掉某一项的选择。
-
这里样式我就不完全模仿 element 组件了。我们直接实现需求即可,功能实现起来也非常简单,就是点击叉叉的时候,在已经选择的数组中找到对应的 index,然后调用 splice 方法即可,比较基础,这里不再过度赘述。
效果如下:
-
小插曲,这里选择同样需要判定一下是否已经存在,不可以重复选择。
-
至此,虽然无法做到完全媲美 element UI,但是仅仅不到 100 行代码就实现的这个 selector 组件,它的所有元素和样式都可以根据需求高度自定义,用来满足我们项目的需求已经绰绰有余了,
六. 源码
<script setup lang="ts">
import { ref, onMounted } from "vue";
const mock = [
{ id: 1, name: "韩振方" },
{ id: 2, name: " vue " },
{ id: 3, name: "react" },
{ id: 4, name: "前端" },
{ id: 5, name: "掘金" },
{ id: 6, name: "CSDN" },
{ id: 7, name: "知乎" },
];
interface MockType {
id: number;
name: string;
}
const isFocusing = ref<boolean>(false);
const selectedItem = ref<MockType[]>([]);
//tips: 点击元素,push 进数组即可
function clickItem(label: MockType) {
const index = selectedItem.value.findIndex((item) => label.id === item.id);
if (index === -1) selectedItem.value.push(label);
}
function focusEvent(e: FocusEvent) {
console.log("聚焦");
isFocusing.value = true;
}
function blurEvent() {
console.log("失焦");
isFocusing.value = false;
}
function unSelectItem(label: MockType) {
const index = selectedItem.value.findIndex((item) => label.id === item.id);
if (index !== -1) selectedItem.value.splice(index, 1);
}
onMounted(() => {
document.addEventListener("click", () => {
isFocusing.value = false;
});
});
</script>
<template>
<div class="w-100vw h-100vh text-14px text-black">
<div class="relative w-full mt-100px flex justify-center items-center">
<div
@click.stop=""
@focus="focusEvent"
tabindex="1"
:class="isFocusing ? 'border-black' : 'border-#2ec1cc'"
class="w-300px h-40px rounded-4px border-1px border-solid flex items-center flex-nowrap"
>
<div
v-for="item in selectedItem"
class="inline-block px-10px leading-28px bg-#F7F9FA rounded-4px flex gap-8px items-center shrink-0"
>
<span class="text-#202020 text-14px">
{{ item.name }}
</span>
<button
@click.stop="unSelectItem(item)"
class="cursor-pointer border-none flex items-center justify-center"
>
<span>x</span>
</button>
</div>
</div>
<div
v-show="isFocusing"
class="w-300px h-274px absolute overflow-y-auto bg-white z-999 rounded-6px border-1px border-#e4e6e5 flex flex-col py-10px"
:style="{
top: `60px`,
boxShadow: '0px 0px 10px rgba(0,0,0,0.1)',
}"
>
<div
v-for="item in mock"
@click.stop="clickItem(item)"
class="w-full leading-37px cursor-pointer hover:bg-#f6f7fa px-20px shrink-0 flex justify-between items-center"
>
<span class="text-14px">
{{ item.name }}
</span>
</div>
</div>
</div>
</div>
</template>
七. 总结
其实在日常开发中,组件库的功能虽然全,但是有些情况下我们仅仅只是用到了里面很少一部分的功能,这时候全部引进的话又显得很没有必要,这时候通过模仿组件库的功能来实现一个轻量级的组件还是非常有必要的。通过这着组件的设计和实现,又掌握了很多之前没接触过的知识。比如 tabIndex 、失焦和聚集事件的优先级🎁。