Vue3 + ElementPlus 2.4.x 自定义指令:解决el-select滚动加载3大坑点

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>

在实际项目中,我们还需要考虑以下细节:

  • 网络错误时的重试机制
  • 空数据状态展示
  • 移动端触摸事件优化
  • 多选模式下的特殊处理
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值