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

news/2025/1/12 6:34:05/

文章目录

      • 封装BrandPickerVirtual.vue组件
      • 页面使用
      • 组件属性

在这里插入图片描述

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


粗略实现,满足需求即可哈哈哈哈哈哈哈:
单选:
在这里插入图片描述


多选:
在这里插入图片描述


封装BrandPickerVirtual.vue组件

<template><div class="brand-picker-virtual"><el-popoverv-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-inputv-model="searchKeyword"placeholder="搜索品牌"prefix-icon="el-icon-search"clearable /></div><div class="brand-list" ref="brandList"><virtual-listref="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-tagv-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><inputtype="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.brandListconst 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) returnif (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)) returnif (isLoadMore && !this.hasMore) returnconst loading = isLoadMore ? 'loading' : 'searchLoading'this[loading] = truetry {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 = datathis.isFirstPageLoaded = true} else {this.brandList = [...this.brandList, ...data]}this.hasMore = this.brandList.length < data_totalif (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 = falsethis.emitChange()}},/*** 清空选中的品牌*/handleClear(e) {// 阻止事件冒泡,防止触发下拉框if (e) {e.stopPropagation()}this.selectedBrands = []this.tempSelectedBrands = []this.emitChange()},/*** 确认多选结果*/handleConfirm() {this.selectedBrands = [...this.tempSelectedBrands]this.visible = falsethis.emitChange()},/*** 触发选中值变化事件*/emitChange() {const value = this.multiple? this.selectedBrands.map(item => item.brand_id): (this.selectedBrands[0] && this.selectedBrands[0].brand_id) || nullthis.$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 = 1this.hasMore = truethis.loading = falsethis.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> 

页面使用

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

组件属性


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}
}

http://www.ppmy.cn/news/1562427.html

相关文章

本地缓存:Guava Cache

这里写目录标题 一、范例二、应用场景三、加载1、CacheLoader2、Callable3、显式插入 四、过期策略1、基于容量的过期策略2、基于时间的过期策略3、基于引用的过期策略 五、显示清除六、移除监听器六、清理什么时候发生七、刷新八、支持更新锁定能力 一、范例 LoadingCache<…

计算机网络之---传输层的功能

传输层的功能 传输层是 OSI&#xff08;开放系统互联&#xff09;七层模型中的第四层&#xff0c;位于应用层和网络层之间。传输层的主要作用是提供端到端的通信服务&#xff0c;确保数据在不同设备之间的可靠传输。它主要负责将来自上层&#xff08;应用层&#xff09;的数据拆…

spring mvc源码学习笔记之九

在前面的文章中&#xff0c;我们简单讲了可以用 WebApplicationInitializer 接口去替换 web.xml。 本文对这一块再做个详细讲解。 在 WebApplicationInitializer 这个接口的 javadoc 中有提到可以用继承 AbstractAnnotationConfigDispatcherServletInitializer 的方式替换实现 …

LeetCode 2765. 最长交替子数组解析与解题思路

LeetCode 2765. 最长交替子数组解析与解题思路 在本篇博客中&#xff0c;我们将深入解析 LeetCode 上的一道有趣题目 —— 2765. 最长交替子数组。我们将通过题目理解、解题思路、代码实现以及示例解析&#xff0c;全面掌握这道题目的解决方法。 题目描述 给定一个下标从 0 …

网站运营数据pv、uv、ip

想要彻底弄清楚pv uv ip的区别&#xff0c;首先要知道三者的定义&#xff1a; IP(独立IP)的定义&#xff1a; 即Internet Protocol,指独立IP数。24小时内相同公网IP地址只被计算一次。 PV(访问量)的定义&#xff1a; 即Page View,即页面浏览量或点击量&#xff0c;用户每次刷…

MySQL 如何实现可重复读?

文章目录 MySQL 可重复读的实现原理1. 多版本并发控制&#xff08;MVCC&#xff09;2. 隐藏列3. 读视图&#xff08;ReadView&#xff09;4. 版本链 案例说明案例 1&#xff1a;避免不可重复读案例 2&#xff1a;避免幻读 说明 MySQL 可重复读的实现原理 1. 多版本并发控制&am…

【PPTist】查找替换、绘制文本框

一、查找、替换 查找替换的组件 src/views/Editor/SearchPanel.vue 回车的时候会执行查找&#xff0c;查找替换功能的相关方法和属性都在 src/hooks/useSearch.ts 中。 1、查找 查找的方法也比较的朴实无华&#xff0c;就是 for 循环所有的幻灯片中的所有元素&#xff0c;通…

【20250109】Nature子刊:一种可改善帕金森患者冻结步态的柔性可穿戴机器人系统...

引言&#xff1a;冻结步态&#xff08;FoG&#xff09;是帕金森病中一种极具破坏性的步态障碍&#xff0c;会导致患者在行走时意外停顿。目前针对冻结步态的治疗效果有限且短暂&#xff0c;因此缺乏有效的治疗方法。该论文展示了一种使用机器人改善帕金森冻结步态的概念验证&am…