实现vue下拉select组件

1,645 阅读2分钟

概述

在各种需要条件筛选的需求下,下拉组件是比较常用的,最多的地方应该就属后台管理系统当中了,各种表格,基本上都会用到下拉组件,用来筛选不同条件的表格数据,结合上一篇提到的跨不同层级的祖孙级的组件通信,今天来实现一个下拉组件。

最终效果

动画.gif

组件构成

用过组件库的都可以看到select组件包含两部分:selct 和 option

  • selct:主要用户处理复杂逻辑的组件
  • option:渲染一个一个的下拉项组件

用法

App.vue

<template>
  <div class="app">
    <div class="flex">
      <!-- 常规用法:带本地搜索 -->
      <div class="item">
        <h2>常规用法:带本地搜索</h2>
        <my-select
          @on-change="handleChange"
          v-model="currentFood"
          placeholder="请选择食品"
          filterable
          clearable
        >
          <my-option
            v-for="item in options"
            :key="item.value"
            :value="item.label"
            :label="item.label"
            :disabled="item.disabled"
          ></my-option>
          <!-- 带远程搜索 -->
        </my-select>
      </div>
      <div class="item">
        <!-- 高级用法:带远程搜索 -->
        高级用法:带远程搜索
        <my-select
          v-model="currentSearchValue"
          :loading="loading"
          filterable
          remote
          :remote-method="remoteMethod"
          :remote-data-list="options2"
          :maxHeight="200"
          @on-change="handleChange"
        >
          <my-option
            v-for="item in options2"
            :key="item.value"
            :value="item.value"
            :label="item.label"
          ></my-option>
        </my-select>
      </div>
    </div>
  </div>
</template>

<script>
import MySelect from "./components/MySelect/MySelect.vue";
import MyOption from "./components/MySelect/MyOption.vue";
export default {
  components: { MySelect, MyOption },
  data() {
    return {
      options: [
        {
          value: "选项1",
          label: "黄金糕",
        },
        {
          value: "选项2",
          label: "双皮奶",
        },

        {
          value: "选项3",
          label: "蚵仔煎",
          disabled: true,
        },
        {
          value: "选项4",
          label: "龙须面",
        },
        {
          value: "选项5",
          label: "北京烤鸭",
          disabled: true,
        },
        {
          value: "选项6",
          label: "杂酱面",
        },
      ],
      currentFood: "黄金糕",
      // 加载中
      loading: false,
      // 搜索关键词
      states: [
        "Alabama",
        "Alaska",
        "Arizona",
        "Arkansas",
        "California",
        "Colorado",
        "Connecticut",
        "Delaware",
        "Florida",
        "Georgia",
        "Hawaii",
        "Idaho",
        "Illinois",
        "Indiana",
        "Iowa",
        "Kansas",
        "Kentucky",
        "Louisiana",
        "Maine",
        "Maryland",
        "Massachusetts",
        "Michigan",
        "Minnesota",
        "Mississippi",
        "Missouri",
        "Montana",
        "Nebraska",
        "Nevada",
        "New Hampshire",
        "New Jersey",
        "New Mexico",
        "New York",
        "North Carolina",
        "North Dakota",
        "Ohio",
        "Oklahoma",
        "Oregon",
        "Pennsylvania",
        "Rhode Island",
        "South Carolina",
        "South Dakota",
        "Tennessee",
        "Texas",
        "Utah",
        "Vermont",
        "Virginia",
        "Washington",
        "West Virginia",
        "Wisconsin",
        "Wyoming",
      ],
      // 处理关键词后的数据
      list: [],
      options2: [],
      currentSearchValue: "",
    };
  },
  mounted() {
    // 初始化搜索数据
    this.list = this.states.map((item) => {
      return { value: `value:${item}`, label: `label:${item}` };
    });
    this.$on("onChange", () => {
      console.log("测试出发了");
    });
  },
  methods: {
    // 下拉变化
    handleChange(value) {
      console.log(value);
    },
    // 远程搜索
    remoteMethod(query) {
      if (query !== "") {
        this.loading = true;
        setTimeout(() => {
          this.loading = false;
          this.options2 = this.list.filter((item) => {
            return item.label.toLowerCase().indexOf(query.toLowerCase()) > -1;
          });
        }, 200);
      } else {
        this.options2 = [];
      }
    },
    //
    handleTestClick() {
      this.$emit("onChange");
    },
  },
};
</script>

<style lang="less">
.app {
  padding: 20px;
  .flex {
    display: flex;
    .item {
      margin: 0 20px;
    }
  }
}
</style>

具体实现

组件目录层级结构,结构红色框里,其他的是其他组件的。

image.png

emitter.js

上一篇文章中有提到这个文件作用

