v-elSelectLoadMore 指令技术文档
📖 概述
v-elSelectLoadMore 是一个专为 Element Plus 的 el-select 组件设计的 Vue 3 自定义指令,用于实现**下拉选项的滚动加载(无限滚动)**功能。该指令解决了 el-select 组件原生不支持分页加载的痛点,特别适用于需要展示大量选项数据的场景。
核心特性
- ✅ 零侵入设计:通过指令方式实现,不需要修改组件内部逻辑
- ✅ 高性能:内置防抖机制,避免频繁触发
- ✅ 类型安全:完整的 TypeScript 类型定义
- ✅ 灵活配置:支持自定义触发距离和 loading 效果
- ✅ 内存安全:完善的清理机制,防止内存泄漏
- ✅ 精准定位:通过
popperClass精确匹配下拉框 - ✅ 智能 Loading:自动显示加载动画,支持 Promise 自动隐藏
🎯 适用场景
典型应用场景
-
大数据量下拉选择
- 用户列表(成千上万的用户)
- 商品列表
- 城市/地区选择
-
远程搜索 + 分页
- 搜索关键词后,结果分页加载
- 减少首次加载时间
-
实时数据流
- 日志选择
- 消息列表
不适用场景
- 选项数量少于 50 条(建议一次性加载)
- 需要复杂的树形结构(建议使用
el-tree-select)
🚀 快速开始
1. 指令注册
在应用入口文件中注册指令:
// main.ts
import { createApp } from 'vue'
import App from './App.vue'
import elSelectLoadMore from '@common/directives/elSelectLoadMore'
const app = createApp(App)
// 注册指令
app.directive('elSelectLoadMore', elSelectLoadMore)
app.mount('#app')
2. 基础用法
<template>
<el-select
v-model="selectedValue"
v-elSelectLoadMore="{
loadMore: handleLoadMore,
popperClass: uniqueClass
}"
:popper-class="uniqueClass"
placeholder="请选择"
>
<el-option
v-for="item in options"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { v4 as uuidv4 } from 'uuid'
const selectedValue = ref('')
const options = ref([])
const uniqueClass = `my-select-${uuidv4()}`
const handleLoadMore = () => {
// 加载下一页数据的逻辑
console.log('加载更多...')
}
</script>
📚 API 文档
指令值类型
{
popperClass: string // 必填:下拉框的唯一标识类名
loadMore: () => void | Promise<unknown> // 必填:触底时的回调函数,可返回 Promise
distance?: number // 可选:触发距离(像素),默认 20
showLoading?: boolean // 可选:是否显示 loading 动画,默认 true
}
参数详解
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|---|---|---|---|---|
popperClass | string | ✅ | - | 下拉框的唯一标识类名,用于精确定位 |
loadMore | () => void | Promise<unknown> | ✅ | - | 滚动到底部时触发的回调函数,可返回 Promise |
distance | number | ❌ | 20 | 距离底部多少像素时触发加载(单位:px) |
showLoading | boolean | ❌ | true | 是否显示加载动画(loading 图标 + "加载中..."文字) |
重要说明
⚠️ popperClass 必须与 el-select 的 popper-class 属性保持一致!
<!-- ✅ 正确 -->
<el-select
v-elSelectLoadMore="{ loadMore, popperClass: 'my-unique-class' }"
:popper-class="'my-unique-class'"
>
<!-- ❌ 错误:不一致 -->
<el-select
v-elSelectLoadMore="{ loadMore, popperClass: 'class-a' }"
:popper-class="'class-b'"
>
💡 完整示例
示例 1:基础分页加载
<template>
<div class="example-container">
<el-select
v-model="selectedUser"
v-elSelectLoadMore="{
loadMore: loadMoreUsers,
popperClass: userSelectClass
}"
:popper-class="userSelectClass"
:loading="loading"
filterable
remote
placeholder="请选择用户"
>
<el-option
v-for="user in users"
:key="user.id"
:label="user.name"
:value="user.id"
/>
</el-select>
<p>已加载: {{ users.length }} / {{ totalUsers }}</p>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { v4 as uuidv4 } from 'uuid'
interface User {
id: string
name: string
}
const selectedUser = ref('')
const loading = ref(false)
const users = ref<User[]>([])
const userSelectClass = `user-select-${uuidv4()}`
const pagination = reactive({
pageNo: 1,
pageSize: 20,
totalUsers: 0
})
// 初始化:加载第一页
const initUsers = async () => {
loading.value = true
try {
const response = await fetchUsers(1, pagination.pageSize)
users.value = response.data
pagination.totalUsers = response.total
} finally {
loading.value = false
}
}
// 加载更多(返回 Promise,指令会自动处理 loading 动画)
const loadMoreUsers = async () => {
// 防止重复加载
if (loading.value) return
// 检查是否还有更多数据
const hasMore = users.value.length < pagination.totalUsers
if (!hasMore) return
loading.value = true
pagination.pageNo++
try {
const response = await fetchUsers(pagination.pageNo, pagination.pageSize)
users.value.push(...response.data)
} catch (error) {
console.error('加载失败:', error)
pagination.pageNo-- // 回滚页码
} finally {
loading.value = false
}
}
// 模拟 API 请求
const fetchUsers = (page: number, size: number) => {
return new Promise<{ data: User[], total: number }>((resolve) => {
setTimeout(() => {
const start = (page - 1) * size
const data = Array.from({ length: size }, (_, i) => ({
id: `user-${start + i + 1}`,
name: `用户 ${start + i + 1}`
}))
resolve({ data, total: 100 })
}, 500)
})
}
// 组件挂载时初始化
initUsers()
</script>
示例 2:自定义触发距离和禁用 Loading
<template>
<el-select
v-model="value"
v-elSelectLoadMore="{
loadMore: loadMore,
popperClass: selectClass,
distance: 50, // 距离底部 50px 时触发
showLoading: false // 禁用内置 loading 动画
}"
:popper-class="selectClass"
>
<el-option
v-for="item in options"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { v4 as uuidv4 } from 'uuid'
const value = ref('')
const options = ref([])
const selectClass = `select-${uuidv4()}`
const loadMore = () => {
// 提前 50px 触发,用户体验更流畅
// 禁用了内置 loading,可以自定义 loading 效果
console.log('提前触发加载')
return new Promise<void>((resolve) => {
setTimeout(() => {
// 加载数据...
resolve()
}, 1000)
})
}
</script>
示例 3:结合远程搜索
<template>
<el-select
v-model="selectedProduct"
v-elSelectLoadMore="{
loadMore: loadMoreProducts,
popperClass: productSelectClass
}"
:popper-class="productSelectClass"
:loading="loading"
filterable
remote
:remote-method="handleSearch"
placeholder="搜索商品"
>
<el-option
v-for="product in products"
:key="product.id"
:label="product.name"
:value="product.id"
/>
</el-select>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { v4 as uuidv4 } from 'uuid'
import { useDebounceFn } from '@vueuse/core'
const selectedProduct = ref('')
const loading = ref(false)
const products = ref([])
const productSelectClass = `product-select-${uuidv4()}`
let currentPage = 1
let searchKeyword = ''
// 搜索处理(防抖)
const handleSearch = useDebounceFn((query: string) => {
searchKeyword = query
currentPage = 1
products.value = []
loadProducts()
}, 300)
// 加载商品
const loadProducts = async () => {
if (loading.value) return
loading.value = true
try {
const response = await fetchProducts(searchKeyword, currentPage)
if (currentPage === 1) {
products.value = response.data
} else {
products.value.push(...response.data)
}
} finally {
loading.value = false
}
}
// 加载更多
const loadMoreProducts = () => {
currentPage++
loadProducts()
}
const fetchProducts = (keyword: string, page: number) => {
// API 调用逻辑
return Promise.resolve({ data: [] })
}
</script>
完整源码
/**
* v-loadMore 指令
* 用于 el-select 组件的触底加载更多功能
*/
import type { Directive } from 'vue'
import { useDebounceFn } from '@vueuse/core'
import { createVNode, render } from 'vue'
import { Loading } from '@element-plus/icons-vue'
// 扩展 HTMLElement 类型,添加自定义属性
interface ExtendedHTMLElement extends HTMLElement {
_scrollLoadCleanup?: () => void
}
// 指令值类型定义
type ElSelectLoadMoreValue = {
popperClass: string // 必填样式表 唯一标识
loadMore: () => void | Promise<unknown> // 加载更多的回调函数,可以返回 Promise
distance?: number // 距离底部多少像素触发,默认 20
showLoading?: boolean // 是否显示 loading 图标,默认 true
}
const elSelectLoadMore: Directive<ExtendedHTMLElement, ElSelectLoadMoreValue> = {
mounted(el, binding) {
// 标准化 binding.value,支持函数和对象两种形式
const loadMoreFn = binding.value?.loadMore
const distance = binding.value?.distance ?? 20
const popperClass = binding.value?.popperClass ?? ''
const showLoading = binding.value?.showLoading ?? true
let dropdownWrap: HTMLElement | null = findDropdown()
let loadingElement: HTMLElement | null = null
let isLoading = false
dropdownWrap?.addEventListener('scroll', useDebounceFn(handleScroll, 300))
/**
* 显示 loading 效果
*/
function showLoadingIcon() {
if (!showLoading || !dropdownWrap || loadingElement) return
// 创建 loading 容器
const loadingContainer = document.createElement('div')
loadingContainer.className = 'el-select-dropdown__loading'
loadingContainer.style.cssText = `
display: flex;
justify-content: center;
align-items: center;
padding: 8px 0;
color: var(--el-color-primary);
font-size: 14px;
`
// 创建 loading 图标
const loadingIcon = document.createElement('i')
loadingIcon.className = 'el-icon is-loading'
loadingIcon.style.marginRight = '8px'
// 使用 Vue 的 createVNode 和 render 来渲染 Loading 组件
const vnode = createVNode(Loading)
render(vnode, loadingIcon)
// 创建文本
const loadingText = document.createElement('span')
loadingText.textContent = '加载中...'
loadingContainer.appendChild(loadingIcon)
loadingContainer.appendChild(loadingText)
// 添加到下拉框底部
const dropdown = dropdownWrap.parentElement
if (dropdown) {
dropdown.appendChild(loadingContainer)
loadingElement = loadingContainer
}
}
/**
* 隐藏 loading 效果
*/
function hideLoadingIcon() {
if (loadingElement) {
loadingElement.remove()
loadingElement = null
}
isLoading = false
}
/**
* 滚动事件处理函数
* 当滚动到距离底部指定距离时触发加载更多
*/
function handleScroll() {
if (!dropdownWrap || isLoading) return
const { scrollTop, scrollHeight, clientHeight } = dropdownWrap
const distanceToBottom = scrollHeight - scrollTop - clientHeight
// 当距离底部小于等于设定距离时触发加载
if (distanceToBottom <= distance) {
isLoading = true
showLoadingIcon()
// 执行加载函数
const result = loadMoreFn?.()
// 如果返回 Promise,等待完成后隐藏 loading
if (result && typeof result === 'object' && 'then' in result) {
(result as Promise<unknown>).finally(() => {
hideLoadingIcon()
})
} else {
// 如果不是 Promise,延迟隐藏(给用户反馈)
setTimeout(() => {
hideLoadingIcon()
}, 500)
}
}
}
/**
* 查找下拉框的滚动容器
* 优先通过 popper-class 精确查找,备用方案是查找最新的下拉框
*/
function findDropdown(): HTMLElement | null {
if (popperClass) {
const classes = popperClass.split(' ')
for (const cls of classes) {
if (cls) {
const dropdown = document.querySelector(`.${cls}`)
if (dropdown) {
const wrap = dropdown.querySelector('.el-select-dropdown__wrap') as HTMLElement
if (wrap) {
return wrap
}
}
}
}
}
return null
}
/**
* 清理函数
* 移除所有事件监听器和定时器,防止内存泄漏
*/
el._scrollLoadCleanup = () => {
hideLoadingIcon()
if (dropdownWrap) {
dropdownWrap.removeEventListener('scroll', handleScroll)
dropdownWrap = null
}
}
},
/**
* 组件卸载时清理资源
*/
unmounted(el) {
if (el._scrollLoadCleanup) {
el._scrollLoadCleanup()
delete el._scrollLoadCleanup
}
}
}
export default elSelectLoadMore
🎨 Loading 效果说明
自动 Loading 管理
指令内置了智能的 loading 效果管理机制:
1. Promise 自动管理(推荐)
当 loadMore 函数返回 Promise 时,指令会:
- ✅ 自动显示 loading 动画
- ✅ 等待 Promise 完成后自动隐藏
- ✅ 处理 Promise 失败的情况
const loadMore = async () => {
// 返回 Promise,指令自动管理 loading
const response = await fetchData()
options.value.push(...response.data)
}
2. 非 Promise 延迟隐藏
如果 loadMore 函数不返回 Promise:
- ✅ 显示 loading 动画
- ✅ 500ms 后自动隐藏(给用户反馈)
const loadMore = () => {
// 不返回 Promise,loading 会在 500ms 后自动隐藏
fetchData().then(data => {
options.value.push(...data)
})
}
3. 禁用 Loading 动画
如果你想使用自定义的 loading 效果:
<el-select
v-elSelectLoadMore="{
loadMore: loadMore,
popperClass: selectClass,
showLoading: false // 禁用内置 loading
}"
:popper-class="selectClass"
:loading="customLoading" // 使用 el-select 自带的 loading
>
Loading 样式
内置的 loading 效果包含:
- 🔄 旋转的加载图标(Element Plus Loading 组件)
- 📝 "加载中..." 文字提示
- 🎨 使用 Element Plus 主题色
样式定位在下拉框底部,不会遮挡选项内容。
🔧 技术实现原理
1. 核心流程
┌─────────────────┐
│ 指令 mounted │
└────────┬────────┘
│
▼
┌─────────────────┐
│ 解析指令参数 │
│ - loadMore 函数 │
│ - distance 距离 │
│ - popperClass │
│ - showLoading │
└────────┬────────┘
│
▼
┌─────────────────┐
│ 查找下拉框容器 │
│ findDropdown() │
└────────┬────────┘
│
▼
┌─────────────────┐
│ 绑定滚动事件 │
│ + 防抖处理 │
└────────┬────────┘
│
▼
┌─────────────────┐
│ 监听滚动位置 │
│ handleScroll() │
└────────┬────────┘
│
▼
┌────┴────┐
│ 距离判断 │
└────┬────┘
│
┌────┴────┐
│ <= 阈值? │
└────┬────┘
│ Yes
▼
┌─────────────────┐
│ 显示 Loading │
│showLoadingIcon()│
└────────┬────────┘
│
▼
┌─────────────────┐
│ 触发 loadMore() │
└────────┬────────┘
│
▼
┌────┴────┐
│返回Promise?│
└────┬────┘
│
┌────┴────┐
│ Yes │ No (500ms后)
└────┬────┴────┐
│ │
▼ ▼
┌─────────────────┐
│ 隐藏 Loading │
│hideLoadingIcon()│
└─────────────────┘
2. 关键代码解析
2.1 查找下拉框容器
function findDropdown(): HTMLElement | null {
if (popperClass) {
// 支持多个 class(空格分隔)
const classes = popperClass.split(' ')
for (const cls of classes) {
if (cls) {
// 通过 popperClass 精确定位
const dropdown = document.querySelector(`.${cls}`)
if (dropdown) {
// 找到滚动容器
const wrap = dropdown.querySelector('.el-select-dropdown__wrap') as HTMLElement
if (wrap) {
return wrap
}
}
}
}
}
return null
}
为什么需要 popperClass?
Element Plus 的 el-select 下拉框是通过 Teleport 渲染到 body 下的,不在组件的 DOM 树中。如果页面上有多个 el-select,无法准确判断滚动事件来自哪个下拉框。通过唯一的 popperClass,可以精确定位到当前 select 对应的下拉框。
2.2 滚动距离计算
function handleScroll() {
if (!dropdownWrap) return
const { scrollTop, scrollHeight, clientHeight } = dropdownWrap
const distanceToBottom = scrollHeight - scrollTop - clientHeight
// 当距离底部小于等于设定距离时触发加载
if (distanceToBottom <= distance) {
loadMoreFn()
}
}
计算公式:
distanceToBottom = scrollHeight - scrollTop - clientHeight
其中:
- scrollHeight: 内容总高度
- scrollTop: 已滚动的距离
- clientHeight: 可视区域高度
- distanceToBottom: 距离底部的距离
2.3 Loading 效果实现
/**
* 显示 loading 效果
*/
function showLoadingIcon() {
if (!showLoading || !dropdownWrap || loadingElement) return
// 创建 loading 容器
const loadingContainer = document.createElement('div')
loadingContainer.className = 'el-select-dropdown__loading'
loadingContainer.style.cssText = `
display: flex;
justify-content: center;
align-items: center;
padding: 8px 0;
color: var(--el-color-primary);
font-size: 14px;
`
// 创建 loading 图标(使用 Element Plus 的 Loading 组件)
const loadingIcon = document.createElement('i')
loadingIcon.className = 'el-icon is-loading'
const vnode = createVNode(Loading)
render(vnode, loadingIcon)
// 添加文本
const loadingText = document.createElement('span')
loadingText.textContent = '加载中...'
loadingContainer.appendChild(loadingIcon)
loadingContainer.appendChild(loadingText)
// 添加到下拉框底部
const dropdown = dropdownWrap.parentElement
if (dropdown) {
dropdown.appendChild(loadingContainer)
loadingElement = loadingContainer
}
}
/**
* 智能处理 Promise 返回值
*/
const result = loadMoreFn?.()
if (result && typeof result === 'object' && 'then' in result) {
// 如果返回 Promise,等待完成后隐藏
(result as Promise<unknown>).finally(() => {
hideLoadingIcon()
})
} else {
// 如果不是 Promise,延迟隐藏
setTimeout(() => {
hideLoadingIcon()
}, 500)
}
关键技术点:
- 使用
createVNode和render动态渲染 Vue 组件 - 使用 CSS 变量
var(--el-color-primary)保持主题一致性 - 智能判断返回值类型,自动管理 loading 生命周期
- 防止重复显示(通过
loadingElement和isLoading标志)
2.4 防抖优化
import { useDebounceFn } from '@vueuse/core'
dropdownWrap?.addEventListener('scroll', useDebounceFn(handleScroll, 300))
使用 @vueuse/core 的 useDebounceFn 进行防抖处理,避免滚动时频繁触发回调。
2.5 资源清理
// 保存清理函数到元素上
el._scrollLoadCleanup = () => {
hideLoadingIcon() // 清理 loading 元素
if (dropdownWrap) {
dropdownWrap.removeEventListener('scroll', handleScroll)
dropdownWrap = null
}
}
// unmounted 时调用清理
unmounted(el) {
if (el._scrollLoadCleanup) {
el._scrollLoadCleanup()
delete el._scrollLoadCleanup
}
}
清理内容:
- 移除 loading DOM 元素
- 移除滚动事件监听器
- 清空引用,防止内存泄漏
⚠️ 注意事项与最佳实践
1. 必须使用唯一的 popperClass
❌ 错误示例:
<!-- 多个 select 使用相同的 class -->
<el-select :popper-class="'my-select'">...</el-select>
<el-select :popper-class="'my-select'">...</el-select>
✅ 正确示例:
<script setup>
import { v4 as uuidv4 } from 'uuid'
const class1 = `select-${uuidv4()}`
const class2 = `select-${uuidv4()}`
</script>
<template>
<el-select :popper-class="class1">...</el-select>
<el-select :popper-class="class2">...</el-select>
</template>
2. 在回调中添加 loading 状态
❌ 错误示例:
const loadMore = () => {
// 没有 loading 判断,可能重复触发
fetchData().then(data => {
options.value.push(...data)
})
}
✅ 正确示例:
const loading = ref(false)
const loadMore = () => {
if (loading.value) return // 防止重复触发
loading.value = true
fetchData()
.then(data => {
options.value.push(...data)
})
.finally(() => {
loading.value = false
})
}
3. 检查是否还有更多数据
const loadMore = () => {
if (loading.value) return
// 检查是否已加载全部数据
const hasMore = currentPage * pageSize < total
if (!hasMore) return
// ... 加载逻辑
}
4. 错误处理
const loadMore = async () => {
if (loading.value) return
loading.value = true
const currentPageBackup = currentPage
currentPage++
try {
const data = await fetchData(currentPage)
options.value.push(...data)
} catch (error) {
console.error('加载失败:', error)
currentPage = currentPageBackup // 回滚页码
ElMessage.error('加载失败,请重试')
} finally {
loading.value = false
}
}
5. 性能优化建议
-
合理设置
pageSize- 太小:频繁请求,用户体验差
- 太大:首次加载慢
- 建议:20-50 条
-
调整
distance- 默认 20px 适合大多数场景
- 网络较慢时可设置为 50-100px,提前加载
-
使用虚拟滚动
- 如果选项数量超过 1000 条,建议结合虚拟滚动库(如
vue-virtual-scroller)
- 如果选项数量超过 1000 条,建议结合虚拟滚动库(如
🐛 常见问题
Q1: 指令不生效,滚动到底部没有触发?
可能原因:
popperClass不唯一或与popper-class不一致- 下拉框还未渲染完成
解决方案:
<script setup>
import { v4 as uuidv4 } from 'uuid'
// 确保唯一性
const uniqueClass = `my-select-${uuidv4()}`
</script>
<template>
<el-select
v-elSelectLoadMore="{ loadMore, popperClass: uniqueClass }"
:popper-class="uniqueClass"
>
<!-- ... -->
</el-select>
</template>
Q2: 触发了多次加载?
原因: 没有添加 loading 状态判断
解决方案:
const loading = ref(false)
const loadMore = () => {
if (loading.value) return // 关键!
loading.value = true
// ...
}
Q3: 如何在搜索后重置分页?
const handleSearch = (query: string) => {
searchKeyword = query
currentPage = 1 // 重置页码
options.value = [] // 清空选项
loadData() // 重新加载
}
Q4: Loading 动画不显示?
可能原因:
- 设置了
showLoading: false loadMore函数执行太快(< 100ms)
解决方案:
// 确保 showLoading 为 true(或不设置,默认为 true)
v-elSelectLoadMore="{
loadMore: loadMore,
popperClass: selectClass,
showLoading: true // 明确启用
}"
// 如果是异步操作,返回 Promise
const loadMore = async () => {
const data = await fetchData() // 返回 Promise
options.value.push(...data)
}
Q5: 能否用于 el-cascader 或其他组件?
不能。 该指令专为 el-select 设计,因为它依赖 .el-select-dropdown__wrap 这个特定的 DOM 结构。
如需支持其他组件,需要修改 findDropdown 函数中的选择器。
🔄 与其他方案对比
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| v-elSelectLoadMore 指令 | 零侵入、易用、可复用 | 依赖 DOM 结构 | 大多数场景 |
| 封装自定义组件 | 完全可控 | 维护成本高、不够通用 | 特殊定制需求 |
| 使用第三方库 | 功能丰富 | 增加依赖、可能过度设计 | 复杂场景 |
| 一次性加载全部 | 实现简单 | 数据量大时性能差 | 数据量少(< 100) |
📦 依赖
{
"dependencies": {
"vue": "^3.3.0",
"@vueuse/core": "^10.0.0"
},
"devDependencies": {
"uuid": "^9.0.0" // 用于生成唯一 class
}
}
🎓 总结
v-elSelectLoadMore 指令通过以下技术要点实现了高效的下拉选项滚动加载:
- ✅ 精准定位:通过
popperClass精确匹配下拉框 - ✅ 性能优化:防抖处理 + loading 状态
- ✅ 内存安全:完善的事件清理机制
- ✅ 类型安全:完整的 TypeScript 支持
- ✅ 易于使用:指令方式,零侵入
使用建议
- 数据量 < 50:直接一次性加载
- 数据量 50-1000:使用本指令
- 数据量 > 1000:考虑虚拟滚动 + 本指令