vue基于el-select组件封装RemoteSelect,支持分页加载,各种场景细节拉满 & blur校验和clearValidate结合使用

2,382 阅读6分钟

一、组件功能

  1. 支持分页加载下拉选项。分页加载用的是自定义指令v-loadmore

动画.gif

  1. 当输入和删除关键词时,按当前最新的关键词进行查询,并且页码回到第一页,滚动条置顶。这里要注意el-selectremote-method没有处理clear的场景,所以我们需要自己添加@clear="remoteMethod('')"

动画.gif

  1. 编辑时支持回显。需要传入name属性,如果给name属性使用了sync修饰符,支持将label绑定给name

动画.gif

  1. 校验:原本使用change触发,但是在输入字符或者是复制粘贴进来的内容,期望的场景是清除校验

问题场景:

动画.gif

实际我们期望的应该是这样:

动画.gif

思路:使用blur事件,但是在改变关键词时手动触发校验

  1. 支持防抖,默认防抖时间为500ms,支持自定义。细节处理:防抖时间内,弹出框收起,防抖需要手动终止
      this.remoteMethodDebounce = debounce(this.remoteMethod, this.time)
  1. 支持传入api,支持自定义字段别名。细节处理:如果接口返回的label和value是一样的,需要特殊处理
          // 为什么要拼接下标?--当value和lable一致时,默认为选中状态,此时再次点击选项则为取消选中
          if (list.length) {
            const firstItem = list[0]
            if (firstItem[fieldAlias.label] === firstItem[fieldAlias.value]) {
              list.forEach((item, index) => {
                item[fieldAlias.value] = item[fieldAlias.value] + '-' + index
              })
            }
          }
  1. 支持用户创建条目,使用el-autocomplete

二、组件封装

RemoteSelect.vue

<template>
  <el-autocomplete
    v-if="allowCreate"
    v-model="keyword"
    v-loadmore:el-autocomplete-suggestion__wrap="loadMore"
    :placeholder="placeholder"
    :clearable="clearable"
    :disabled="disabled"
    :fetch-suggestions="fetchSuggestionsDebounce"
    @select="({ value }) => $emit('input', value)"
    @blur="handleBlur"
    @clear="$emit('input', '')"
  />
  <el-select
    v-else
    v-model="keyword"
    v-loadmore:el-select-dropdown__list="loadMore"
    class="remote-select"
    filterable
    remote
    :clearable="clearable"
    :placeholder="placeholder"
    :loading="loading"
    :disabled="disabled"
    :popper-append-to-body="false"
    :remote-method="remoteMethodDebounce"
    @change="handleChange"
    @focus="handleFocus"
    @clear="remoteMethod('')"
    @visible-change="handleVisibleChange"
  >
    <el-option v-for="item in options" :key="item[fieldAlias.value]" :label="item[fieldAlias.label]" :value="item[fieldAlias.value]" />
  </el-select>