/**
 * @Description 由于涉及到跨不同祖孙组件之间通信,因此我们只有自己实现发布订阅的模式,来实现组件之间通信,灵感主要来源于element-ui组件库源码中跨层级父子组件通信方案,本质上也是发布订阅和$emit和$on
 * @param { String } componentName 组件名
 * @param { String } eventName 事件名
 * @param { argument } params 参数
 **/

// 广播通知事件
function _broadcast(componentName, eventName, params) {
  // 遍历当前组件的子组件
  this.$children.forEach(function (child) {
    // 取出componentName,组件options上面可以自己配置
    var name = child.$options.componentName;
    // 如果找到了需要通知的组件名,触发组件上面的$eimit方法,触发自定义事件
    if (name === componentName) {
      child.$emit.apply(child, [eventName].concat(params));
    } else {
      // 没找到,递归往下找
      _broadcast.apply(child, [componentName, eventName].concat([params]));
    }
  });
}
const emiiter = {
  methods: {
    // 派发事件(通知父组件)
    dispatch(componentName, eventName, params) {
      var parent = this.$parent || this.$root;
      var name = parent.$options.componentName;
      // 循环往上层父组件,知道知道组件名和需要触发的组件名相同即可,然后触发对应组件的事件
      while (parent && (!name || name !== componentName)) {
        parent = parent.$parent;
        if (parent) {
          name = parent.$options.componentName;
        }
      }
      if (parent) {
        parent.$emit.apply(parent, [eventName].concat(params));
      }
    },
    // 广播事件(通知子组件)
    broadcast(componentName, eventName, params) {
      _broadcast.call(this, componentName, eventName, params);
    },
  },
};

export default emiiter;

index.js

import MySelect from "./MySelect.vue";
import MyOption from "./MyOption.vue";

export default { MySelect, MyOption };

MySelect.vue

<template>
  <div class="select">
    <!-- 初始输入框状态 -->
    <div class="select-text-wrap">
      <input
        type="text"
        class="select-input"
        :placeholder="placeholder"
        @click.stop="showOptions = !showOptions"
        :value="value"
        :readonly="!filterable"
        :disabled="disabled"
        @input="handleInputTextChange"
      />
      <div
        class="icon-wrap"
        @mouseenter="value.length > 0 && clearable ? (showCancel = true) : ''"
        @mouseout="showCancel = false"
      >
        <!-- 输入框下拉箭头 -->
        <span
          :class="['iconfont', 'icon-arrow-up', showOptions ? 'icon-down' : '']"
          v-if="!showCancel"
        ></span>
        <!-- 清空输入框 -->
        <span
          :class="['iconfont', 'icon-cancel']"
          v-if="showClearIncon"
          @click.stop="handleClearInputText"
        ></span>
      </div>
    </div>
    <!-- 对应下下拉选项插槽 -->
    <transition name="el-zoom-in-top">
      <div
        class="select-options-slots"
        :style="{
          textAlign: textAlign,
          maxHeight: maxHeight + 'px',
          overflow: 'auto',
        }"
        v-show="showOptions"
      >
        <!-- 本地搜索时展示 -->
        <template
          v-if="
            !remote && filterResultList.length && isSearchIn && value.length > 0
          "
        >
          <my-option
            v-for="item in filterResultList"
            :value="item.value"
            :key="item.value"
            :label="item.label"
          ></my-option>
        </template>
        <!-- 远程搜索时展示 -->
        <template v-if="remote && remoteDataList.length && !loading">
          <my-option
            v-for="item in remoteDataList"
            :value="item.value"
            :key="item.value"
            :label="item.label"
          ></my-option>
        </template>
        <!-- 外部下拉项插槽 -->
        <slot
          v-if="
            !remote &&
            (value.length == 0 || !isSearchIn) &&
            filterDataList.length
          "
        ></slot>
        <!-- 搜索为空 -->
        <div
          class="select-empty"
          v-if="
            (!loading &&
              !remote &&
              ((!filterResultList.length && isSearchIn && value.length > 0) ||
                !filterDataList.length)) ||
            (remote && !remoteDataList.length && !loading)
          "
        >
          暂无数据
        </div>
        <!-- 加载中状态 -->
        <div class="select-loading" v-if="loading">加载中...</div>
      </div>
    </transition>
  </div>
</template>

