Vue3 + ElementPlus 2.4.x 自定义指令:解决el-select滚动加载3大坑点
在Vue3项目中,ElementPlus的el-select组件因其丰富的功能和良好的兼容性,成为开发者处理下拉选择需求的首选。然而,当面对大数据量场景时,直接一次性渲染所有选项会导致严重的性能问题。滚动加载(Infinite Scroll)是解决这一问题的有效方案,但在实际开发中,我们往往会遇到几个棘手的坑点。
1. Teleport机制带来的DOM结构变化
ElementPlus 2.4.x版本中,el-select的下拉菜单默认通过Teleport挂载到body元素下。这一变化导致传统的DOM查询方式失效,我们需要调整自定义指令的实现逻辑。
export default {
beforeMount(el, binding) {
// 使用popper-class作为查询选择器
const selectWrap = document.querySelector(
`.${binding.arg} .el-select-dropdown__wrap`
)
const scrollHandler = function() {
const isBottom = this.scrollHeight - this.scrollTop <= this.clientHeight + 5
if (isBottom) {
binding.value()
}
}
selectWrap?.addEventListener('scroll', scrollHandler)
el._scrollHandler = scrollHandler
el._selectWrap = selectWrap
},
beforeUnmount(el) {
el._selectWrap?.removeEventListener('scroll', el._scrollHandler)
}
}
关键调整点:
-
不再直接查询
.el-select-dropdown__wrap,而是通过传入的popper-class定位 - 添加5px的容差阈值,避免精确匹配导致的触发不灵敏
- 将DOM引用和事件处理器挂载到el上,便于卸载时清理
提示:建议为每个el-select设置唯一的popper-class,避免多个下拉框互相干扰。
2. 事件监听与内存泄漏防范
滚动加载的核心是监听滚动事件,但如果处理不当,很容易造成内存泄漏。以下是常见的三种问题场景及解决方案:
2.1 事件未正确移除
// 错误示例:直接添加匿名函数,无法移除
selectWrap.addEventListener('scroll', function() {
// ...
})
// 正确做法:使用具名函数引用
const handler = function() { /*...*/ }
selectWrap.addEventListener('scroll', handler)
// 卸载时
selectWrap.removeEventListener('scroll', handler)
2.2 组件卸载时未清理
{
beforeUnmount(el) {
// 必须检查元素是否存在
if (el._selectWrap && el._scrollHandler) {
el._selectWrap.removeEventListener('scroll', el._scrollHandler)
}
// 清除引用
delete el._selectWrap
delete el._scrollHandler
}
}
2.3 动态popper-class处理
当popper-class是动态生成时,需要特殊处理:
<template>
<el-select
:popper-class="`select-${uniqueId}`"
v-select-loadmore:[`select-${uniqueId}`]="loadMore"
>
<!-- options -->
</el-select>
</template>
<script setup>
import { nanoid } from 'nanoid'
const uniqueId = nanoid()
</script>
3. 性能优化与用户体验提升
单纯的滚动加载实现可能还不够,我们需要考虑以下优化点:
3.1 防抖处理
import { debounce } from 'lodash-es'
const scrollHandler = debounce(function() {
const isBottom = this.scrollHeight - this.scrollTop <= this.clientHeight + 5
if (isBottom && !loading.value) {
binding.value()
}
}, 200, { leading: true, trailing: true })
参数说明:
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| leading | boolean | false | 是否在延迟开始前调用 |
| trailing | boolean | true | 是否在延迟结束后调用 |
| maxWait | number | - | 最大等待时间 |
3.2 加载状态反馈
<template>
<el-select v-select-loadmore="loadMore">
<el-option v-for="item in options" :value="item.value" :label="item.label" />
<template #footer>
<div v-if="loading" class="loading-more">
<el-icon class="is-loading"><Loading /></el-icon>
加载中...
</div>
<div v-else-if="noMore" class="no-more">没有更多了</div>
</template>
</el-select>
</template>
<style>
.loading-more, .no-more {
padding: 8px 0;
text-align: center;
color: var(--el-text-color-secondary);
font-size: 12px;
}
</style>
3.3 数据分页策略
推荐的后端接口参数设计:
const loadMore = async () => {
if (loading.value || noMore.value) return
loading.value = true
try {
const res = await api.getList({
page: page.value + 1,
pageSize: 20,
keyword: searchKeyword.value
})
if (res.data.length) {
options.value.push(...res.data)
page.value++
} else {
noMore.value = true
}
} finally {
loading.value = false
}
}
分页参数对比:
| 分页方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 页码分页 | 实现简单 | 数据新增时可能重复 | 数据变动不频繁 |
| 游标分页 | 避免重复 | 实现复杂 | 实时数据流 |
| 时间分页 | 自然排序 | 依赖时间字段 | 时间序列数据 |
4. 完整实现与最佳实践
下面是一个生产环境可用的完整实现方案:
4.1 自定义指令封装
// directives/selectLoadmore.js
import { debounce } from 'lodash-es'
export default {
beforeMount(el, binding) {
const { arg: popperClass, value: callback } = binding
if (typeof callback !== 'function') {
console.warn('[v-select-loadmore] 必须传入函数')
return
}
const selectWrap = document.querySelector(
`.${popperClass} .el-select-dropdown__wrap`
)
if (!selectWrap) {
console.warn(`[v-select-loadmore] 未找到下拉框容器,请检查popper-class: ${popperClass}`)
return
}
const checkBottom = function() {
const { scrollHeight, scrollTop, clientHeight } = this
return scrollHeight - scrollTop <= clientHeight + 5
}
const scrollHandler = debounce(function() {
if (checkBottom.call(this)) {
callback()
}
}, 200)
selectWrap.addEventListener('scroll', scrollHandler)
// 存储引用以便清理
el._selectLoadmore = {
selectWrap,
scrollHandler
}
},
beforeUnmount(el) {
const { selectWrap, scrollHandler } = el._selectLoadmore || {}
if (selectWrap && scrollHandler) {
selectWrap.removeEventListener('scroll', scrollHandler)
}
delete el._selectLoadmore
}
}
4.2 全局注册
// main.js
import { createApp } from 'vue'
import App from './App.vue'
import selectLoadmore from './directives/selectLoadmore'
const app = createApp(App)
app.directive('select-loadmore', selectLoadmore)
app.mount('#app')
4.3 组件使用示例
<template>
<el-select
v-model="selectedValue"
filterable
remote
:remote-method="handleSearch"
:popper-class="selectPopperClass"
v-select-loadmore:[selectPopperClass]="loadMore"
@visible-change="handleVisibleChange"
>
<el-option
v-for="item in options"
:key="item.value"
:label="item.label"
:value="item.value"
/>
<template #footer>
<div v-if="loading" class="select-footer">
<el-icon class="is-loading"><Loading /></el-icon>
<span>加载中...</span>
</div>
<div v-else-if="isEnd" class="select-footer">没有更多数据了</div>
</template>
</el-select>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { nanoid } from 'nanoid'
const selectPopperClass = `select-${nanoid()}`
const selectedValue = ref('')
const options = ref([])
const loading = ref(false)
const isEnd = ref(false)
const pageInfo = ref({
page: 1,
pageSize: 20,
keyword: ''
})
const fetchData = async (reset = false) => {
if (loading.value) return
loading.value = true
try {
const res = await api.getData({
...pageInfo.value,
page: reset ? 1 : pageInfo.value.page
})
if (reset) {
options.value = res.data
isEnd.value = false
} else {
options.value.push(...res.data)
}
if (res.data.length < pageInfo.value.pageSize) {
isEnd.value = true
} else if (!reset) {
pageInfo.value.page++
}
} finally {
loading.value = false
}
}
const loadMore = () => {
if (!isEnd.value && !loading.value) {
fetchData()
}
}
const handleSearch = (keyword) => {
pageInfo.value.keyword = keyword
fetchData(true)
}
const handleVisibleChange = (visible) => {
if (!visible) {
// 重置状态
pageInfo.value.page = 1
isEnd.value = false
}
}
onMounted(() => {
fetchData(true)
})
</script>
<style>
.select-footer {
padding: 8px 16px;
text-align: center;
color: var(--el-text-color-secondary);
font-size: 12px;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
}
</style>
在实际项目中,我们还需要考虑以下细节:
- 网络错误时的重试机制
- 空数据状态展示
- 移动端触摸事件优化
- 多选模式下的特殊处理



1918

被折叠的 条评论
为什么被折叠?



