shayloyuki

科技是第一生产力

 

点击输入框,底部弹出下拉框显示输入建议

需求

  1. 点击输入框时,或者输入内容时,底部弹出下拉框显示输入建议。
  2. 选择某项输入建议后,输入框显示该内容。
  3. 输入建议列表根据输入的内容模糊搜索返回;若输入内容为空,则返回全部输入建议。

效果类似于搜索引擎的搜索栏:

image

分析

<input /> 原生属性 autocomplet,若为 on 则下拉框形式展示该输入框的输入历史,off 则不展示。

image

若要自定义输入建议列表,则不能直接使用该属性。

经研究,发现 el-autocomplete 组件 满足需求。

解决

封装组件

由于页面上会使用多个 el-autocomplet,为了便于复用,封装为 InputLoadMore 组件。

模糊搜索返回输入建议列表的接口,可以通过 props 传递接口地址,封装到 search.js 中。

代码如下:

InputLoadMore/index.js
<template>
  <el-autocomplete
    :ref="refName"
    :value-key="valueKey"
    v-scrollLoad="selectLoadMore"
    v-model="autocompleteVal"
    v-trim
    clearable
    :fetch-suggestions="querySearch"
    :placeholder="placeholder"
    :trigger-on-focus="isTriggerFocus"
    :disabled="isDisabled"
    :popper-append-to-body="false"
    @select="handleSelect"
    @focus="searchFocus"
  />
</template>

<script>
import { queryData } from "./search.js";

export default {
  name: "InputLoadMore",
  props: {
    refName: {
      type: String,
      required: true,
    },
    isDisabled: {
      type: Boolean,
      default: false,
    },
    // v-model的绑定值
    modelVal: {
      type: String,
      required: true,
    },
    // 搜索接口
    queryUrl: {
      type: String,
      required: true,
    },
    // 后端定义的联想的key
    searchKey: {
      type: String,
      default: "value",
    },
    // 搜索额外需要的参数,若有分页,要包含 pageNum 和 pageSize
    additionParas: {
      type: Object,
      default: {
        pageNum: 1,
        pageSize: 10,
      },
    },
    // 输入建议对象中用于显示的键名
    valueKey: {
      type: String,
      default: "value",
    },
    // 默认点击输入框就列出输入建议,反之,输入内容才触发输入建议
    isTriggerFocus: {
      type: Boolean,
      default: true,
    },
    // 是否是滑动分页加载数据
    isScrollPage: {
      type: Boolean,
      default: true,
    },
    placeholder: {
      type: String,
      default: "请输入",
    },
  },
  data() {
    return {
      // autocomplete 组件绑定值
      autocompleteVal: "",
      pageNum: 1,
      total: 0,
    };
  },
  watch: {
    autocompleteVal(val) {
      this.$emit("input-value", val);
    },
    modelVal: {
      handler(val) {
        if (val && val !== "null") {
          this.autocompleteVal = val;
        }
      },
      immediate: true,
    },
  },
  methods: {
    // 点击输入框时,隐藏下拉框
    searchFocus() {
      const el = this.$refs[this.refName];
      if (el) {
        el.activated = false;
      }
    },
    // 加载(第一页)数据
    async querySearch(queryString, cb) {
      try {
        let para = {
          ...this.additionParas,
          [this.searchKey]: queryString,
        };
        if (this.isScrollPage) {
          // 输入框失去焦点后再次获焦时,重置页码
          this.pageNum = 1;
          para.pageNum = this.pageNum;
        }
        let { data } = await queryData(para, this.queryUrl);
        if (data.rows) {
          let arr = [];
          data.rows.forEach((item) => {
            // 下拉框选项要转为字符串,避免选择后,导致 autocomplete 组件绑定值为数字而报错
            arr.push({ [this.valueKey]: item.toString() });
          });
          // 若有数据,则显示,否则隐藏下拉框
          if (arr.length) {
            this.$refs[this.refName].activated = true;
            cb(arr);
          } else {
            // 无数据,不显示下拉框
            cb([]);
          }
        }
        this.total = data.totle || 0;
      } catch (err) {
        console.log({ err });
      }
    },
    // 选择后触发
    handleSelect(item) {
      // console.log("handleSelect:", item);
    },
    // 加载更多
    async selectLoadMore() {
      if (!this.isScrollPage) return;
      const curList = this.$refs[this.refName].$data.suggestions;
      if (Number(this.total) <= curList.length) return;
      this.pageNum++;
      try {
        let { data } = await queryData(
          {
            ...this.additionParas,
            [this.searchKey]: this.autocompleteVal,
            pageNum: this.pageNum,
          },
          this.queryUrl
        );
        if (data.rows) {
          const arr = data.rows.map((item) => {
            return { [this.valueKey]: item.toString() };
          });
          // 将数据添加到下拉列表
          this.$refs[this.refName].$data.suggestions = curList.concat(arr);
        }
        this.total = data.totle || 0;
      } catch (err) {
        console.log({ err });
      }
    },
  },
};
</script>

