缘起
转眼半年没写文章了,上个月离职还出去玩了一遭,都不会写代码了😂。
最近入职新公司,需要写一个像下面这样子的组件👇🏻:
这个组件主要就是由一堆 tag 和一个 input 组成,并且也有现成的库,只不过我们需要一个额外的功能,就是可以通过鼠标点击在 tag 之间插入 tag,但是目前找到的组件大多都是直接在末尾添加 tag,顶多可以按下左右键移动 input 的位置,但是不支持鼠标直接点击到 tag 之间,快速定位到当前位置插入,So,就自己动手,顺便找下手感吧⌨️。
明确需求
- 回车输入 tag
- 按后退键可删除 tag
- 按左右键可以移动当前 input 的位置
- 点击两个 tag 之间,需要将 input 移动到该位置
- 给予联想词提示,上下键可进行选择,回车可直接输入该词
- 希望 input 的宽度随输入字符变长
开始实现
🥳 看了下大部分该组件的实现方式后,我们可以先写出大致结构,就是一堆 tag 外加一个 input,然后点击整个组件的时候聚焦 input 即可,就像下面这样(样式方面的就不贴上来了):
<template>
<div class="muli-tags" @click="focus">
<span v-for='(tag, index) in tags' :key='index'>{{tag}}</span>
<input ref='input' v-model='current'>
</div>
</template>
<script>
export default {
name: 'TagsInput',
props: {
value: {
type: Array,
require: true,
default: () => ['111', '222', '333'],
},
},
data() {
return {
current: '',
};
},
computed: {
tags() {
return this.value.slice();
},
},
methods: {
focus() {
this.$refs.input.focus();
}
}
}
</script>
回车添加
现在我们就开始实现第一个功能,回车输入。这个就是监听 input 回车的时候(用 vue 的修饰符即可),取出当前 input 的值,往数组里面追加值,然后清空输入框。当然,考虑到如果写着写着✍🏻可能会突然失去焦点,所以我们把 blur 事件当做添加事件处理即可。
// <input v-model='current' @keyup.enter="add" @blur="add">
add() {
const val = this.current;
if (!val) return; // 空值就不添加了
this.tags.push(val); // 把 input 添加到数组中
this.current = ''; // 清空 input
this.emitParent();
},
emitParent() {
this.$emit('input', this.tags);
},
后退键删除
接下来是删除功能,这个要注意以下两点:
- 如果当前 input 有值,我们是不需要进行处理的
- 因为目前我们的 input 是在最后一个位置,所以先简单考虑直接删除数组最后一位
// <input v-model='current' @keydown.delete="delete" @keyup.enter="add" @blur="add">
delete() {
if (this.current.length) return; // 当文本框内没有值,按回退键才删除
this.tags.pop();
this.emitParent();
},
左右键移动 input 位置
这个其实就是交换一下 input 和它左右两边 tag 的位置即可,比如我们按下左键,就是找到 input 前面的那个 tag,然后将 input 插入到这个 tag 之前,不过也是要注意,如果当前 input 中有值,也是不需要处理的。
// <input v-model='current' @keydown.left="moveLeft" @keyup.enter="add" @keydown.delete="delete" @blur="add">
moveLeft() {
if (this.current.length) return;
const input = this.$refs.input; // 获取当前 input 元素
const preTag = input.previousElementSibling; // 获取当前 input 前面的那个 tag 标签
if (!preTag) return; // 如果删到第一个就不作任何操作
preTag.before(input); // 这个和 insertBefore 是一个意思
this.focus(); // 重新聚焦
}
注意:在元素已存在的情况下(比如本例中的 input),insertBefore 和 appendChild 都是移动的意思。
移动到当前位置
这个问题应该是最麻烦的了🤔,我们怎么才能知道他点的是哪两个 tag 之间呢,想了想好像只能获取当前鼠标点击位置,然后遍历找到一个离点击位置较近的一个 tag(不是最近哦),再把 input 移动过来,好像有点麻烦。所以先想了个投机取巧的方式,就是我们不要点击两个 tag 之间,而是点击 tag,当我们点击 tag 的时候我们就把 input 移到这个 tag 后面来,就像下面这样:
// <span v-for='(tag, index) in tags' :key='index' @click="moveInputToHere">{{tag}}</span>
moveInputToHere(e) {
const curEle = e.currentTarget; // 当前点击的 tag
const nextEle = curEle.nextElementSibling; // 当前 tag 的下一个 tag,因为要用 insertBefore 这个方法,插入只能在某个元素之前插入
const parentNode = curEle.parentNode;
parentNode.insertBefore(this.$refs.input, nextEle);
},
但是很显然这样不行:
- 体验不够好,最好肯定是点击两个 tag 之间啦
- 如果我要在最开始的位置插入怎么办,好像永远移不过去😂,只能用左键移了 所以关于点击中间的问题,我们再换个思路,就是在 tag 里面的左右两边各加上一个 gap(注意是两边,并且是包含在一个 tag 里面的),就像下面这样的结构:
<div class="tag">
<i class="gap" @click.prevent.stop="moveInputToHereLeft"></i>
<span class="label">{{ tag.val }}</span>
<i class="gap" @click.prevent.stop="moveInputToHereRight"></i>
</div>
从上面的结构中可以看出中间的 label 才是真正的 tag,两边的 gap 只是辅助我们定位是哪个 tag 而已,当然要做一些样式改动,不过原理和点击当前这个 tag 是一样的,但是它在视觉上给你呈现的效果就是点在空白处,挺好的障眼法(前端一大特色👺),来看下图例:
可惜这样还是有问题的,就是如果我点击空白处咋整,比如每一行的最右边时常因为放不满而换行,这可咋整,我们更希望的是点击到空白处,也能将输入框移动到这个位置,所以。。。往下看吧。。。
重新梳理一下
想了半天我还是决定放弃这个投机取巧的办法,老老实实用坐标找到相对近的一个 tag,然后把 input 移过去。那我们如何找到离鼠标点击最近的一个 tag 呢,我是这样想的哈:
上图中我们找到的 tag 应该是 44444,当然我们得注意到特殊情况,比如我点击了第一个标签前面的空白,我们在点击事件的左边就找不到要插入的标签了,这时候就需要从右边开始遍历了,这一段描述的有点晦涩,大家自己体会一下😂,或者看看下面代码(注释满满),当然也可以跳过:
// <div class="muli-tags" @click="handleClick">...</div>
export default {
methods: {
handleClick(e) {
e.preventDefault();
const rs = this.calcLatestTag(e);
const ele = rs && rs.ele;
if (ele) { // 找到了就移动 input,没有就聚焦即可
const input = this.$refs.input;
const parentNode = ele.parentNode;
if (rs.isRight) { // 将 input 移到标签右边
const nextEle = ele.nextSibling;
parentNode.insertBefore(input, nextEle);
} else { // 将 input 移到标签左边
parentNode.insertBefore(input, ele);
}
}
this.focus();
},
calcLatestTag(e) {
if (this.tags.length <= 1) return;
if (e.target.tagName === 'INPUT' || e.target.tagName === 'SPAN') return;
const { clientX, clientY } = e;
const tagArr = this.$refs.tag;
const inlineTagArr = []; // 把同一行的 tag 的放进来
let ele;
let isRight = true; // 如果找到,就把 input 移到这个 tag 的右边
tagArr.forEach(tag => {
const { top, left, height } = tag.getBoundingClientRect();
if (top <= clientY && top + height >= clientY) { // 如果在同一行,如果超出高度了可以提前退出循环,这里就简单写
inlineTagArr.push(tag);
if (left <= clientX) ele = tag; // 找到点击事件左手边最近的一个 tag
}
});
if (!ele) { // 左手边没有找到最近的一个 tag,说明点击的是最左边的空白处,于是我们尝试从 inlineTagArr 找到离点击事件右手边最近的一个 tag
if (inlineTagArr.length) {
ele = inlineTagArr[0]; // 其实可以不用遍历,因为一定是同一行的第一个元素
isRight = false; // 如果找到,就把 input 移到这个 tag 的左边
}
}
return { isRight, ele };
}
}
}
下拉选项
简单说说下拉框的思路吧,首先我们需要通过 props 传进来一些需要快速匹配的候选词,然后通过当前 input 的值实时匹配出当前的候选项(就是 computed),再将候选词绝对定位在整个外框的底部即可,此外还要对上下键事件进行处理以支持选择高亮。
最后在回车的时候,先判断当前是否有选中的候选词,有就添加候选词,没有就添加 input 的值。当然你也可以限制只输入候选词,或者禁止输入某些词。大致代码如下:
<template>
<div class="muli-tags" @click="handleClick">
...
<div class="tip" @mouseout="selectedItem = null">
<div v-for="(item, i) in relativeTagOpts" :key="i" class="tip-item" :class="isSelected(i) ? 'tip-selected' : ''" @mouseover="selectedItem = i">
<span class="tip-text">{{ item }}</span>
<span v-if="isSelected(i)" class="enter">回车选中</span>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
relativeOpts: { // 候选词
type: Array,
default: () => ['110', '114', '119', '120'],
},
},
data() {
return {
selectedItem: null // 用一个变量记录当前候选词下标
};
},
computed: {
relativeTagOpts() {
const curInputVal = this.current.trim();
if (!curInputVal) return [];
return this.relativeOpts.filter(opt => opt.includes(curInputVal));
}
},
methods: {
isSelected(index) {
return this.selectedItem === index;
},
getSelectedIndex(method) { // 上下键高亮候选词
const items = this.relativeTagOpts;
const selectedItem = this.selectedItem;
const lastItem = items.length - 1;
if (items.length === 0) return null;
if (selectedItem === null) return 0;
if (method === 'before' && selectedItem === 0) return lastItem;
else if (method === 'after' && selectedItem === lastItem) return 0;
else return method === 'after' ? selectedItem + 1 : selectedItem - 1;
},
selectItem(e, method) {
e.preventDefault();
this.selectedItem = this.getSelectedIndex(method);
},
add() {
...
const trueVal = this.relativeTagOpts[this.selectedItem] || this.current; // 先判断候选词汇再添加
...
}
}
</script>
input 宽度可变
现在我们希望 input 不要一开始就那么宽(input 是有默认宽度的),想让 input 的宽度可变,怎么操作呢,也很简单,就是利用 input 的 size 属性即可,也不用设置 css 宽度属性,然后我们去掉 input 的外边框看下,效果极好:
<input v-model="current" :size="current.length || 1" ...>
小问题
至此,我们的一个小组件就写好了,但问题还是有的🤯。
- 一个首要的问题就是我们在回车添加 tag 的时候,现在是直接 push 到最后一个,这显然是不对的,我们得先找到要插入的地方才行,包括删除也是一样的道理,不能直接删最后一个;
- 另一个问题就是上面的代码写起来应该是有一丢丢变扭的,因为我们在手动操作 dom,insertBefore 和 previousElementSibling 满天飞,写着写着突然就变成 jQuery 的思想了,所以我们更应该采用的是数据驱动视图的思想,什么意思呢,就是把代码改成下面这种结构:
<template>
<div class="muli-tags" @click="handleClick">
<template v-for="tag in tags">
<!-- input 不再单拎出来,而是和标签一起放进 tags 数组中 -->
<input v-if="tag.isInput" :key="tag.id" class="input">
<span v-else class="tag" :key="tag.id"></span>
</template>
<div class="tip"></div>
</div>
</template>
像上面那样写的话,当我们要交换位置啥的,只要交换 tags 数组的下标就行了,而不是去操作 dom,数据变了,视图自然就变了,这个感觉很微妙,哈哈😄。
另外,大家也可以多多拓展思路,比如上面的下拉选项提示词其实可以用在很多地方,比如微博的@人和飞书文档的@人的功能,或者用在代码提示等等之类的。
结语
确实是手生了,要继续加油呀😬。
有感而发:四月面试的时候收获颇丰,尤其是和互娱的花叔聊天,很有启发,特此感谢。
温馨提示:最近深圳疫情又严重了,大家记得勤洗手、戴口罩😷、多通风哈。
ps: 代码在这里👉🏻tags-input 代码地址