</template>
<script>
import debounce from "./debounce"
export default {
  props: {
    value: { type: String, default: "" },
    api: { type: Function, required: true }, // 接口函数
    name: { type: String, default: "" }, // 编辑时,必传此参数,用于回显
    size: { type: Number, default: 10 }, // 每页条数
    debounceTime: { type: Number, default: 500 }, // 防抖时间
    // 字段别名,如果不传fieldAlias,需要在core中处理下字段。payerListPageApi接口出餐本来就是要处理的,那么建议在core中处理
    fieldAlias: {
      type: Object,
      default: () => ({
        keyword: "keyword",
        current: "current",
        size: "size",
        label: "label",
        value: "value",
      }),
    },
    allowCreate: { type: Boolean, default: false }, // 是否允许用户创建
    placeholder: { type: String, default: "请输入" },
    clearable: { type: Boolean, default: true },
    disabled: { type: Boolean, default: false },
  },
  data() {
    return {
      keyword: "", // 关键字模糊查询
      options: [], // 下拉选项
      loading: false,
      current: 1,
      total: 0,
      flag: true,
    }
  },
  watch: {
    // 由于是分页加载,当编辑时,初次渲染需要将name传递过来,带上关键字查询列表。注意:只有编辑的初次渲染才需要设置name
    value: {
      handler(val) {
        if (this.allowCreate) {
          // el-autocomplete组件回显
          this.keyword = val
        } else {
          // el-select组件回显
          if (this.flag && this.name) {
            this.keyword = this.name
            this.flag = false
          }
        }
      },
    },
  },

  created() {
    const { allowCreate, fetchSuggestions, remoteMethod, debounceTime } = this
    if (allowCreate) {
      this.fetchSuggestionsDebounce = debounce(fetchSuggestions, debounceTime)
    } else {
      this.remoteMethodDebounce = debounce(remoteMethod, debounceTime)
    }
  },
  methods: {
    handleBlur() {
      this.fetchSuggestionsDebounce.cancel()
      this.$emit("input", this.keyword.trim())
    },
    fetchSuggestions(queryString, cb) {
      this.current = 1
      this.total = 0
      this.options = []
      this.getOptions(cb)
    },
    // 选中某一项
    handleChange(val) {
      const { fieldAlias, options } = this
      const item = options.find((n) => n[fieldAlias.value] === val) || {}
      this.$emit("input", item[fieldAlias.value])
      this.$emit("update:name", item[fieldAlias.label]) // 当name属性添加了sync修饰符,这句代码可以将lable绑定给name属性
      this.$emit("change", item) // 当组件使用change事件时,可以获取到当前选项的所有属性
    },
    handleFocus() {
      if (!this.options.length) {
        this.getOptions()
      }
    },
    handleVisibleChange(visible) {
      if (!visible) {
        this.remoteMethodDebounce.cancel() // 在下拉框隐藏时,取消防抖。问题场景:当用户输入关键字时,点击页面其他地方,此时下拉框隐藏,但是防抖还在执行,所以需要手动取消防抖
        // 下拉框隐藏时触发,模拟blur事件;blur事件有bug:当选中一个项目时,继续输入关键字,此时再失焦时不会清空,所以用visible-change事件
        if (this.keyword && !this.value) {
          // 如果当前有关键字,但是没有选中项目,需要清空关键字
          this.remoteMethod("")
        }
      }
    },
    // 加载更多
    loadMore() {
      if (this.total > this.options.length) {
        this.current++
        this.getOptions()
      } else {
        console.log("没有更多了")
      }
    },
    // 当输入框内容发生变化时会触发remoteMethod,但是清空需要手动触发
    remoteMethod(name) {
      this.loading = true
      this.keyword = name || ""
      this.reset() // 当用户输入不同的字符时,意味着需要重新加载下拉,需要将当前数据重置
      this.getOptions()
      if (!this.keyword) {
        this.loading = false
      }
      this.handleChange("")
    },
    // 重置当前页码,总数,下拉,并且将滚动条置顶
    reset() {
      this.current = 1
      this.total = 0
      this.options = []
      const targetDOM = this.$el.querySelector(".remote-select .el-select-dropdown__list")
      targetDOM.scrollTop = 0
      this.$emit("clearValidate") // 如果需要清除校验,可以调用此方法
    },
    async getOptions(cb) {
      try {
        const { fieldAlias, keyword, size, current } = this
        const params = {
          [fieldAlias.keyword]: keyword.trim(),
          [fieldAlias.size]: size,
          [fieldAlias.current]: current,
        }
        const { list = [], total = 0 } = (await this.api(params)) || {}
        if (current === 1) {
          this.options = list
        } else {
          this.options.push(...list)
        }
        this.total = total
        // 给el-autocomplete组件添加下拉数据
        cb && cb(this.options) // options选项必须要提供value值
      } catch (err) {
        console.error(err)
      } finally {
        this.loading = false
      }
    },
  },
  directives: {
    loadmore: {
      bind(el, binding) {
        // 获取element-ui定义好的scroll盒子
        const SELECTWRAP_DOM = el.querySelector(`.${binding.arg}`)
        function handler() {
          const CONDITION = this.scrollHeight - this.scrollTop <= this.clientHeight
          if (CONDITION) {
            binding.value()
          }
        }
        SELECTWRAP_DOM.addEventListener("scroll", handler)
        el.__handler__ = handler
      },
      unbind(el, binding) {
        const SELECTWRAP_DOM = el.querySelector(`.${binding.arg}`)
        if (SELECTWRAP_DOM && el.__handler__) {
          SELECTWRAP_DOM.removeEventListener("scroll", el.__handler__)
          delete el.__handler__
        }
      },
    },
  },
}
</script>
<style scoped>
.el-autocomplete {
  width: 100%;
}
.remote-select {
  width: 100%;
}
.remote-select /deep/ .el-select-dropdown__list {
  height: 300px;
  overflow-y: auto;
  overflow-x: hidden;
}
.remote-select /deep/ .el-select-dropdown__list li {
  max-width: 470px;
}
</style>