InputLoadMore/search.js
import request from "@/utils/request";

// 模糊搜索查询元件规格数据
export const queryData = (params, queryUrl) => {
  return request({
    url: queryUrl,
    method: "get",
    params,
  });
};

注意的坑

下拉框不显示选项数据?

image

image

阅读文档发现,value-key 表示键名,这说明每条选项数据必须是对象。

image

因此,需要改造下接口返回的数据,不能直接 cb(data.rows),要把它转为对象,如下所示:

相关代码
    // 加载(第一页)数据
    async querySearch(queryString, cb) {
      try {
        let para = {
          ...this.additionParas,
          [this.searchKey]: queryString,
        };
        if (this.isScrollPage) {
          // 输入框失去焦点后再次获焦时,重置页码
          this.pageNum = 1;
          para.pageNum = this.pageNum;
        }
        let { data } = await queryData(para, this.queryUrl);
        if (data.rows) {
          let arr = [];
          data.rows.forEach((item) => {
            // 下拉框选项要转为字符串,避免选择后,导致 autocomplete 组件绑定值为数字而报错
            arr.push({ [this.valueKey]: item.toString() });
          });
          // 若有数据,则显示,否则隐藏下拉框
          if (arr.length) {
            this.$refs[this.refName].activated = true;
            cb(arr);
          } else {
            // 无数据,不显示下拉框
            cb([]);
          }
        }
        this.total = data.totle || 0;
      } catch (err) {
        console.log({ err });
      }
    },

怎样实现鼠标滚动到底部显示下一页?

封装自定义指令 v-scrollLoad,给 .el-autocomplete-suggestion__wrap 盒子添加监听事件:当输入建议列表 .el-autocomplete-suggestion__wrap .el-autocomplete-suggestion__list 滚动到底部时,执行加载更多函数 selectLoadMore()

directive/scroll/scrollLoad.js
export default {
  // 写法一:
  bind(el, binding, vnode) {
    const wrapDom = el.querySelector(".el-autocomplete-suggestion__wrap");
    const listDom = el.querySelector(
      ".el-autocomplete-suggestion__wrap  .el-autocomplete-suggestion__list"
    );
    wrapDom.addEventListener(
      "scroll",
      (e) => {
        const condition =
          wrapDom.offsetHeight + wrapDom.scrollTop + 10 - listDom.offsetHeight;
        if (condition > 0 && !vnode.context.loading) {
          //滚动到底部则执行滚动方法,binding.value就是v-scrollLoad绑定的值,加()表示执行绑定的方法
          binding.value();
        }
      },
      false
    );
  },

  // 写法二:
  // bind(el, binding, vnode) {
  //   function handleScroll(e) {
  //     let isBottom =
  //       e.target.clientHeight + e.target.scrollTop >=
  //       e.target.scrollHeight - 40;
  //     if (isBottom && !vnode.context.loading) {
  //       binding.value();
  //     }
  //   }
  //   // 监听滚动
  //   let wrapDom = el.querySelector(".el-autocomplete-suggestion__wrap");
  //   el.__handleScroll__ = handleScroll;
  //   el.__wrapDom__ = wrapDom;
  //   wrapDom.addEventListener("scroll", handleScroll, false);
  // },

  // unbind(el, binding, vnode) {
  //   // 解除事件监听
  //   el.__wrapDom__.removeEventListener("scroll", el.__handleScroll__, false);
  // },
};
directive/index.js
import scrollLoad from "./scroll/scrollLoad";

const install = function (Vue) {
  Vue.directive("scrollLoad", scrollLoad);
};

export default install;

加载更多函数的本质:把新页的数据拼接到当前下拉框列表的底部

当前下拉框列表:this.$refs[this.refName].$data.suggestions

要注意的是:如果没有封装独立的组件,页面上有多个 el-autocomplete,且每个 el-autocomplete 的 ref 值相同(假如都设为 autocomplete),则 this.$refs.autocomplete 得到的是数组

