Ant-design-vue下拉框组件,实现了 分页操作、模糊搜索、无限滚动、搜索防抖

技术栈

vue3 (3.5.18) + ant-design-vue ( 3.2.20) 实现

主要参考:https://juejin.cn/post/7267114615661920295#heading-8 主要把这位大佬的代码用vue3实现了,具体可以看一下这位大佬的原文

数据准备

创建 data.js 文件:模拟后端数据

const data = [
  { label: '雷进宝', value: 1 },
  { label: '林雅南', value: 2 },
  { label: '小黑', value: 3 },
  { label: '洪振霞', value: 4 },
  { label: '蔡莉华', value: 5 },
  { label: '小蓝', value: 6 },
  { label: '李军', value: 7 },
  { label: '小橙', value: 8 },
  { label: '王军', value: 9 },
  { label: '小黄', value: 10 },
  { label: '小棕', value: 11 },
  { label: '瀚文', value: 12 },
  { label: '赵浩歌', value: 13 },
  { label: '陈浩', value: 14 },
  { label: '安鹏飞', value: 15 },
  { label: '刘茂强', value: 16 },
  { label: '邓海阳', value: 17 },
  { label: '陈婉璇', value: 18 },
  { label: '小粉粉', value: 19 },
  { label: '方一强', value: 20 },
  { label: '茹定', value: 21 },
  { label: '王汝民', value: 22 },
  { label: '郑昌梦', value: 23 },
  { label: '须承文', value: 24 },
];

export function getDataAPI({ pageNum, pageSize, keyword }) {
  return new Promise(resolve => {
    setTimeout(() => {
      const listData = [];
      data.forEach(item => {
        if(keyword) {
          if(item.label.indexOf(keyword) !== -1) {
            listData.push(item);
          }
        } else {
          listData.push(item);
        }
      })
      const start = (pageNum - 1) * pageSize;
      const end = pageNum * pageSize;
      resolve({
        rows: listData.slice(start, end),
        total: listData.length,
      });
    }, 500)
  })
}

基础分页功能

主要用到的组件:

  • Select
    下拉选择框的主组件,本例的核心组件。
  • SelectOption
    Select 的子组件,用来渲染具体的下拉选项。
  • Divider
    分割线,用来把下拉选项和分页器分隔开。
  • Pagination
    分页器,用于下拉面板中的数据分页。

图标组件

  • CloseCircleOutlined
    Ant Design Vue 内置的图标,用来做自定义清空按钮。

其中 我们使用了Select的两个插槽:

  • #clearIcon → 自定义清空图标,默认是个 x,这里换成了 CloseCircleOutlined
  • #dropdownRender → 自定义下拉框内容,在默认菜单 menuNode 后加了 DividerPagination