这是以前的,不够完美,留着作为参考

<template>
  <el-select
    v-model.trim="keyword"
    v-loadmore:el-select-dropdown__list="loadMore"
    class="remote-select"
    :popper-append-to-body="false"
    filterable
    remote
    reserve-keyword
    clearable
    placeholder="请输入关键字进行查询"
    :remote-method="remoteMethod"
    :loading="loading"
    @change="handleChange"
    @clear="remoteMethod('')"
  >
    <el-option
      v-for="item in categoryDs"
      :key="item.id"
      :label="item.sageCategoryName"
      :value="item.sageCategoryId"
    />
  </el-select>
</template>
<script>
  /*
    主体逻辑:
      1、第一次点击输入框,加载全部下拉,滚动到底部加载下一页
      2、输入框输入关键字时,按关键字查询,滚动加载下一页
      3、关键字进行补充和减少时,按当前最新的关键字进行查询,并且页码回到第一页,滚动加载下一页
      4、点击输入框右侧的清空按钮时,需要手动触发remoteMethod,此时的name是''
      5、编辑时的回显,需要父组件将name传过来,使用name作为关键字进行查询
  */
  import { productCategoryDsApi } from '@/api/product/catesManager'
  export default {
    props: {
      isEdit: { type: Boolean, default: false }, // 是否是编辑,编辑时需要回显
      value: { type: String, default: '' },
      sageCategoryName: { type: String, default: '' }, // 编辑时,必传此参数
    },
    data() {
      return {
        keyword: '', // 关键字模糊查询
        categoryDs: [], // 下拉选项
        loading: false,
        pageLe: 20,
        pageNo: 1,
        total: 0,
        flag: true,
      }
    },
    watch: {
      // 由于是分页加载,当编辑时,初次渲染需要将name传递过来,带上关键字查询列表。注意:只有编辑的初次渲染才需要设置categoryName
      value: {
        handler(val) {
          if (this.flag && this.isEdit) {
            this.keyword = this.sageCategoryName
            this.getOptions()
            this.flag = false
          }
        },
      },
    },
    created() {
      if (!this.isEdit) {
        this.getOptions()
      }
    },
    methods: {
      // 选中某一项
      handleChange(val) {
        const item = this.categoryDs.find((n) => n.sageCategoryId === val) || {}
        this.$emit('input', item.sageCategoryId)
        this.$emit('update:sageCategoryName', item.sageCategoryName)
        this.$emit('change', item)
      },
      // 加载更多
      loadMore() {
        if (this.total > this.categoryDs.length) {
          this.pageNo++
          this.getOptions()
        }
      },
      // 当输入框内容发生变化时会触发remoteMethod,但是清空需要手动触发
      remoteMethod(name) {
        this.loading = true
        this.keyword = name && name.trim()
        this.reset() // 当用户输入不同的字符时,意味着需要重新加载下拉,需要将当前数据重置
        this.getOptions()
        if (!this.keyword) {
          this.loading = false
        }
      },
      // 重置当前页码,总数,下拉,并且将滚动条置顶
      reset() {
        this.pageNo = 1
        this.total = 0
        this.categoryDs = []
        const targetDOM = document.querySelector(
          '.remote-select .el-select-dropdown__list'
        )
        targetDOM.scrollTop = 0
        this.$emit('clearValidate') // 清除校验
      },
      async getOptions() {
        try {
          const { keyword, pageLe, pageNo } = this
          const params = { categoryName: keyword, pageLe, pageNo }
          const { code, data } = await productCategoryDsApi(params)
          this.loading = false
          if (code === '000000' && Array.isArray(data.data)) {
            const list = data.data.map((n) => ({
              ...n,
              sageCategoryId: n.sageCategoryId + '',
              sageCategoryName: n.categoryName,
            }))
            if (pageNo === 1) {
              this.categoryDs = list
            } else {
              this.categoryDs.push(...list)
            }
            this.total = data.total
          }
        } catch (err) {
          console.log(err)
          this.loading = false
        }
      },
    },
  }
