记录一下vue2项目优化,虚拟列表vue-virtual-scroll-list处理10万条数据
文章目录
- 封装BrandPickerVirtual.vue组件
- 页面使用
- 组件属性
select下拉接口一次性返回10万条
数据,页面卡死,如何优化??这里使用 分页 + 虚拟列表(vue-virtual-scroll-list)
,去模拟一个下拉的内容显示区域。支持单选 + 多选 + 模糊查询 + 滚动触底自动分页请求
。
粗略实现,满足需求即可哈哈哈哈哈哈哈:
单选:
多选:
封装BrandPickerVirtual.vue组件
<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>
页面使用
<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>
组件属性
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
}
}