实现仿element远程搜索下拉框实现方式与遇到的坑

背景

由于项目中须要写一个下拉组件相似于element-ui的远程搜索下拉框,可是element的组件不支持下拉框和已选中人的自定义,因此本身模拟element的方式实现了此组件(vue+ts)。html

my-demo: vue

my-demo
element-ui:

element-ui
组件的代码请跳转另外一篇文章( demo

嵌套结构的设计

首先看到这个结构(选中的结果显示在输入框内,后面可继续输入),想到两种实现方式:
git

  1. 外层是一个input输入框,选中后将结果渲染在div中定位在输入框内,光标定位在div的后面
    此种方案有两个很大问题:一、选中结果的长度不肯定因此div的长度不肯定,光标定位的位置就很难肯定; 二、选中多个结果后会出现换行,判断几个div会占满一行会特别难,而且换行后输入框要变高,而且光标定位在最后,很难实现。
    显然此种方案不可行
  2. 外层是一个假的输入框,内层嵌套一个真正的输入框,而后将内部输入框的外边框隐藏掉,用外部假的输入框的样式模拟一个输入框的各类状态,element-ui的实现方式也为此种状况。
    此方案能够实现

方案二的实现,element-ui的实现方式为最外层框为一个div,而后将两个input同时定位输入框位置,真输入框进行输入操做,假输入框显示placeholder,在选择结果跟真输入框展现在同一行并把输入框位置挤到后面。
我在实现此方案时,感受两个input有点多余,假的input只是提供了一个placeholder的功能,彻底可使用里面input的动态placeholder,在选中内容后将placeholder置为空,这样能够省去一个假的input标签和定位操做(定位就会涉及到层级问题比较复杂)。选中结果展现是使用了flex布局,将选中的结果循环渲染在div中,最后面再加上input输入框,便可实现输入框一直在选中结果的最后面,而且选中多人后能够换行(解决了输入框高度和选中结果宽度的不固定问题)。而后经过对input输入框focus事件和blur事件的监听来对外部div进行一些样式的改变来模拟一个真实输入框的输入效果、失焦效果和获取焦点的效果。github

注:选中结果与输入框的同行显示与换行,我使用的是flex布局实现的,使用span标签或行内块元素渲染选中结果也能够实现此效果,可是须要保证slot传进来的结构不能使用块级元素。element-ui

代码实现

<!--外层div(模拟inout的各类状态)-->
<div class="demo-outBox-XL" @click="chooseInput" :class="{'demo-is-focus-XL': inputFocus}">
    <!--输入框后面的清空按钮-->
    <span @click.stop="clearChecked">
        <img src="./assets/crossIcon.svg" class="demo-InputCloseIcon-XL" >
    </span>
    <div class="demo-chooseContent-XL" :class="{'demo-showInput-XL': !showClearIcon}" v-if="isMultiple">
        <!--选中的结果-->
        <div v-for="(item, i) in checkedArr" :key="'checked' + i" class="demo-checkedTag-XL">
            <div class="demo-outContent-XL" @click="chooseTag(item)" :class="{'demo-deleteStatus-XL': i === checkedArr.length - 1 && deleteStatus}">
                <slot name="tag" v-bind:item="item">{{item[props.label]}}</slot>
            </div>
        </div>
        <!--输入框input-->
        <input type="text" v-model.trim="searchVal" :placeholder="!checkedArr.length ? placeholder : ''" class="demo-inInput-XL" @focus="handleFocus" @blur="loseFocus" ref="inInput" @keydown.8="deleteOne" v-on="$listeners">
    </div>
</div>
复制代码

下拉框和选中结果显示样式的设计

此功能在element-ui上并无,element的下拉框和选中结果只支持显示某一个字段,而没法实现自定义,我在实现此功能时使用了具名插槽,经过组件内v-for循环拿到数据,而后经过v-bind:item="item"暴露给父组件,在父组件中使用 slot-scope="{ item }"接收组件内传过来的数据,并在父组件中设置显示样式,实现显示样式的自定义;json

实现此功能时须要注意由组件暴露出来item若是直接使用slot-scope="item"接收会没法使用,此时是一个json字符串,须要使用{item}才能拿到传过来的对象加以使用。在使用slot时增长了默认值,能够保证若是调用组件时没有传插槽时不会出问题,默认显示label字段。插槽的写法用的是旧版的书写方式,由于项目缘由并不支持最新版的插槽书写方式(能够看vue官网有新写法和废弃写法的区别)。windows

子组件代码示例

<!--选中的结果-->
        <div v-for="(item, i) in checkedArr" :key="'checked' + i" class="demo-checkedTag-XL">
            <div class="demo-outContent-XL" @click="chooseTag(item)" :class="{'demo-deleteStatus-XL': i === checkedArr.length - 1 && deleteStatus}">
                <!--name为名称   v-bind为要暴露出去的对象 {{item[props.label]}}为默认值-->
                <slot name="tag" v-bind:item="item">{{item[props.label]}}</slot>
            </div>
        </div>
复制代码

父组件代码示例

<!--样式并不用写在行内,此处为了方便写在行内了(并不建议这样写)-->
<template slot="tag" slot-scope="{ item }">
    <img src="./img.jpg" style="width: 16px; height: 16px; border-radius: 50%;">
    <div style="line-height: 14px; font-size: 14px; margin-left:4px; word-wrap: break-word; word-break: break-word;">
        {{item.name}}
    </div>
</template>
复制代码

将绑定在组件上的方法绑定在目标标签input上

首先想到的是使用native修饰符,尝试后发现并不能实现,只有input事件能够绑定上去,change事件并不会失效,因此否认了这个方法。
而后使用了$emit在input上绑定经常使用方法,并在方法内去使用$emit去调用父组件绑定的对应的方法,此方法存在必定缺陷并不完美,即只能绑定本身提早预设好的一些方法,若是组内内部没有去调用的方法,则在父组件中绑定方法会无效,因此一样否认了此种方法。
在使用此方法时遇到了另一个问题,本身在组件上写的v-model(默认子组件使用value接收父组件传的值,调用父组件的input方法来改变父组件的值)双向绑定与本身想要在组件上绑定的input事件冲突,调用input事件时会给v-model的值置空,经过修改model配置更改了v-model默认的传值与接收,再也不使用input方法更改更改组件的v-model绑定的值。浏览器

最后,经过$listeners接收父组件绑定的方法,再经过v-on绑定在组件的input上解决了此问题,而且写组件时在input绑定的方法并不会与父组件传过来的相同方法冲突,而是会两个都执行,完美。bash

代码示例

<!--父组件-->
<Myinput v-model="arr" :options="options" multiple @input="inputEvent" @change="changeEvent" @keyup.enter="enterEvent">
</Myinput>

<!--子组件-->
<div class="demo-chooseContent-XL" :class="{'demo-showInput-XL': !showClearIcon}" v-if="isMultiple">
    <div v-for="(item, i) in checkedArr" :key="'checked' + i" class="demo-checkedTag-XL">
        <div class="demo-outContent-XL" @click="chooseTag(item)" :class="{'demo-deleteStatus-XL': i === checkedArr.length - 1 && deleteStatus}">
            <slot name="tag" v-bind:item="item">{{item[props.label]}}</slot>
            <span @click.stop="deleteChecked(item)" class="demo-icon-XL">
                <img src="./assets/crossIcon.svg" class="demo-tagIcon-XL" >
            </span>
        </div>
    </div>
    <input type="text" v-model.trim="searchVal" v-on="$listeners" :placeholder="!checkedArr.length ? placeholder : ''" class="demo-inInput-XL" @focus="handleFocus" @blur="loseFocus" ref="inInput" @keydown.8="deleteOne">
</div>
复制代码

拖拽功能实现

此功能是后来产品加的功能,选中人以后要进行顺序的拖拽,首先想到了两种实现方式:
dom

  1. 原生的drag方法
  2. 引用drag插件

而后就使用了原生的方法实现了此功能,能够实现,而且没发现问题,很短期实现了此功能,代码示例以下:

<div class="demo-chooseContent-XL" :class="{'demo-showInput-XL': !showClearIcon}" v-if="isMultiple">
    <div v-for="(item, i) in checkedArr" :key="'checked' + i" class="demo-checkedTag-XL"
        :draggable="isDraggable && !deleteStatus"
        @dragstart="handleDragStart($event, item)"
        @dragover.prevent="handleDragOver($event, item)"
        @dragenter="handleDragEnter($event, item)"
        @dragend="handleDragEnd($event, item)"
    >
        <div class="demo-outContent-XL" @click="chooseTag(item)" :class="{'demo-deleteStatus-XL': i === checkedArr.length - 1 && deleteStatus}">
            <slot name="tag" v-bind:item="item">{{item[props.label]}}</slot>
            <span @click.stop="deleteChecked(item)" class="demo-icon-XL">
                <img src="./assets/crossIcon.svg" class="demo-tagIcon-XL" >
            </span>
        </div>
    </div>
    <input type="text" v-model.trim="searchVal" :placeholder="!checkedArr.length ? placeholder : ''" class="demo-inInput-XL" @focus="handleFocus" @blur="loseFocus" ref="inInput" @keydown.8="deleteOne" v-on="$listeners">
</div>

// 实现拖拽功能
private dragging: any = null;
private handleDragStart(e: any, item: object): void {
    this.dragging = item;
}
private handleDragEnd(e: any, item: object): void {
    this.dragging = null;
}

private handleDragOver(e: any, item: object): void {
    e.dataTransfer.dropEffect = "move"; // e.dataTransfer.dropEffect="move";//在dragenter中针对放置目标来设置!
}

private handleDragEnter(e: any, item: object): void {
    e.dataTransfer.effectAllowed = "move"; //为须要移动的元素设置dragstart事件
    if (item === this.dragging) {
    return;
    }
    const newArr = [...this.checkedArr];
    const src = newArr.indexOf(this.dragging);
    const dst = newArr.indexOf(item);
    newArr.splice(dst, 0, ...newArr.splice(src, 1));
    this.checkedArr = newArr;
}
复制代码

提测以后才是噩梦的开始,safari浏览器在拖拽时没法实现输入框的自动滚动(输入框限制了高度,超出三行出现滚动效果,在其余浏览器把最下面一行内容拉到输入框顶部则本身会向上滚动页面,然而safari却无任何效果),没办法只能回来改。首先是在网上查了一下这个问题,并未查到什么解决方案,而后本身考虑了两种解决方案:一、改成使用插件解决;二、经过监听拖拽时鼠标位置来用js控制滚动。
插件能解决的问题,我才懒得手写监听还要各类判断,而后使用了vue-Draggable插件,此时又遇到了此插件不支持ts,个人ts环境中引入后报错,查了好久经过一些配置才解决了此问题,然而提交后,测试那依旧是不能滚动。(我是windows电脑此问题只在mac的safari上才能复现,我就只能找测试复现,用测试的mac来访问我本地环境测试,两天的时间都在本身的位置和测试的位置来回跑,我是多么渴望此时拥有一台mac)
问题仍是要解决的,只能经过监听鼠标位置了(自信满满的写好了监听和各类状况判断),此次总该是解决了问题,然而到了测试那依旧是不动。没理由啊,监听鼠标位置怎么都没法实现呢,经过打印发现,在拖拽过程当中鼠标mousemove事件并不会触发,此方案并不能实现滚动效果。
又经过百度+google查找终于看到了一个drag事件(之前没作过拖拽的功能,根本不知道这个事件),看这名字感受应该能够用上,终于经过此事件解决了此问题,由于滚动过快又对此功能加了一个节流,这个问题差很少用了一天半的时间才彻底解决掉。代码示例以下:

<div class="wfc-chooseContent-XRZJ" :class="{'wfc-showInput-XRZJ': !showPlaceholder}" v-if="isMultiple" @drag="divDragging">
    <div v-for="(item, i) in checkedArr" :key="'checked' + i" class="wfc-checkedTag-XRZJ"
        :draggable="isDraggable && !deleteStatus"
        @dragstart="handleDragStart($event, item)"
        @dragover.prevent="handleDragOver($event, item)"
        @dragenter="handleDragEnter($event, item)"
        @dragend="handleDragEnd($event, item)"
    >
        <div class="wfc-outContent-XRZJ" @click="chooseTag(item)" :class="{'wfc-deleteStatus-XRZJ': i === checkedArr.length - 1 && deleteStatus}">
            <slot name="tag" v-bind:item="item">{{item[props.label]}}</slot>
            <span @click.stop="deleteChecked(item)" class="wfc-icon-XRZJ">
                <img src="./assets/crossIcon.svg" class="wfc-tagIcon-XRZJ" >
            </span>
        </div>
    </div>
    <input type="text" v-model.trim="searchVal" class="wfc-inInput-XRZJ" :placeholder="checkedArr.length ? '' : placeholder" @focus="handleFocus" @blur="loseFocus" @keydown.8="deleteOne" ref="inInput" v-on="$listeners">
</div>

dragInterVal: boolean = false
private divDragging(e: any) {
    if (this.dragInterVal) {
        return
    }
    if (e.x === 0 && e.y === 0) {
        return
    }
    if (e.layerY < 26 && this.domTemp.scrollTop > 0) {
        this.domTemp.scrollTop = this.domTemp.scrollTop - 24
        this.dragInterVal = true
        setTimeout(() => {
            this.dragInterVal = false
        }, 400);
    } else if (94 - e.layerY < 30 && this.domTemp.scrollHeight - this.domTemp.scrollTop - 94 > 0) {
        this.domTemp.scrollTop = this.domTemp.scrollTop + 24
        this.dragInterVal = true
        setTimeout(() => {
            this.dragInterVal = false
        }, 400);
    }
}
复制代码

搜索结果变化

此功能为根据搜索结果将下拉框内容变色,即模糊查询时搜索结果中被查询字段变色,虽然此功能不属于下拉框组件内的内容,可是想拿出来讲一下,提供一些思路。

单独拿出v-html这个指令来讲,应该不少人都知道有什么做用,可是却不多会使用到,此功能有了这个指令就变得异常简单。
首先咱们能拿到输入框内的内容(暂时命名为searchVal),也能够拿到根据此内容模糊查询到的结果,此时咱们只需对此结果进行一个循环,用标签的形式<span style="color: red;">searchVal<span>替换调结果中含有的searchVal的字段(经过replace方法和正则),并使用v-html绑定到页面上便可实现此效果。代码示例以下:

<template slot="option" slot-scope="{ item }">
    <div class="wfc-optionStyleXRZJrules">
        <img class="wfc-headPortraitXRZJrules" :src="item.photoBig">
        <div class="wfc-rightXRZJrules">
            <!-- 变色的内容 -->
            <div class="wfc-nameXRZJrules" v-html="item.newname"></div>
            <div class="wfc-emailXRZJrules">{{item.email}}</div>
            <div class="wfc-bottomXRZJrules">{{isEN ? item.org_name_mult_lang.en : item.org_name_mult_lang.zh}}</div>
        </div>
    </div>
</template>

this.options = searchUser.map((item: any) => {
    item.newname = item.name.replace(new RegExp(val, 'g'), `<span style="color: #3C8CFF;">${val}</span>`)
    return item
})
复制代码

###其余的内容暂时没有想到须要拿出来讲的问题,后面想到会进行补充,哪些写的很差的地方请留言,感谢拨冗翻阅拙做,敬请斧正。

相关文章
相关标签/搜索