加载更多函数
    // 加载更多
    async selectLoadMore() {
      if (!this.isScrollPage) return;
      const curList = this.$refs[this.refName].$data.suggestions;
      if (Number(this.total) <= curList.length) return;
      this.pageNum++;
      try {
        let { data } = await queryData(
          {
            ...this.additionParas,
            [this.searchKey]: this.autocompleteVal,
            pageNum: this.pageNum,
          },
          this.queryUrl
        );
        if (data.rows) {
          const arr = data.rows.map((item) => {
            return { [this.valueKey]: item.toString() };
          });
          // 将数据添加到下拉列表
          this.$refs[this.refName].$data.suggestions = curList.concat(arr);
        }
        this.total = data.totle || 0;
      } catch (err) {
        console.log({ err });
      }
    },

分页的坑

点击输入框获取到输入建议后,滚动到下拉框底部加载更多获取下一页数据,此时点击输入框外区域失焦,再点击输入框获取输入建议,发现:失焦后请求数据的参数 pageNum 不是 1,而是继续上次的 pageNum++(比如3)。

原因是:没有重置 pageNum

解决:给初次获取数据的函数,加上重置代码即可,如下所示:

        if (this.isScrollPage) {
          this.pageNum = 1; // 输入框失去焦点后再次获焦时,重置页码
          para.pageNum = this.pageNum;
        }

自定义接口参数,降低耦合性

如果在组件中把模糊查询的接口参数写死,则不利于复用组件。

该接口的参数,由以下两部分组成:

  1. searchKey:要模糊查询的值。默认为 value
  2. additionParas:额外的参数。默认有分页功能,{pageNum: 1, pageSize: 10}。若有其他参数,可一并传入。
相关代码
  props: {
    // 后端定义的联想的key
    searchKey: {
      type: String,
      default: "value",
    },
    // 搜索额外需要的参数,若有分页,要包含 pageNum 和 pageSize
    additionParas: {
      type: Object,
      default: {
        pageNum: 1,
        pageSize: 10,
      },
    },
  },
  methods: {
    // 加载(第一页)数据
    async querySearch(queryString, cb) {
      try {
        let para = {
          ...this.additionParas,
          [this.searchKey]: queryString,
        };
        if (this.isScrollPage) {
          // 输入框失去焦点后再次获焦时,重置页码
          this.pageNum = 1;
          para.pageNum = this.pageNum;
        }
        let { data } = await queryData(para, this.queryUrl);
        if (data.rows) {
          let arr = [];
          data.rows.forEach((item) => {
            // 下拉框选项要转为字符串,避免选择后,导致 autocomplete 组件绑定值为数字而报错
            arr.push({ [this.valueKey]: item.toString() });
          });
          // 若有数据,则显示,否则隐藏下拉框
          if (arr.length) {
            this.$refs[this.refName].activated = true;
            cb(arr);
          } else {
            // 无数据,不显示下拉框
            cb([]);
          }
        }
        this.total = data.totle || 0;
      } catch (err) {
        console.log({ err });
      }
    },
    // 加载更多
    async selectLoadMore() {
      if (!this.isScrollPage) return;
      const curList = this.$refs[this.refName].$data.suggestions;
      if (Number(this.total) <= curList.length) return;
      this.pageNum++;
      try {
        let { data } = await queryData(
          {
            ...this.additionParas,
            [this.searchKey]: this.autocompleteVal,
            pageNum: this.pageNum,
          },
          this.queryUrl
        );
        if (data.rows) {
          const arr = data.rows.map((item) => {
            return { [this.valueKey]: item.toString() };
          });
          // 将数据添加到下拉列表
          this.$refs[this.refName].$data.suggestions = curList.concat(arr);
        }
        this.total = data.totle || 0;
      } catch (err) {
        console.log({ err });
      }
    },
  },

若没有输入建议,则下拉框会一闪而过,然后只显示一半的高度

image

解决方案有两个:

  1. 若无输入建议,则加载状态结束后,下拉框内容显示 暂无数据
  2. (推荐)若无输入建议,则不显示下拉框。输入建议存在时,才显示下拉框。

经对比,发现采用第二种方案,页面会更简洁。但有个弊端:无法显示获取输入建议的加载状态。

方案二思路:页面初始隐藏下拉框,只有获取的输入建议长度不为 0 时,才让下拉框显示。

控制下拉框的显隐,有两种方法:

  1. 给下拉框盒子 .el-autocomplete-suggestion 设置样式: display: none 隐藏,display: block 显示;
  2. (推荐)el-autocomplete 组件的 activated:为 true 则下拉框列表显示,反之隐藏。

经测试,发现方法一会导致页面卡顿、且下拉框只显示一半高度。因此推荐方法二。

