记录一下vue2项目优化,虚拟列表vue-virtual-scroll-list处理10万条数据

文章目录

select下拉接口一次性返回10万条数据,页面卡死,如何优化??这里使用 分页 + 虚拟列表(vue-virtual-scroll-list),去模拟一个下拉的内容显示区域。支持单选 + 多选 + 模糊查询 + 滚动触底自动分页请求


粗略实现,满足需求即可哈哈哈哈哈哈哈:
单选:


多选:


封装BrandPickerVirtual.vue组件

js 复制代码
<template>
  <div class="brand-picker-virtual">
    <el-popover
      v-model="visible"
      placement="bottom-start"
      trigger="click"
      popper-class="brand-picker-popper"
      :append-to-body="false"
      :width="300">
      <div class="brand-picker-popover">
        <div class="search-box">
          <el-input
            v-model="searchKeyword"
            placeholder="搜索品牌"
            prefix-icon="el-icon-search"
            clearable />
        </div>
        <div class="brand-list" ref="brandList">
          <virtual-list
            ref="virtualList"
            class="scroller"
            :data-key="'brand_id'"
            :data-sources="filteredBrands"
            :data-component="itemComponent"
            :estimate-size="40"
            :keeps="20"
            :item-class="'brand-item'"
            :extra-props="{
              multiple,
              isSelected: isSelected,
              handleSelect: handleSelect,
              disabled
            }"
            :buffer="10"
            :bottom-threshold="30"
            @tobottom="handleScrollToBottom"/>
          <div v-if="loading" class="loading-more">
            <i class="el-icon-loading"></i> 加载中...
          </div>
          <div ref="observer" class="observer-target"></div>
        </div>
        <div v-if="multiple" class="footer">
          <el-button size="small" @click="handleClear">清空</el-button>
          <el-button type="primary" size="small" @click="handleConfirm">确定</el-button>
        </div>
      </div>
      <div 
        slot="reference" 
        class="el-input el-input--suffix select-trigger"
        :class="{ 'is-focus': visible }">
        <div class="el-input__inner select-inner">
          <div class="select-tags" v-if="multiple && selectedBrands.length">
            <el-tag
              v-for="brand in selectedBrands"
              :key="brand.brand_id"
              closable
              :disable-transitions="false"
              @close="handleRemoveTag(brand)"
              size="small"
              class="brand-tag">
              {{ brand.name }}
            </el-tag>
          </div>
          <div v-else-if="!multiple && selectedBrands.length" class="selected-single">
            <span class="selected-label">{{ selectedBrands[0].name }}</span>
          </div>
          <input
            type="text"
            readonly
            :placeholder="getPlaceholder"
            class="select-input">
          <i v-if="selectedBrands.length" 
             class="el-icon-circle-close clear-icon" 
             @click.stop="handleClear">
          </i>
        </div>
      </div>
    </el-popover>
  </div>
</template>

<script>
import VirtualList from 'vue-virtual-scroll-list'
import request from '@/utils/request'

const BrandItem = {
  name: 'BrandItem',
  props: {
    source: {
      type: Object,
      required: true
    },
    multiple: Boolean,
    isSelected: Function,
    handleSelect: Function,
    disabled: Boolean
  },
  render(h) {
    const isItemSelected = this.isSelected(this.source)
    return h('div', {
      class: {
        'item-content': true,
        'is-selected': isItemSelected && !this.multiple
      },
      on: {
        click: (e) => {
          if (!this.disabled) {
            this.handleSelect(this.source)
          }
        }
      }
    }, [
      this.multiple && h('el-checkbox', {
        props: {
          value: isItemSelected,
          disabled: this.disabled
        }
      }),
      h('span', { class: 'brand-name' }, this.source.name)
    ])
  }
}

