vue3+Ant-design-vue的 搜索下拉框(分页操作,模糊查询,Intersection Observer API无限滚动,搜索防抖)
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
下拉选择框的主组件,本例的核心组件。SelectOptionSelect的子组件,用来渲染具体的下拉选项。Divider
分割线,用来把下拉选项和分页器分隔开。Pagination
分页器,用于下拉面板中的数据分页。
图标组件:
CloseCircleOutlined
Ant Design Vue 内置的图标,用来做自定义清空按钮。
其中 我们使用了Select的两个插槽:
#clearIcon→ 自定义清空图标,默认是个x,这里换成了CloseCircleOutlined。#dropdownRender→ 自定义下拉框内容,在默认菜单menuNode后加了Divider和Pagination。
<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自带滚轮效果
本人出错点:
本人刚开始写的时候 通过
dropdownRender插槽,自己包了一层scroll-box,在这个自定义容器上监听scroll事件 ,但是导致里面一层自定义滚轮,外面一层select自带的滚轮,形成了双层滚动条。后来本人查找资料发现 ant3.x的版本里
select下拉框自带滚轮效果,所以删除了插槽里的自定义滚动容器<div class="scroll-container" @scroll="handleScroll">,直接将数据渲染进dropdownRender插槽,但发现无法实现滚动效果,下拉框只显示第一页的五个数据。后来发现是因为自定义的 pageSize <SelectOption固定高度 ,导致选项总高度未超容器高度,滚动条没出现,所以看起来只能看到第一页的所有数据。后面把pagesize从5改到10,就实现滚动加载了
1. 结构部分
- 监听原生下拉框滚动
- 插槽部分放入渲染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. 操作部分
- 更改最大数据量(确保选项总高度超出容器高度)
- 滚动监听函数(计算高度差方法,也可以用Intersection Observer API)
- 请求数据时滚动加载需合并数据
- 添加原生选项容器的滚动样式(最大高度,外框高度,垂直/水平滚动设置)
<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 并监听
- 新增
loadMoreRefref 指向监听元素,定义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>
实现效果:

防抖功能
防抖原理:
- 通过
setTimeout延迟执行搜索操作 - 在用户连续输入时,通过
clearTimeout取消之前的搜索请求 - 只有当用户停止输入一定时间(这里设为300ms)后,才真正执行搜索
关键修改点:
- 添加了
searchTimeout变量用于存储定时器ID - 定义了
DEBOUNCE_TIME常量设置防抖延迟时间 - 修改了
search函数,添加防抖逻辑和控制台输出 - 在
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后才执行搜索框内关键词的请求。

更多推荐
所有评论(0)