</script>
<style lang="scss" scoped>
  .remote-select {
    width: 100%;
    /deep/ .el-select-dropdown__list {
      height: 300px;
      overflow-y: auto;
      overflow-x: hidden;
      li {
        max-width: 470px;
      }
    }
  }
</style>

三、使用

            <el-form-item  label="付款方" prop="payer">
              <WewooRemoteSelect
                v-model="queryForm.payer"
                :name="queryForm.payer"
                :api="TicketManagementInteractor.payerListPageApi"
                :time="1800"
                allowCreate
              />
            </el-form-item>
  1. time防抖时间
  2. allowCreate支持用户创建条目
            <el-form-item label="SAGE 平台类目" prop="sageCategoryId">
              <RemoteSelect
                v-model="form.sageCategoryId"
                :name.sync="form.sageCategoryName"
                :api="PlatformProductsInteractor.sageCategoryListPageApi"
                :fieldAlias="{
                  keyword: 'categoryName',
                  current: 'pageNo',
                  size: 'pageLe',
                  label: 'categoryName',
                  value: 'sageCategoryId'
                }"
                @clearValidate="clearValidate"
              />
            </el-form-item>
  1. v-model用于绑定所选中的id值
  2. name用于回显时,带参查询列表,加上sync修饰符,可以快速将lable绑定给name
  3. api是后端提供的接口,如果接口的入参和出参字段和组件内不一致,支持使用fieldAlias自定义
  4. clearValidate事件用于在输入或删除关键词时手动清除平台类目的校验
        rules: {
          sageCategoryId: [
            { required: true, message: '请选择平台类目', trigger: 'blur' }, // 这里不要用change事件
          ],
        }
      clearValidate() {
        this.$refs.form.clearValidate('sageCategoryId')
      }

因为使用了blur,这里我需要使用watch,实时地添加校验或去掉校验

    watch: {
      'form.sageCategoryId': {
        handler(val) {
          if (val) {
            this.$refs.form.clearValidate('sageCategoryId')
          } else {
            this.$refs.form.validateField('sageCategoryId')
          }
        },
      },
    },

因为这个校验,在父组件中多写了一个clearValidate方法和watch监听,用起来很麻烦,能不能把这个逻辑封装到组件中,大神看到了解答一下,多谢!

四、blur校验和clearValidate结合使用

场景:当选中选项时,不应该出现提示信息

动画.gif

原因:使用了change事件

image.png

解决:使用blur事件,并结合clearValidate

image.png

image.png

效果:

动画.gif

如果你觉得这篇文章对你有用,可以看看作者封装的库xtt-utils,里面封装了非常实用的js方法。如果你也是vue开发者,那更好了,除了常用的api,还有大量的基于element-ui组件库二次封装的使用方法和自定义指令等,帮你提升开发效率。不定期更新,欢迎交流~