export default {
  name: 'BrandPickerVirtual',
  components: {
    VirtualList
  },
  props: {
    multiple: {
      type: Boolean,
      default: false
    },
    defaultBrandId: {
      type: [Array, String, Number],
      default: () => []
    },
    api: {
      type: String,
      default: 'admin/goods/brands'
    },
    disabled: {
      type: Boolean,
      default: false
    }
  },
  data() {
    return {
      visible: false, // 弹窗是否可见
      searchKeyword: '', // 搜索关键字
      brandList: [], // 品牌列表数据
      selectedBrands: [], // 已选中的品牌列表
      tempSelectedBrands: [], // 多选时的临时选中列表
      loading: false, // 是否正在加载数据
      itemComponent: BrandItem, // 品牌项组件
      pageNo: 1, // 当前页码
      pageSize: 20, // 每页数量
      hasMore: true, // 是否还有更多数据
      searchTimer: null, // 搜索防抖定时器
      searchLoading: false, // 搜索加载状态
      lastScrollTop: 0, // 上次滚动位置
      isFirstPageLoaded: false, // 是否已加载第一页数据
      observer: null // 交叉观察器实例
    }
  },
  computed: {
    /**
     * 根据搜索关键字过滤品牌列表
     * @returns {Array} 过滤后的品牌列表
     */
    filteredBrands() {
      if (!this.searchKeyword) return this.brandList
      const keyword = this.searchKeyword.toLowerCase()
      return this.brandList.filter(item =>
        item.name.toLowerCase().includes(keyword)
      )
    },

    /**
     * 选中品牌的显示文本
     * @returns {string} 显示文本
     */
    selectedText() {
      if (this.multiple) {
        return this.selectedBrands.length
          ? `已选择 ${this.selectedBrands.length} 个品牌`
          : ''
      }
      return (this.selectedBrands[0] && this.selectedBrands[0].name) || ''
    },

    /**
     * 获取占位符文本
     */
    getPlaceholder() {
      if (this.multiple) {
        return this.selectedBrands.length ? '' : '请选择品牌(可多选)'
      }
      return this.selectedBrands.length ? '' : '请选择品牌'
    }
  },
  watch: {
    /**
     * 监听默认品牌ID变化,同步选中状态
     */
    defaultBrandId: {
      immediate: true,
      handler(val) {
        if (!val || !this.brandList.length) return
        if (this.multiple) {
          this.selectedBrands = this.brandList.filter(item =>
            val.includes(item.brand_id)
          )
        } else {
          const brand = this.brandList.find(item =>
            item.brand_id === val
          )
          this.selectedBrands = brand ? [brand] : []
        }
        this.tempSelectedBrands = [...this.selectedBrands]
      }
    },

    /**
     * 监听弹窗显示状态,首次打开时加载数据
     */
    visible(val) {
      if (val) {
        if (this.multiple) {
          this.tempSelectedBrands = [...this.selectedBrands]
        }
        this.resetData()
        this.getBrandList()
        // 确保虚拟列表在显示时重新初始化
        this.$nextTick(() => {
          if (this.$refs.virtualList) {
            this.$refs.virtualList.reset()
          }
        })
      }
    },

    /**
     * 监听搜索关键字变化,带防抖的搜索处理
     */
    searchKeyword(val) {
      if (this.searchTimer) {
        clearTimeout(this.searchTimer)
      }
      this.searchTimer = setTimeout(() => {
        this.resetData()
        this.getBrandList()
      }, 300)
    }
  },
  beforeDestroy() {
    if (this.observer) {
      this.observer.disconnect()
    }
  },
  methods: {
    /**
     * 初始化交叉观察器,用于监听滚动到底部
     */
    initObserver() {
      this.observer = new IntersectionObserver(
        (entries) => {
          const target = entries[0]
          if (target.isIntersecting && !this.loading && this.hasMore) {
            this.getBrandList(true)
          }
        },
        {
          root: this.$el.querySelector('.scroller'),
          threshold: 0.1
        }
      )

      if (this.$refs.observer) {
        this.observer.observe(this.$refs.observer)
      }
    },

    /**
     * 获取品牌列表数据
     * @param {boolean} isLoadMore - 是否是加载更多
     */
    async getBrandList(isLoadMore = false) {
      if (this.loading || (!isLoadMore && this.searchLoading)) return
      if (isLoadMore && !this.hasMore) return

      const loading = isLoadMore ? 'loading' : 'searchLoading'
      this[loading] = true

      try {
        if (isLoadMore) {
          this.pageNo++
        } else {
          this.pageNo = 1
        }

        const response = await request({
          url: this.api,
          method: 'get',
          params: {
            page_no: this.pageNo,
            page_size: this.pageSize,
            keyword: this.searchKeyword
          },
          loading: false
        })

        const { data, data_total } = response        
        if (!isLoadMore) {
          this.brandList = data
          this.isFirstPageLoaded = true
        } else {
          this.brandList = [...this.brandList, ...data]
        }

        this.hasMore = this.brandList.length < data_total

        if (this.defaultBrandId && !isLoadMore) {
          this.initializeSelection()
        }
      } catch (error) {
        console.error('获取品牌列表失败:', error)
      } finally {
        this[loading] = false
      }
    },

    /**
     * 滚动到底部的处理函数
     */
    handleScrollToBottom() {
      if (!this.loading && this.hasMore) {
        this.getBrandList(true)
      }
    },

    /**
     * 初始化选中状态
     */
    initializeSelection() {
      if (this.multiple) {
        this.selectedBrands = this.brandList.filter(item =>
          this.defaultBrandId.includes(item.brand_id)
        )
      } else {
        const brand = this.brandList.find(item =>
          item.brand_id === this.defaultBrandId
        )
        this.selectedBrands = brand ? [brand] : []
      }
      this.tempSelectedBrands = [...this.selectedBrands]
    },

    /**
     * 判断品牌是否被选中
     * @param {Object} item - 品牌项
     * @returns {boolean} 是否选中
     */
    isSelected(item) {
      return this.multiple
        ? this.tempSelectedBrands.some(brand => brand.brand_id === item.brand_id)
        : this.selectedBrands.some(brand => brand.brand_id === item.brand_id)
    },

    /**
     * 处理品牌选择
     * @param {Object} item - 选中的品牌项
     */
    handleSelect(item) {
      if (this.multiple) {
        const index = this.tempSelectedBrands.findIndex(
          brand => brand.brand_id === item.brand_id
        )
        if (index > -1) {
          this.tempSelectedBrands.splice(index, 1)
        } else {
          this.tempSelectedBrands.push(item)
        }
      } else {
        this.selectedBrands = [item]
        this.visible = false
        this.emitChange()
      }
    },

    /**
     * 清空选中的品牌
     */
    handleClear(e) {
      // 阻止事件冒泡,防止触发下拉框
      if (e) {
        e.stopPropagation()
      }
      this.selectedBrands = []
      this.tempSelectedBrands = []
      this.emitChange()
    },

    /**
     * 确认多选结果
     */
    handleConfirm() {
      this.selectedBrands = [...this.tempSelectedBrands]
      this.visible = false
      this.emitChange()
    },

    /**
     * 触发选中值变化事件
     */
    emitChange() {
      const value = this.multiple
        ? this.selectedBrands.map(item => item.brand_id)
        : (this.selectedBrands[0] && this.selectedBrands[0].brand_id) || null
      this.$emit('changed', value)
    },

    handleRemoveTag(brand) {
      const index = this.selectedBrands.findIndex(item => item.brand_id === brand.brand_id)
      if (index > -1) {
        this.selectedBrands.splice(index, 1)
      }
      this.tempSelectedBrands = [...this.selectedBrands]
      this.emitChange()
    },

    /**
     * 重置列表相关数据
     */
    resetData() {
      this.brandList = []
      this.pageNo = 1
      this.hasMore = true
      this.loading = false
      this.searchLoading = false
    }
  }
}
</script>

