写一个 tags-input 组件?

4,063 阅读3分钟

缘起

转眼半年没写文章了,上个月离职还出去玩了一遭,都不会写代码了😂。
最近入职新公司,需要写一个像下面这样子的组件👇🏻: QQ20210619-183828.gif 这个组件主要就是由一堆 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 是一样的,但是它在视觉上给你呈现的效果就是点在空白处,挺好的障眼法(前端一大特色👺),来看下图例: QQ20210619-174038.gif 可惜这样还是有问题的,就是如果我点击空白处咋整,比如每一行的最右边时常因为放不满而换行,这可咋整,我们更希望的是点击到空白处,也能将输入框移动到这个位置,所以。。。往下看吧。。。

重新梳理一下

想了半天我还是决定放弃这个投机取巧的办法,老老实实用坐标找到相对近的一个 tag,然后把 input 移过去。那我们如何找到离鼠标点击最近的一个 tag 呢,我是这样想的哈: image.png 上图中我们找到的 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" ...>

QQ20210619-182959.gif

小问题

至此,我们的一个小组件就写好了,但问题还是有的🤯。

  • 一个首要的问题就是我们在回车添加 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 代码地址