相关代码
<template>
  <!--   方法一:给组件加上 @focus="searchFocus"   -->
  <el-autocomplete
    :ref="refName"
    :value-key="valueKey"
    v-scrollLoad="selectLoadMore"
    v-model="autocompleteVal"
    v-trim
    clearable
    :fetch-suggestions="querySearch"
    :placeholder="placeholder"
    :trigger-on-focus="isTriggerFocus"
    :disabled="isDisabled"
    :popper-append-to-body="false"
    @select="handleSelect"
    @focus="searchFocus"
  />
</template>

<script>
export default: {
  methods: {
    // 点击输入框时,隐藏下拉框
    searchFocus() {
      const el = this.$refs[this.refName];
      if (el) {
        el.activated = false;
      }
    },
    async querySearch(queryString, cb) {
      try {
        let para = {
          ...this.additionParas,
          [this.searchKey]: queryString,
        };
        if (this.isScrollPage) {
          this.pageNum = 1;
          para.pageNum = this.pageNum;
        }
        let { data } = await queryData(para, this.queryUrl);
        if (data.rows) {
          let arr = [];
          data.rows.forEach((item) => {
            arr.push({ [this.valueKey]: item.toString() });
          });
          // 若有数据,则显示,否则隐藏下拉框
          if (arr.length) {
            // 方法一:会导致下拉框显示样式错乱
            // document.getElementsByClassName(
            //   `autocomplete-${this.refName}`
            // )[0].style.display = "block";
	    // 方法二:
            this.$refs[this.refName].activated = true;
            cb(arr);
          } else {
            cb([]);
          }
        }
        this.total = data.totle || 0;
      } catch (err) {
        console.log({ err });
      }
    },
  }
}
</script>

<style lang="scss" scoped>
// 方法一:初始隐藏下拉框
// ::v-deep .el-autocomplete-suggestion {
//   display: none;
// }
</style>

滚动防抖

滚动下拉框触底加载下一页,(有时)会触发多次selectLoadMore函数,性能消耗大。

image

因此要给 v-scrollLoad 指令设置防抖函数。

directive/scroll/scrollLoad.js 设置防抖
export default {
  /* 原代码 */
  // bind(el, binding, vnode) {
  //   const wrapDom = el.querySelector(".el-autocomplete-suggestion__wrap");
  //   const listDom = el.querySelector(
  //     ".el-autocomplete-suggestion__wrap  .el-autocomplete-suggestion__list"
  //   );
  //   wrapDom.addEventListener(
  //     "scroll",
  //     (e) => {
  //       const condition =
  //         wrapDom.offsetHeight + wrapDom.scrollTop + 10 - listDom.offsetHeight;
  //       // 节流
  //       if (condition > 0 && !vnode.context.loading) {
  //         //滚动到底部则执行滚动方法,binding.value就是v-scrollLoad绑定的值,加()表示执行绑定的方法
  //         binding.value();
  //       }
  //     },
  //     false
  //   );
  // },

  /* 加入防抖:避免滚动到底时,多次触发加载下一页的函数 */
  bind(el, binding, vnode) {
    // 防抖函数
    function debounce(func, wait, immediate) {
      // func 函数,wait 时间周期
      var timeout;
      return function () {
        var that = this,
          args = arguments;
        var later = function () {
          timeout = null;
          if (!immediate) {
            func.apply(that, args);
          }
        };
        var callNow = immediate && !timeout;
        clearTimeout(timeout);
        timeout = setTimeout(later, wait);
        if (callNow) {
          func.apply(that, args);
        }
      };
    }
    const wrapDom = el.querySelector(".el-autocomplete-suggestion__wrap");
    const listDom = el.querySelector(
      ".el-autocomplete-suggestion__wrap  .el-autocomplete-suggestion__list"
    );
    // 将滚动事件通过参数传入防抖函数中
    var myEfficientFn = debounce(function () {
      const condition =
        wrapDom.offsetHeight + wrapDom.scrollTop + 10 - listDom.offsetHeight;
      if (condition > 0 && !vnode.context.loading) {
        //滚动到底部则执行滚动方法,binding.value就是v-scrollLoad绑定的值,加()表示执行绑定的方法
        binding.value();
      }
    }, 250);
    wrapDom.addEventListener("scroll", myEfficientFn, false);
  },
};

效果:

image

参考链接

  1. HTML input autocomplete 属性
  2. vue对el-autocomplete二次封装,增加下拉分页
  3. element-ui 使用autocomplete组件时, 去除掉显示出来的下拉框
  4. 解决 el-autocomplete 不显示及没数据时闪一下的问题
  5. Vue + ElementUI+ el-autocomplete 组件的防抖方案的懒加载

posted on 2023-11-15 11:53  shayloyuki  阅读(450)  评论(0编辑  收藏  举报

导航