<style lang="scss">
.brand-picker-popper {
  max-height: calc(100vh - 100px);
  overflow: visible !important;
  left: 0 !important;
  top: 26px !important;

  .el-popover__title {
    margin: 0;
    padding: 0;
  }
}
</style>

<style lang="scss" scoped>
.brand-picker-virtual {
  display: inline-block;
  width: 100%;
  position: relative;

  .select-trigger {
    width: 100%;
    
    &.is-focus .el-input__inner {
      border-color: #409EFF;
    }
  }

  .select-inner {
    padding: 3px 8px;
    min-height: 32px;
    height: auto;
    cursor: pointer;
    position: relative;
    background-color: #fff;
    border: 1px solid #dcdfe6;
    border-radius: 4px;
    display: flex;
    align-items: center;
    flex-wrap: wrap;
  }

  .select-tags {
    display: flex;
    flex-wrap: wrap;
    gap: 4px;
    flex: 1;
    min-height: 24px;
    padding: 2px 0;
    
    .brand-tag {
      max-width: 100%;
      margin: 2px 0;
      
      &:last-child {
        margin-right: 4px;
      }
    }
  }

  .select-input {
    width: 0;
    min-width: 60px;
    margin: 2px 0;
    padding: 0;
    background: none;
    border: none;
    outline: none;
    height: 24px;
    line-height: 24px;
    font-size: 14px;
    color: #606266;
    flex: 1;

    &::placeholder {
      color: #c0c4cc;
    }
  }

  .clear-icon {
    position: absolute;
    right: 8px;
    color: #c0c4cc;
    font-size: 14px;
    cursor: pointer;
    transition: color .2s;
    
    &:hover {
      color: #909399;
    }
  }

  .selected-single {
    display: flex;
    align-items: center;
    flex: 1;
    padding-right: 24px;
    
    .selected-label {
      flex: 1;
      font-size: 14px;
      color: #606266;
      overflow: hidden;
      text-overflow: ellipsis;
      white-space: nowrap;
    }
  }

  .el-input__suffix,
  .el-icon-arrow-down {
    display: none;
  }

  .brand-picker-popover {
    margin-top: 4px !important;
    
    .search-box {
      padding: 0 0 12px;

      .el-input {
        font-size: 14px;
      }
    }

    .brand-list {
      position: relative;
      height: 320px;
      border: 1px solid #EBEEF5;
      border-radius: 4px;
      overflow: hidden;
      
      .scroller {
        height: 100%;
        overflow-y: auto !important;
        overflow-x: hidden;
        padding: 4px 0;

        /deep/ .virtual-list-container {
          position: relative !important;
        }

        /deep/ .virtual-list-phantom {
          position: relative !important;
        }

        /deep/ .brand-item {
          .item-content {
            padding-left: 8px;
            height: 40px;
            line-height: 40px;
            cursor: pointer;
            transition: all 0.3s;
            box-sizing: border-box;
            position: relative;
            font-size: 14px;
            color: #606266;
            border-bottom: 1px solid #f0f0f0;
            display: flex;
            align-items: center;
            user-select: none;

            .el-checkbox {
              margin-right: 8px;
            }

            .brand-name {
              flex: 1;
              overflow: hidden;
              text-overflow: ellipsis;
              white-space: nowrap;
            }

            &:hover {
              background-color: #F5F7FA;
            }

            &.is-selected {
              background-color: #F5F7FA;
              color: #409EFF;
              font-weight: 500;

              &::after {
                content: '';
                position: absolute;
                right: 15px;
                width: 14px;
                height: 14px;
                background: url(data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgMTAyNCAxMDI0IiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxwYXRoIGQ9Ik00MDYuNjU2IDcwNi45NDRsLTE2MC0xNjBjLTEyLjQ4LTEyLjQ4LTMyLjc2OC0xMi40OC00NS4yNDggMHMtMTIuNDggMzIuNzY4IDAgNDUuMjQ4bDE4Mi42MjQgMTgyLjYyNGMxMi40OCAxMi40OCAzMi43NjggMTIuNDggNDUuMjQ4IDBsNDAwLTQwMGMxMi40OC0xMi40OCAxMi40OC0zMi43NjggMC00NS4yNDhzLTMyLjc2OC0xMi40OC00NS4yNDggMEw0MDYuNjU2IDcwNi45NDR6IiBmaWxsPSIjNDA5RUZGIi8+PC9zdmc+) no-repeat center center;
                background-size: contain;
              }
            }
          }
        }
      }

      .loading-more {
        position: absolute;
        bottom: 0;
        left: 0;
        right: 0;
        padding: 8px;
        text-align: center;
        background: rgba(255, 255, 255, 0.95);
        color: #909399;
        font-size: 13px;
        z-index: 1;
        border-top: 1px solid #f0f0f0;
      }

      .observer-target {
        height: 2px;
        width: 100%;
        position: absolute;
        bottom: 0;
        left: 0;
      }
    }

    .footer {
      margin-top: 12px;
      text-align: right;
      padding: 0 2px;
    }
  }

  .selected-label {
    flex: 1;
    font-size: 14px;
    color: #606266;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }

  .selected-single {
    display: flex;
    align-items: center;
    flex: 1;
    padding: 0 4px;
    
    .selected-label {
      flex: 1;
      font-size: 14px;
      height: 24px;
      line-height: 24px;
      color: #606266;
      overflow: hidden;
      text-overflow: ellipsis;
      white-space: nowrap;
    }

    .el-icon-circle-close {
      margin-left: 8px;
      color: #c0c4cc;
      font-size: 14px;
      cursor: pointer;
      transition: color .2s;
      
      &:hover {
        color: #909399;
      }
    }
  }
}
</style> 

页面使用

js 复制代码
<template>
  <!-- 单选模式 -->
  <brand-picker-virtual
    :default-brand-id="singleBrandId"
    @changed="handleBrandChange"
  />

  <!-- 多选模式 -->
  <brand-picker-virtual
    multiple
    :default-brand-id="multipleBrandIds"
    @changed="handleMultipleBrandChange"
  />
</template>

<script>
// 注册组件别忘了,我这里省略了,我是个全局注册的
export default {
  data() {
    return {
      singleBrandId: null,  // 单选模式:存储单个品牌ID
      multipleBrandIds: []  // 多选模式:存储品牌ID数组
    }
  },
  methods: {
    // 单选回调
    handleBrandChange(brandId) {
      this.singleBrandId = brandId
    },
    // 多选回调
    handleMultipleBrandChange(brandIds) {
      this.multipleBrandIds = brandIds
    }
  }
}
</script>

组件属性

js 复制代码
props: {
  // 是否多选模式
  multiple: {
    type: Boolean,
    default: false
  },
  // 默认选中的品牌ID(单选时为number/string,多选时为array)
  defaultBrandId: {
    type: [Array, String, Number],
    default: () => []
  },
  // 自定义接口地址
  api: {
    type: String,
    default: 'admin/goods/brands'
  },
  // 是否禁用
  disabled: {
    type: Boolean,
    default: false
  }
}
相关推荐
wen's28 分钟前
React Native 0.79.4 中 [RCTView setColor:] 崩溃问题完整解决方案
javascript·react native·react.js
vvilkim1 小时前
Electron 自动更新机制详解:实现无缝应用升级
前端·javascript·electron
vvilkim1 小时前
Electron 应用中的内容安全策略 (CSP) 全面指南
前端·javascript·electron
aha-凯心1 小时前
vben 之 axios 封装
前端·javascript·学习
漫谈网络1 小时前
WebSocket 在前后端的完整使用流程
javascript·python·websocket
失落的多巴胺3 小时前
使用deepseek制作“喝什么奶茶”随机抽签小网页
javascript·css·css3·html5
DataGear3 小时前
如何在DataGear 5.4.1 中快速制作SQL服务端分页的数据表格看板
javascript·数据库·sql·信息可视化·数据分析·echarts·数据可视化
影子信息3 小时前
vue 前端动态导入文件 import.meta.glob
前端·javascript·vue.js
青阳流月3 小时前
1.vue权衡的艺术
前端·vue.js·开源
RunsenLIu3 小时前
基于Vue.js + Node.js + MySQL实现的图书销售管理系统
vue.js·mysql·node.js