<script>
import MyOption from "./MyOption.vue";
import emitter from "./emitter";
export default {
  name: "MySelect",
  componentName: "MySelect",
  mixins: [emitter],
  components: {
    MyOption,
  },
  props: {
    // 输入框文字是否居中显示
    textAlign: {
      type: String,
      default: "left",
    },
    // 绑定value值
    value: {
      type: String,
      default: "",
    },
    // 输入框提示
    placeholder: {
      type: String,
      default: "请选择",
    },
    // 是否禁用
    disabled: {
      type: Boolean,
      default: false,
    },
    // 是否可清空
    clearable: {
      type: Boolean,
      default: false,
    },
    // 可搜索
    filterable: {
      type: Boolean,
      default: false,
    },
    // 下来最大高度,下拉数据太长之后,超出显示滚动条
    maxHeight: {
      type: [String, Number],
      default: "auto",
    },
    // 可远程搜索
    remote: {
      type: Boolean,
      default: false,
    },
    // 远程搜索函数
    remoteMethod: {
      type: Function,
      default: () => {},
    },
    // 搜索loading
    loading: {
      type: Boolean,
      default: false,
    },
    // 远程搜索时候的需要执行的搜索函数
    remoteDataList: {
      type: Array,
      default: () => {
        return [];
      },
    },
  },
  data() {
    return {
      // 是否显示下来
      showOptions: false,
      // 点击活跃项
      activeValue: this.value,
      // 输入框只读状态
      readonly: "",
      // 显示取消
      showCancel: false,
      // 是否处于搜索
      isSearchIn: false,
      // 需要搜索是的数据
      filterDataList: [],
      // 搜索结果
      filterResultList: [],
    };
  },
  computed: {
    // 是否显示清空图标
    showClearIncon() {
      return this.clearable && this.value.length && this.showCancel;
    },
  },
  mounted() {
    this.init();
  },
  methods: {
    //初始化
    init() {
    //订阅item项点击事件
      this.$on("onChange", (value) => {
        this.handleOptionsHandler(value);
      });
      // window点击关闭下拉
      document.addEventListener(
        "click",
        this.handleCloseOptions.bind(this),
        false
      );
      // 初始下拉数据
      this.getSearchData();
      // 当前value和下拉数据对比
      this.setActiveOption();
    },
    // item项点击
    handleOptionsHandler(value) {
      // 设置当前点击活跃项
      this.activeValue = value;
      // 关闭弹窗
      this.showOptions = false;
      // 发布change事件,将value抛出给外部使用
      this.$emit("on-change", value);
      // 发布input事件,更改input框的值
      this.$emit("input", value);
      // 点击后设置成未搜索状态(避免点击后下拉显示的不是全部)
      this.isSearchIn = false;
    },
    // 清空图标点击
    handleClearInputText() {
      // 发布清空输入框的事件(原理请参考vue文档在组件上使用v-model)
      this.$emit("input", "");
      // 当前活跃项清空
      this.activeValue = "";
      // 清空图标隐藏
      this.showCancel = false;
      // 关闭下拉选项
      this.showOptions = false;
      // 发布change事件,将value抛出给外部使用
      this.$emit("on-change", "");
    },
    // 组件之外点击关闭下来
    handleCloseOptions() {
      this.showOptions = false;
    },
    // 搜索输入框变化
    handleInputTextChange(e) {
      this.$emit("input", e.target.value);
      // 执行搜索
      this.setSearchData(e.target.value);
    },
    // 初始化搜索数据
    getSearchData() {
      // 判空处理,可能没下来数据
      if (this.$slots.default) {
        // 组装数据,供搜索使用
        this.filterDataList = this.$slots.default.map((vnode) => {
          // 从插槽中的componentOptions.proprsData中可以拿到组件对应的props
          const value = vnode.componentOptions?.propsData?.value,
            label = vnode.componentOptions?.propsData?.label;
          return {
            value,
            label,
          };
        });
      }
    },
    // 搜索数据
    setSearchData(value) {
    //输入框input事件触发,搜索
      this.isSearchIn = true;
      this.filterResultList = this.filterDataList.filter((item) =>
        item.value.includes(value)
      );
    },
    // 初始化设置高亮item
    setActiveOption() {
      const activeOption = this.filterDataList.find((item) => {
        return item.value == this.value;
      });
      !activeOption && this.$emit("input", "");
    },
  },
  watch: {
    value(val) {
      // 执行远程搜索逻辑
      this.remoteMethod(val);
      // value变化通知option组件更新高亮组件
      this.$emit("input", val);
      this.activeValue = "";
      //广播给所有子组件也就是MyOption组件
      this.broadcast("MyOption", "activeValueChange", val);
    },
  },
};
</script>