<template>
  <Select
    class="select"
    v-model:value="selectedValue"
    v-bind="getProps"
    :loading="loading"
    :allowClear="!loading"
    @dropdown-visible-change="dropdownVisibleChange"
  >   <!-- @dropdown-visible-change监听下拉框展开/收起状态。 -->

    <!-- 自定义清除按钮 -->
    <template #clearIcon>
      <CloseCircleOutlined @click="clear" />
    </template>

    <!-- 自定义下拉内容 -->
    <template #dropdownRender="{ menuNode }">
      <!-- 默认的下拉菜单 DOM 结构(也就是正常情况下 <SelectOption> 渲染出来的列表 -->
      <component :is="menuNode" />
      <Divider class="divider" />
      <!-- 
       @mousedown:鼠标按下事件。
       (e) => e.preventDefault():阻止浏览器的默认行为。
       保证点击分页器按钮时,不会触发 Select 下拉框的关闭逻辑
       -->
      <div class="paging-box" @mousedown="(e) => e.preventDefault()">
        <Pagination
          size="small"
          :current="params.pageNum"
          :pageSize="params.pageSize"
          :total="total"
          @change="paginationChange"
        />
      </div>
    </template>

    <!-- menuNode的渲染内容 -->
    <SelectOption
      v-for="item in listData"
      :key="item.value"
      :value="item.label"
    >
      {{ item.label }}
    </SelectOption>
  </Select>
</template>

<script setup>
import { getDataAPI } from './data/data.js';
import { ref, computed, onMounted, useAttrs } from 'vue'
import { Divider, Pagination, Select } from 'ant-design-vue'
import { CloseCircleOutlined } from '@ant-design/icons-vue'
// antV 3.x 写法:需要从 Select 里解构出 Option
const { Option: SelectOption } = Select

// props
const props = defineProps({
  modelValue: {
    type: [Number, String, Array],
    default: undefined
  }
})

// emits
const emit = defineEmits(['update:modelValue'])

// 状态
const loading = ref(false)
const params = ref({
  pageNum: 1,
  pageSize: 5,
  keyword: ''
})
const listData = ref([])
const total = ref(0)

const selectedValue = computed({
  get() {
    return props.modelValue
  },
  set(value) {
    emit('update:modelValue', value)
  }
})

const open = ref(false)

// 默认配置
const DEFAULT_PROPS = {
  mode: undefined, // 3.x 默认单选
  placeholder: '请选择',
  defaultActiveFirstOption: false,
  showArrow: false,
  optionLabelProp: 'lobal',
  showSearch: true,
  filterOption: false
}

const attrs = useAttrs()
const getProps = computed(() => ({
  ...DEFAULT_PROPS,
  ...attrs
}))

// 方法
const paginationChange = (page) => {
  params.value.pageNum = page
  getData()
}

const clear = () => {
  params.value = {
    pageNum: 1,
    pageSize: 5,
    keyword: ''
  }
  selectedValue.value = undefined
  getData()
}

const dropdownVisibleChange = (isOpen) => {
  open.value = isOpen
  if (isOpen) {
    if (loading.value) return
    if (
      listData.value.length !== 0 &&
      params.value.pageNum === 1 &&
      params.value.keyword === ''
    )
      return
    resetData()
  } else {
    params.value.keyword = ''
  }
}

const resetData = () => {
  params.value.pageNum = 1
  params.value.keyword = ''
  getData()
}

const getData = async () => {
  loading.value = true
  try {
    // 请求data
    const res = await getDataAPI(params.value);
    listData.value = res.rows
    total.value = res.total
  } catch (error) {
    console.error('获取数据失败:', error)
  } finally {
    loading.value = false
  }
}

// 生命周期
onMounted(() => {
  getData()
})

// 暴露方法
defineExpose({
  clear,
  resetData
})
</script>

<style scoped>
.select {
  margin: 30px;
  width: 50%;
}
.divider {
  margin: 20px 0;
}
.paging-box {
  display: flex;
  justify-content: flex-end;
  box-sizing: border-box;
  padding: 4px 8px 8px 0;
}
</style>

实现效果:

模糊查询

1. 修改 Select 属性

<Select> 上加:

:show-search="true"
@search="search"
  • show-search 开启搜索输入框
  • @search 绑定搜索事件回调

2. 添加搜索回调函数

<script setup> 里增加一个函数(可以直接写在现有方法里):

const search = (value) => {
  if (!getProps.value.showSearch) return;  // 防御性判断
  if (open.value) {
    params.value.keyword = value || '';
    params.value.pageNum = 1;
    getData();
  }
}
  • value :搜索框输入内容
  • 每次输入都会触发,更新 params.keyword 并重新请求数据
  • 与分页结合:搜索时重置 pageNum = 1

完整代码:

<template>
  <Select
    class="select"
    v-model:value="selectedValue"
    v-bind="getProps"
    :loading="loading"
    :allowClear="!loading"
    :show-search="true"  
    @search="search"
    @dropdown-visible-change="dropdownVisibleChange"
  >   <!-- @dropdown-visible-change监听下拉框展开/收起状态。 -->

    <!-- 自定义清除按钮 -->
    <template #clearIcon>
      <CloseCircleOutlined @click="clear" />
    </template>

    <!-- 自定义下拉内容 -->
    <template #dropdownRender="{ menuNode }">
      <!-- 默认的下拉菜单 DOM 结构(也就是正常情况下 <SelectOption> 渲染出来的列表 -->
      <component :is="menuNode" />
      <Divider class="divider" />
      <!-- 
       @mousedown:鼠标按下事件。
       (e) => e.preventDefault():阻止浏览器的默认行为。
       保证点击分页器按钮时,不会触发 Select 下拉框的关闭逻辑
       -->
      <div class="paging-box" @mousedown="(e) => e.preventDefault()">
        <Pagination
          size="small"
          :current="params.pageNum"
          :pageSize="params.pageSize"
          :total="total"
          @change="paginationChange"
        />
      </div>
    </template>

    <!-- menuNode的渲染内容 -->
    <SelectOption
      v-for="item in listData"
      :key="item.value"
      :value="item.label"
    >
      {{ item.label }}
    </SelectOption>
  </Select>
</template>

<script setup>
import { getDataAPI } from './data/data.js';
import { ref, computed, onMounted, useAttrs } from 'vue'
import { Divider, Pagination, Select } from 'ant-design-vue'
import { CloseCircleOutlined } from '@ant-design/icons-vue'
// antV 3.x 写法:需要从 Select 里解构出 Option
const { Option: SelectOption } = Select

// props
const props = defineProps({
  modelValue: {
    type: [Number, String, Array],
    default: undefined
  }
})

// emits
const emit = defineEmits(['update:modelValue'])

// 状态
const loading = ref(false)
const params = ref({
  pageNum: 1,
  pageSize: 5,
  keyword: ''
})
const listData = ref([])
const total = ref(0)

const selectedValue = computed({
  get() {
    return props.modelValue
  },
  set(value) {
    emit('update:modelValue', value)
  }
})

const open = ref(false)

// 默认配置
const DEFAULT_PROPS = {
  mode: undefined, // 3.x 默认单选
  placeholder: '请选择',
  defaultActiveFirstOption: false,
  showArrow: false,
  optionLabelProp: 'lobal',
  showSearch: true,
  filterOption: false
}

const attrs = useAttrs()
const getProps = computed(() => ({
  ...DEFAULT_PROPS,
  ...attrs
}))

// 方法
    // 搜索
const search = (value) => {
  if (!getProps.value.showSearch) return;  // 防御性判断
  if (open.value) {
    params.value.keyword = value || '';
    params.value.pageNum = 1;
    getData();
  }
}
    // 换页
const paginationChange = (page) => {
  params.value.pageNum = page
  getData()
}
   // 清空 查询参数 + 选中值,重新加载初始数据
const clear = () => {
  params.value = {
    pageNum: 1,
    pageSize: 5,
    keyword: ''
  }
  selectedValue.value = undefined
  getData()
}
    // 下拉框 展开/收起 状态改变时触发
const dropdownVisibleChange = (isOpen) => {
  open.value = isOpen
  if (isOpen) {
    if (loading.value) return
    if (
      listData.value.length !== 0 &&
      params.value.pageNum === 1 &&
      params.value.keyword === ''
    )
      return
    resetData()
  } else {
    params.value.keyword = ''
  }
}
   // 仅数据重新加载,选中状态不变
const resetData = () => {
  params.value.pageNum = 1
  params.value.keyword = ''
  getData()
}
   // 请求数据
const getData = async () => {
  loading.value = true
  try {
    const res = await getDataAPI(params.value);
    listData.value = res.rows
    total.value = res.total
  } catch (error) {
    console.error('获取数据失败:', error)
  } finally {
    loading.value = false
  }
}

// 生命周期
onMounted(() => {
  getData()
})

// 暴露方法api
defineExpose({
  clear,
  resetData
})
</script>

<style scoped>
.select {
  margin: 30px;
  width: 50%;
}
.divider {
  margin: 20px 0;
}
.paging-box {
  display: flex;
  justify-content: flex-end;
  box-sizing: border-box;
  padding: 4px 8px 8px 0;
}
</style>

实现效果:

滚动加载

把 自定义的:下拉框 + 分页加载 ---> 去掉自定义部分

因为antdv中的select自带滚轮效果

本人出错点:

  1. 本人刚开始写的时候 通过 dropdownRender 插槽,自己包了一层 scroll-box,在这个自定义容器上监听 scroll 事件 ,但是导致里面一层自定义滚轮,外面一层select自带的滚轮,形成了双层滚动条。

  2. 后来本人查找资料发现 ant3.x的版本里select下拉框自带滚轮效果,所以删除了插槽里的自定义滚动容器<div class="scroll-container" @scroll="handleScroll">,直接将数据渲染进 dropdownRender 插槽,但发现无法实现滚动效果,下拉框只显示第一页的五个数据。后来发现是因为自定义的 pageSize < SelectOption固定高度 ,导致选项总高度未超容器高度,滚动条没出现,所以看起来只能看到第一页的所有数据。后面把pagesize从5改到10,就实现滚动加载了

1. 结构部分

  1. 监听原生下拉框滚动
  2. 插槽部分放入渲染dom,加载提示
<template>
  <Select
    ...
    @dropdown-visible-change="dropdownVisibleChange"
    @popup-scroll="handleScroll"  <!-- 监听原生下拉框滚动 -->
  >
    <!-- 自定义下拉内容:只保留加载提示,选项让 Select 原生容器渲染 -->
    <template #dropdownRender="{ menuNode }">
      <component :is="menuNode" /> 
      <!-- 加载/无数据提示 -->
      <div v-if="loading" class="loading-tip">加载中...</div>
      <div v-else-if="listData.length >= total && listData.length > 0" class="loading-tip">
        没有更多数据了
      </div>
    </template>
   ...
  </Select>
</template>

2. 操作部分

  1. 更改最大数据量(确保选项总高度超出容器高度)
  2. 滚动监听函数(计算高度差方法,也可以用Intersection Observer API
  3. 请求数据时滚动加载需合并数据
  4. 添加原生选项容器的滚动样式(最大高度,外框高度,垂直/水平滚动设置)
<script setup>
const params = ref({
  pageNum: 1,
  pageSize: 10,   //最大数据量从5改为10
  keyword: ''
  })

  // 监听滚动
  const handleScroll =  (e) => {
  // e.target 就是 Select 原生的选项容器(.ant-select-dropdown-menu)
  const nativeScrollContainer = e.target;

  // 防御判断:加载中、无更多数据时不触发
  if (loading.value || listData.value.length >= total.value) return;

  // 判断是否滚动到底部(5px误差,避免漏触发)
  const isBottom = 
  nativeScrollContainer.scrollTop + nativeScrollContainer.clientHeight 
  >= nativeScrollContainer.scrollHeight - 5;

  if (isBottom) {
  params.value.pageNum += 1;
  getData(true); // 加载下一页
  }
  };
  // 下拉框 展开/收起 状态一改变就触发
  const dropdownVisibleChange = (isOpen) => {
  open.value = isOpen
  if (isOpen) {
  resetData()
  }
  }
  // 请求数据
  const getData = async (isScroll = false) => {
  loading.value = true;
  try {
  const res = await getDataAPI(params.value);
  // 滚动加载合并数据,非滚动加载替换数据
  listData.value = isScroll 
  ? [...listData.value, ...res.rows] 
  : res.rows;
  total.value = res.total;
  } catch (error) {
  console.error('获取数据失败:', error);
  // 错误时回滚页码,避免下次请求异常
  if (isScroll) params.value.pageNum -= 1;
  } finally {
  loading.value = false;
  }
  };
</script>
<style scoped>
 ...
/* !!控制 Select 原生选项容器的滚动 */
::v-deep .ant-select-dropdown-menu {
  max-height: 200px !important; /* 原生容器最大高度,超出自动滚动 */
  overflow-y: auto !important;  /* 垂直滚动开启 */
  overflow-x: hidden !important;/* 禁止水平滚动,避免多余滚动条 */
}

/* 外层下拉框:限制整体高度,避免加载提示被截断 */
::v-deep .ant-select-dropdown {
  max-height: 240px !important; 
  overflow: hidden !important;  /* 禁止外层滚动,避免双层滚动条 */
}

.loading-tip {
  text-align: center;
  padding: 6px;
  color: #999;
  font-size: 12px;
  margin: 0;
}
</style>

完整代码

<template>
  <Select
    class="select"
    v-model:value="selectedValue"
    v-bind="getProps"
    :loading="loading"
    :allowClear="!loading"
    :show-search="true"  
    @search="search"
    @dropdown-visible-change="dropdownVisibleChange"
    @popup-scroll="handleScroll"  
  >   <!-- @dropdown-visible-change监听下拉框展开/收起状态。 -->

    <!-- 自定义清除按钮 -->
    <template #clearIcon>
      <CloseCircleOutlined @click="clear" />
    </template>

    <!-- 自定义下拉内容 -->
    <template #dropdownRender="{ menuNode }">
      <component :is="menuNode" /> <!-- 原生选项容器(自带滚动) -->
      <!-- 加载/无数据提示(跟在选项后面) -->
      <div v-if="loading" class="loading-tip">加载中...</div>
      <div v-else-if="listData.length >= total && listData.length > 0" class="loading-tip">
        没有更多数据了
      </div>
    </template>

    <!-- menuNode的渲染内容 -->
    <SelectOption
      v-for="item in listData"
      :key="item.value"
      :value="item.label"
    >
      {{ item.label }}
    </SelectOption>
  </Select>
</template>

<script setup>
import { getDataAPI } from './data/data.js';
import { ref, computed, onMounted, useAttrs } from 'vue'
import { Divider, Pagination, Select } from 'ant-design-vue'
import { CloseCircleOutlined } from '@ant-design/icons-vue'
// antV 3.x 写法:需要从 Select 里解构出 Option
const { Option: SelectOption } = Select

// props
const props = defineProps({
  modelValue: {
    type: [Number, String, Array],
    default: undefined
  }
})

// emits
const emit = defineEmits(['update:modelValue'])

// 状态
const loading = ref(false)
const params = ref({
  pageNum: 1,
  pageSize: 10,
  keyword: ''
})
const listData = ref([])
const total = ref(0)
const open = ref(false)

const selectedValue = computed({
  get() {
    return props.modelValue
  },
  set(value) {
    emit('update:modelValue', value)
  }
})


// 默认配置
const DEFAULT_PROPS = {
  placeholder: '请选择',
  defaultActiveFirstOption: false,
  showArrow: false,
  optionLabelProp: 'lobal',
  showSearch: true,
  filterOption: false,
  dropdownMatchSelectWidth: false,
}

const attrs = useAttrs()
const getProps = computed(() => ({
  ...DEFAULT_PROPS,
  ...attrs
}))

// 方法
    // 搜索
const search = (value) => {
  if (!getProps.value.showSearch) return;  // 防御性判断
  if (open.value) {
    params.value.keyword = value || '';
    params.value.pageNum = 1;
    getData();
  }
}
   // 监听滚动
const handleScroll =  (e) => {
  // e.target 就是 Select 原生的选项容器(.ant-select-dropdown-menu)
  const nativeScrollContainer = e.target;

  // 防御判断:加载中、无更多数据时不触发
  if (loading.value || listData.value.length >= total.value) return;

  // 判断是否滚动到底部(5px误差,避免漏触发)
  const isBottom = 
    nativeScrollContainer.scrollTop + nativeScrollContainer.clientHeight 
    >= nativeScrollContainer.scrollHeight - 5;

  if (isBottom) {
    params.value.pageNum += 1;
    getData(true); // 加载下一页
  }
};

   // 清空 查询参数 + 选中值,重新加载初始数据
const clear = () => {
  params.value = {
    pageNum: 1,
    pageSize: 10,
    keyword: ''
  }
  selectedValue.value = undefined
  open.value = true
  getData()
}
    // 下拉框 展开/收起 状态改变时触发
const dropdownVisibleChange = (isOpen) => {
  open.value = isOpen
  if (isOpen) {
    resetData()
  }
}

   // 仅数据重新加载,选中状态不变
const resetData = () => {
  params.value.pageNum = 1
  params.value.keyword = ''
  getData()
}
   // 请求数据
const getData = async (isScroll = false) => {
  loading.value = true;
  try {
    const res = await getDataAPI(params.value);
    // 滚动加载合并数据,非滚动加载替换数据
    listData.value = isScroll 
      ? [...listData.value, ...res.rows] 
      : res.rows;
    total.value = res.total;
  } catch (error) {
    console.error('获取数据失败:', error);
    // 错误时回滚页码,避免下次请求异常
    if (isScroll) params.value.pageNum -= 1;
  } finally {
    loading.value = false;
  }
};


// 生命周期
onMounted(() => {
  getData()
})

// 暴露方法api
defineExpose({
  clear,
  resetData
})
</script>

<style scoped>
.select {
  margin: 30px;
  width: 50%;
}

/* !!控制 Select 原生选项容器的滚动 */
::v-deep .ant-select-dropdown-menu {
  max-height: 200px !important; /* 原生容器最大高度,超出自动滚动 */
  overflow-y: auto !important;  /* 垂直滚动开启 */
  overflow-x: hidden !important;/* 禁止水平滚动,避免多余滚动条 */
}

/* 外层下拉框:限制整体高度,避免加载提示被截断 */
::v-deep .ant-select-dropdown {
  max-height: 240px !important; 
  overflow: hidden !important;  /* 禁止外层滚动,避免双层滚动条 */
}

.loading-tip {
  text-align: center;
  padding: 6px;
  color: #999;
  font-size: 12px;
  margin: 0;
}
</style>

3. 用Intersection Observer API 监听滚动

Intersection Observer API 实现滚动加载,核心是监听加载提示元素(哨兵元素)是否进入下拉框可视区域(进入即代表滚动到底部)。

1. 添加哨兵元素

在选项渲染的<SelectOption>后添加一个哨兵div,但是也要用<SelectOption>包起来,因为只有放在这个标签里才能被渲染出来(渲染进下拉框)。这个<div ref="loadMoreRef">作为 Intersection Observer 监听目标

<template>
  删除@popup-scroll="handleScroll" 滚动监听
  ...

    <!-- 选项渲染 -->
    <SelectOption
      v-for="item in listData"
      :key="item.value"
      :value="item.label"
    >
      {{ item.label }}
    </SelectOption>
    
    <!-- 这个SelectOption会被渲染在数据列表的末尾 -->
    <SelectOption key="load_more" :value="load_more" disabled class="load-more-option">
      <div ref="loadMoreRef" class="load-more-sentinel" aria-hidden="true"></div>
    </SelectOption>
  </Select>
  
</template>

2.初始化 Intersection Observer 并监听

  1. 新增 loadMoreRef ref 指向监听元素,定义 observer 变量存储 Observer 实例
import { ref, computed, onMounted, useAttrs, onBeforeUnmount, nextTick, watch } from 'vue'
const loadMoreRef = ref(null)
let observer = null

     2. 删除原 handleScroll 方法,创建Intersection Observer 实例滚动监听方法

    const setupObserver = () => {
      if (!loadMoreRef.value || !open.value) return
    
      // 清除之前的observer
      if (observer) {
        observer.disconnect()
        observer = null
      }
    
      // 动态获取Select组件的下拉菜单容器
      const scrollContainer = document.querySelector('.ant-select-dropdown-menu');
      
      // 创建新的observer,根元素设为滚动容器
      observer = new IntersectionObserver(async (entries) => {
        const entry = entries[0]
        // 当哨兵元素可见且不在加载中且还有数据
        if (entry.isIntersecting && !loading.value && listData.value.length < total.value) {
          params.value.pageNum += 1
          await getData(true)
        }
      }, {
        root: scrollContainer,  //将观察范围限定在下拉菜单内,避免页面滚动干扰
        rootMargin: '0px 0px 50px 0px',  // 底部 50px 预加载区触发
        threshold: 0.01   
      })
    
      observer.observe(loadMoreRef.value)
    }

           3. 下拉框打开时创建Observer ,关闭时清理Observer

      const dropdownVisibleChange = (isOpen) => {
        open.value = isOpen
        if (isOpen) {
          resetData()
          // 当下拉框打开后,确保正确设置observer
          nextTick(() => {
            setupObserver()
          })
        } else {
          // 关闭时清理observer
          if (observer) {
            observer.disconnect()
            observer = null
          }
        }
      }
      

             4.  监听列表数据变化,确保observer正确工作

        watch(listData, () => {
          if (open.value) {
            //使用 nextTick 确保 DOM 更新完成后,再调用 setupObserver() 函数设置 Intersection Observer
            nextTick(() => setupObserver()); 
          }
        })

               5. onMounted 中初始化 Observer,在 onUnmounted 中销毁(避免内存泄漏)

          onBeforeUnmount(() => {
            if (observer && loadMoreRef.value) {
              observer.unobserve(loadMoreRef.value)  //停止观察哨兵元素
              observer.disconnect()   //关闭Intersection Observer实例,释放占用的所有资源
            }
          })

          完整代码:

          <template>
            <Select
              class="select"
              v-model:value="selectedValue"
              v-bind="getProps"
              :loading="loading"
              :allowClear="!loading"
              :show-search="true"  
              @search="search"
              @dropdown-visible-change="dropdownVisibleChange"
            >
              <!-- 自定义清除按钮 -->
              <template #clearIcon>
                <CloseCircleOutlined @click="clear" />
              </template>
          
              <!-- 自定义下拉内容 -->
              <template #dropdownRender="{ menuNode }">
                <component :is="menuNode" /> 
                <div v-if="loading" class="loading-tip">加载中...</div>
                <div v-else-if="listData.length >= total && listData.length > 0" class="loading-tip">
                  没有更多数据了
                </div>
              </template>
          
              <!-- 选项渲染 -->
              <SelectOption
                v-for="item in listData"
                :key="item.value"
                :value="item.label"
              >
                {{ item.label }}
              </SelectOption>
              
              <!-- 这个SelectOption会被渲染在数据列表的末尾 -->
              <SelectOption key="load_more" :value="load_more" disabled class="load-more-option">
                <div ref="loadMoreRef" class="load-more-sentinel" aria-hidden="true"></div>
              </SelectOption>
            </Select>
            
          </template>
          
          <script setup>
          import { getDataAPI } from './data/data.js';
          import { ref, computed, onMounted, useAttrs, onBeforeUnmount, nextTick, watch } from 'vue'
          import { Select } from 'ant-design-vue'
          import { CloseCircleOutlined } from '@ant-design/icons-vue'
          const { Option: SelectOption } = Select
          
          // props
          const props = defineProps({
            modelValue: {
              type: [Number, String, Array],
              default: undefined
            }
          })
          
          // emits
          const emit = defineEmits(['update:modelValue'])
          
          // 状态
          const loading = ref(false)
          const params = ref({
            pageNum: 1,
            pageSize: 10,
            keyword: ''
          })
          const listData = ref([])
          const total = ref(0)
          const open = ref(false)
          const loadMoreRef = ref(null)
          let observer = null
          
          const selectedValue = computed({
            get() {
              return props.modelValue
            },
            set(value) {
              emit('update:modelValue', value)
            }
          })
          
          // 默认配置
          const DEFAULT_PROPS = {
            placeholder: '请选择',
            defaultActiveFirstOption: false,
            showArrow: false,
            optionLabelProp: 'label',
            showSearch: true,
            filterOption: false,
            dropdownMatchSelectWidth: false,
          }
          
          const attrs = useAttrs()
          const getProps = computed(() => ({
            ...DEFAULT_PROPS,
            ...attrs
          }))
          
          // 方法
          const search = (value) => {
            if (!getProps.value.showSearch) return;
            if (open.value) {
              params.value.keyword = value || '';
              params.value.pageNum = 1;
              getData();
            }
          }
          
          // 滚动监听
          const setupObserver = () => {
            if (!loadMoreRef.value || !open.value) return
          
            // 清除之前的observer
            if (observer) {
              observer.disconnect()
              observer = null
            }
          
            // 动态获取Select组件的下拉菜单容器
            const scrollContainer = document.querySelector('.ant-select-dropdown-menu');
            
            // 创建新的observer,根元素设为滚动容器
            observer = new IntersectionObserver(async (entries) => {
              const entry = entries[0]
              // 当哨兵元素可见且不在加载中且还有数据
              if (entry.isIntersecting && !loading.value && listData.value.length < total.value) {
                params.value.pageNum += 1
                await getData(true)
              }
            }, {
              root: scrollContainer,  //将观察范围限定在下拉菜单内,避免页面滚动干扰
              rootMargin: '0px 0px 50px 0px',  // 底部 50px 预加载区触发
              threshold: 0.01   
            })
          
            observer.observe(loadMoreRef.value)
          }
          
          const clear = () => {
            params.value = {
              pageNum: 1,
              pageSize: 10,
              keyword: ''
            }
            selectedValue.value = undefined
            open.value = true
            getData()
          }
          
          const dropdownVisibleChange = (isOpen) => {
            open.value = isOpen
            if (isOpen) {
              resetData()
              // 当下拉框打开后,确保正确设置observer
              nextTick(() => {
                setupObserver()
              })
            } else {
              // 关闭时清理observer
              if (observer) {
                observer.disconnect()
                observer = null
              }
            }
          }
          
          const resetData = () => {
            params.value.pageNum = 1
            params.value.keyword = ''
            getData()
          }
          
          const getData = async (isScroll = false) => {
            // 防止重复请求
            if (loading.value) return;
            
            loading.value = true;
            try {
              const res = await getDataAPI(params.value);
              // 滚动加载合并数据,非滚动加载替换数据
              listData.value = isScroll 
                ? [...listData.value, ...res.rows] 
                : res.rows;
              total.value = res.total;
              
              // 数据加载后重新设置observer,确保正确观察
              if (isScroll) {
                nextTick(() => setupObserver());
              }
            } catch (error) {
              console.error('获取数据失败:', error);
              // 错误时回滚页码
              if (isScroll) params.value.pageNum -= 1;
            } finally {
              loading.value = false;
            }
          };
          
          // 监听列表数据变化,确保observer正确工作
          watch(listData, () => {
            if (open.value) {
              //使用 nextTick 确保 DOM 更新完成后,再调用 setupObserver() 函数设置 Intersection Observer
              nextTick(() => setupObserver()); 
            }
          })
          
          onMounted(() => {
            getData()
          })
          
          //组件即将被卸载和销毁之前
          onBeforeUnmount(() => {
            if (observer && loadMoreRef.value) {
              observer.unobserve(loadMoreRef.value)  //停止观察哨兵元素
              observer.disconnect()   //关闭Intersection Observer实例,释放占用的所有资源
            }
          })
          
          defineExpose({
            clear,
            resetData
          })
          </script>
          
          <style scoped>
          .select {
            margin: 30px;
            width: 50%;
          }
          
          /* 下拉菜单容器的滚动 */
          ::v-deep .ant-select-dropdown-menu {
            max-height: 200px !important;
            overflow-y: auto !important;
            overflow-x: hidden !important;
          }
          
          /* 下拉框 */
          ::v-deep .ant-select-dropdown {
            max-height: 240px !important;
            overflow: hidden !important;
          }
          
          .loading-tip {
            text-align: center;
            padding: 6px;
            color: #999;
            font-size: 12px;
            margin: 0;
          }
          /* 哨兵元素 */
          .load-more-sentinel { 
            height: 16px;       
            width: 100%;
            pointer-events: none; 
          }
          .load-more-option { 
            padding: 0;          
          }
          
          </style>

          实现效果:

          防抖功能

          防抖原理

          1. 通过setTimeout延迟执行搜索操作
          2. 在用户连续输入时,通过clearTimeout取消之前的搜索请求
          3. 只有当用户停止输入一定时间(这里设为300ms)后,才真正执行搜索

          关键修改点

          1. 添加了searchTimeout变量用于存储定时器ID
          2. 定义了DEBOUNCE_TIME常量设置防抖延迟时间
          3. 修改了search函数,添加防抖逻辑和控制台输出
          4. onBeforeUnmount钩子中清理定时器,防止内存泄漏

          1. 所需变量

          // 添加防抖相关变量
          let searchTimeout = null;
          const DEBOUNCE_TIME = 300; // 防抖时间,单位:毫秒

          2. 为search方法添加防抖

          const search = (value) => {
             console.log(`当前输入值: "${value}"`);
            if (!getProps.value.showSearch) return;
            if (!open.value) return;
            
            // 清除之前的定时器
            if (searchTimeout) {
              clearTimeout(searchTimeout);
              searchTimeout = null;
            }
            
            // 设置新的定时器
            searchTimeout = setTimeout(() => {
              console.log(`执行搜索,关键词: "${value || ''}",时间戳: ${Date.now()}`);
              params.value.keyword = value || '';
              params.value.pageNum = 1;
              getData();
              searchTimeout = null;
            }, DEBOUNCE_TIME);
          }

          3. 清除定时器

          //组件销毁时需清除定时器
          onBeforeUnmount(() => {
            if (observer && loadMoreRef.value) {
              observer.unobserve(loadMoreRef.value);
              observer.disconnect();
            }
            
            // 清理搜索防抖定时器
            if (searchTimeout) {
              clearTimeout(searchTimeout);
              searchTimeout = null;
            }
          })

          完整代码:

          <template>
            <Select
              class="select"
              v-model:value="selectedValue"
              v-bind="getProps"
              :loading="loading"
              :allowClear="!loading"
              :show-search="true"  
              @search="search"
              @dropdown-visible-change="dropdownVisibleChange"
            >
              <!-- 自定义清除按钮 -->
              <template #clearIcon>
                <CloseCircleOutlined @click="clear" />
              </template>
          
              <!-- 自定义下拉内容 -->
              <template #dropdownRender="{ menuNode }">
                <component :is="menuNode" /> 
                <div v-if="loading" class="loading-tip">加载中...</div>
                <div v-else-if="listData.length >= total && listData.length > 0" class="loading-tip">
                  没有更多数据了
                </div>
              </template>
          
              <!-- 选项渲染 -->
              <SelectOption
                v-for="item in listData"
                :key="item.value"
                :value="item.label"
              >
                {{ item.label }}
              </SelectOption>
              
              <!-- 这个SelectOption会被渲染在数据列表的末尾 -->
              <SelectOption key="load_more" :value="load_more" disabled class="load-more-option">
                <div ref="loadMoreRef" class="load-more-sentinel" aria-hidden="true"></div>
              </SelectOption>
            </Select>
            
          </template>
          
          <script setup>
          import { getDataAPI } from './data/data.js';
          import { ref, computed, onMounted, useAttrs, onBeforeUnmount, nextTick, watch } from 'vue'
          import { Select } from 'ant-design-vue'
          import { CloseCircleOutlined } from '@ant-design/icons-vue'
          const { Option: SelectOption } = Select
          
          // props
          const props = defineProps({
            modelValue: {
              type: [Number, String, Array],
              default: undefined
            }
          })
          
          // emits
          const emit = defineEmits(['update:modelValue'])
          
          // 状态
          const loading = ref(false)
          const params = ref({
            pageNum: 1,
            pageSize: 10,
            keyword: ''
          })
          const listData = ref([])
          const total = ref(0)
          const open = ref(false)
          const loadMoreRef = ref(null)
          let observer = null
          // 添加防抖相关变量
          let searchTimeout = null;
          const DEBOUNCE_TIME = 300; // 防抖时间,单位:毫秒
          
          const selectedValue = computed({
            get() {
              return props.modelValue
            },
            set(value) {
              emit('update:modelValue', value)
            }
          })
          
          // 默认配置
          const DEFAULT_PROPS = {
            placeholder: '请选择',
            defaultActiveFirstOption: false,
            showArrow: false,
            optionLabelProp: 'label',
            showSearch: true,
            filterOption: false,
            dropdownMatchSelectWidth: false,
          }
          
          const attrs = useAttrs()
          const getProps = computed(() => ({
            ...DEFAULT_PROPS,
            ...attrs
          }))
          
          // 方法
          const search = (value) => {
             console.log(`当前输入值: "${value}"`);
            if (!getProps.value.showSearch) return;
            if (!open.value) return;
            
            // 清除之前的定时器
            if (searchTimeout) {
              clearTimeout(searchTimeout);
              searchTimeout = null;
            }
            
            // 设置新的定时器
            searchTimeout = setTimeout(() => {
              console.log(`执行搜索,关键词: "${value || ''}",时间戳: ${Date.now()}`);
              params.value.keyword = value || '';
              params.value.pageNum = 1;
              getData();
              searchTimeout = null;
            }, DEBOUNCE_TIME);
          }
          
          // 滚动监听
          const setupObserver = () => {
            if (!loadMoreRef.value || !open.value) return
          
            // 清除之前的observer
            if (observer) {
              observer.disconnect()
              observer = null
            }
          
            // 动态获取Select组件的下拉菜单容器
            const scrollContainer = document.querySelector('.ant-select-dropdown-menu');
            
            // 创建新的observer,根元素设为滚动容器
            observer = new IntersectionObserver(async (entries) => {
              const entry = entries[0]
              // 当哨兵元素可见且不在加载中且还有数据
              if (entry.isIntersecting && !loading.value && listData.value.length < total.value) {
                params.value.pageNum += 1
                await getData(true)
              }
            }, {
              root: scrollContainer,  //将观察范围限定在下拉菜单内,避免页面滚动干扰
              rootMargin: '0px 0px 50px 0px',  // 底部 50px 预加载区触发
              threshold: 0.01   
            })
          
            observer.observe(loadMoreRef.value)
          }
          
          const clear = () => {
            params.value = {
              pageNum: 1,
              pageSize: 10,
              keyword: ''
            }
            selectedValue.value = undefined
            open.value = true
            getData()
          }
          
          const dropdownVisibleChange = (isOpen) => {
            open.value = isOpen
            if (isOpen) {
              resetData()
              // 当下拉框打开后,确保正确设置observer
              nextTick(() => {
                setupObserver()
              })
            } else {
              // 关闭时清理observer
              if (observer) {
                observer.disconnect()
                observer = null
              }
            }
          }
          
          const resetData = () => {
            params.value.pageNum = 1
            params.value.keyword = ''
            getData()
          }
          
          const getData = async (isScroll = false) => {
            // 防止重复请求
            if (loading.value) return;
            
            loading.value = true;
            try {
              const res = await getDataAPI(params.value);
              // 滚动加载合并数据,非滚动加载替换数据
              listData.value = isScroll 
                ? [...listData.value, ...res.rows] 
                : res.rows;
              total.value = res.total;
              
              // 数据加载后重新设置observer,确保正确观察
              if (isScroll) {
                nextTick(() => setupObserver());
              }
            } catch (error) {
              console.error('获取数据失败:', error);
              // 错误时回滚页码
              if (isScroll) params.value.pageNum -= 1;
            } finally {
              loading.value = false;
            }
          };
          
          // 监听列表数据变化,确保observer正确工作
          watch(listData, () => {
            if (open.value) {
              //使用 nextTick 确保 DOM 更新完成后,再调用 setupObserver() 函数设置 Intersection Observer
              nextTick(() => setupObserver()); 
            }
          })
          
          onMounted(() => {
            getData()
          })
          
          //组件即将被卸载和销毁之前
          onBeforeUnmount(() => {
            if (observer && loadMoreRef.value) {
              observer.unobserve(loadMoreRef.value);
              observer.disconnect();
            }
            
            // 清理搜索防抖定时器
            if (searchTimeout) {
              clearTimeout(searchTimeout);
              searchTimeout = null;
            }
          })
          
          
          defineExpose({
            clear,
            resetData
          })
          </script>
          
          <style scoped>
          .select {
            margin: 30px;
            width: 50%;
          }
          
          /* 下拉菜单容器的滚动 */
          ::v-deep .ant-select-dropdown-menu {
            max-height: 200px !important;
            overflow-y: auto !important;
            overflow-x: hidden !important;
          }
          
          /* 下拉框 */
          ::v-deep .ant-select-dropdown {
            max-height: 240px !important;
            overflow: hidden !important;
          }
          
          .loading-tip {
            text-align: center;
            padding: 6px;
            color: #999;
            font-size: 12px;
            margin: 0;
          }
          /* 哨兵元素 */
          .load-more-sentinel { 
            height: 16px;       
            width: 100%;
            pointer-events: none; 
          }
          .load-more-option { 
            padding: 0;          
          }
          
          </style>

          实现效果:

          在用户连续输入时,不触发搜索请求,输入暂停300ms后才执行搜索框内关键词的请求。

          Logo

          Agent 垂直技术社区,欢迎活跃、内容共建。

          更多推荐