记录一下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
  }
}
相关推荐
Super毛毛穗21 分钟前
npm 与 pnpm:JavaScript 包管理工具的对比与选择
前端·javascript·npm
Libby博仙23 分钟前
VUE3 VITE项目在 npm 中,关于 Vue 的常用命令有一些基础命令
前端·vue.js·npm·node.js
布兰妮甜1 小时前
Three.js 扩展与插件:增强3D开发的利器
javascript·3d·three.js·扩展与插件
布兰妮甜1 小时前
Three.js 性能优化:打造流畅高效的3D应用
javascript·3d·性能优化·three.js
木子M1 小时前
前端多端响应式适配方案
前端·javascript·css
wfsm1 小时前
uniapp中h5使用地图
开发语言·javascript·uni-app
程序猿000001号2 小时前
Vue.js 中父组件与子组件通信指南
前端·vue.js·flutter
GISer_Jing2 小时前
React中Fiber树构建过程详解——react中render一个App组件(包含子组件)的流程详解
前端·javascript·react.js
前端熊猫2 小时前
Vue3的reactive、ref、toRefs、toRef、toRaw 和 markRaw处理响应式数据区别
vue.js·toref·torefs·toraw·reactive·ref·markraw