<style lang="less">
.select {
  width: 240px;
  position: relative;
  .select-input {
    -webkit-appearance: none;
    background-color: #fff;
    background-image: none;
    border-radius: 4px;
    border: 1px solid #dcdfe6;
    box-sizing: border-box;
    color: #606266;
    display: inline-block;
    font-size: inherit;
    height: 40px;
    line-height: 40px;
    outline: none;
    padding: 0 15px;
    transition: border-color 0.2s cubic-bezier(0.645, 0.045, 0.355, 1);
    width: 100%;
    &:focus {
      border-color: #409eff;
    }
  }
  .select-input[disabled] {
    background-color: #f5f7fa;
    border-color: #e4e7ed;
    color: #c0c4cc;
    cursor: not-allowed;
  }
  .select-options-slots {
    position: absolute;
    left: 0;
    top: 40px;
    border: 1px solid #e4e7ed;
    border-radius: 4px;
    background-color: #fff;
    box-shadow: 0 2px 12px 0 rgb(0 0 0 / 10%);
    box-sizing: border-box;
    margin: 5px 0;
    width: 100%;
  }
  .select-empty,
  .select-loading {
    text-align: center;
    padding: 10px 0;
    margin: 0;
    text-align: center;
    color: #999;
    font-size: 14px;
  }
  //S iconfont字体(iconfont图标框可以找到)
  @font-face {
    font-family: "iconfont"; /* Project id 2863961 */
    src: url("//at.alicdn.com/t/c/font_2863961_qgnvuxx0y7.woff2?t=1659601861494")
        format("woff2"),
      url("//at.alicdn.com/t/c/font_2863961_qgnvuxx0y7.woff?t=1659601861494")
        format("woff"),
      url("//at.alicdn.com/t/c/font_2863961_qgnvuxx0y7.ttf?t=1659601861494")
        format("truetype");
  }

  .iconfont {
    font-family: "iconfont" !important;
    font-size: 16px;
    font-style: normal;
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
  }

  .icon-cancel:before {
    content: "\e6db";
  }

  .icon-arrow-up:before {
    content: "\e743";
  }
  //E iconfont字体
  .select-text-wrap {
    position: relative;
    .icon-arrow-up,
    .icon-cancel {
      position: absolute;
      top: 50%;
      right: 10px;
      transform: translateY(-50%) rotate(180deg);
      transition: transform 0.3s;
      font-size: 20px;
      cursor: pointer;
    }
    .icon-down {
      transform-origin: center;
      transform: translateY(-50%) rotate(0deg);
    }
  }

  // 过度
  .el-zoom-in-top-enter-active,
  .el-zoom-in-top-leave-active {
    opacity: 1;
    transform: scaleY(1);
    transition: transform 0.3s cubic-bezier(0.23, 1, 0.32, 1),
      opacity 0.3s cubic-bezier(0.23, 1, 0.32, 1);
    transform-origin: center top;
  }

  .el-zoom-in-top-enter,
  .el-zoom-in-top-leave-active {
    opacity: 0;
    transform: scaleY(0);
  }
}
</style>

MyOption.vue

<template>
  <div
    :class="[
      'my-option-item',
      activeValue == value ? 'active-item' : '',
      disabled ? 'disbled-item' : '',
    ]"
    @click.stop="handleOptionsClick"
  >
    <!-- 用户提供的插槽内容优先级更高 -->
    <slot v-if="$slots.default"></slot>
    <!-- 没提供插槽的时候,使用label占位 -->
    <span v-else>{{ label }}</span>
  </div>
</template>

<script>
import emitter from "./emitter";

export default {
  name: "MyOption",
  componentName: "MyOption",

  mixins: [emitter],
  props: {
    // 是否禁用
    disabled: {
      type: Boolean,
      default: false,
    },
    // label
    label: {
      type: String,
      default: "",
    },
    // 绑定的value值
    value: {
      type: String,
      default: "",
    },
  },
  data() {
    return {
      // 当前活跃项
      activeValue: this.$parent.activeValue,
    };
  },
  mounted() {
    // 订阅更新当前activeValue的事件
    this.$on("activeValueChange", (value) => {
      this.activeValue = value;
    });
  },
  methods: {
    // item项点击
    handleOptionsClick() {
      // 禁用项点击,直接终止后续操作
      if (this.disabled) return;
      //通知父组件MySelwction当前项点击了
      this.dispatch("MySelect", "onChange", this.value || "");
    },
  },
  watch: {},
};
</script>

<style lang="less">
.my-option-item {
  width: 100%;
  font-size: 14px;
  padding: 0 20px;
  position: relative;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  color: #606266;
  height: 34px;
  line-height: 34px;
  box-sizing: border-box;
  cursor: pointer;
  &:hover {
    background-color: #f5f7fa;
  }
}
// 点击活跃项
.my-option-item.active-item {
  font-weight: bold;
  color: #008c8c;
  background-color: #f5f7fa;
}
// 禁用项
.my-option-item.disbled-item {
  color: #c0c4cc;
  cursor: not-allowed;
}
// 禁用项滑过
.disbled-item:hover {
  background-color: #fff;
}
</style>

总结

要封装一个组件库使用的组件,还是需要考虑很多方面的,上面还有很多地方需要完善,大家根据自己需要可以进行扩展。