为了确保滚动时元素正确显示并且性能良好,我们可以通过调整数据的管理方式和渲染逻辑,来实现虚拟滚动。这次,我们确保所有计算正确,并使用 flex
布局。以下是一个详细的实现方式,保证元素在滚动时正确显示。
更新后的代码实现
<template>
<div class="container" ref="container" @scroll="onScroll">
<div class="spacer" :style="{ height: spacerHeight + 'px' }"></div>
<div class="items" :style="{ transform: `translateY(${marginTop}px)` }">
<el-card
v-for="(item, index) in visibleItems"
:key="item.id"
class="item"
>
<div class="card-content">
<el-checkbox :label="item.id" v-model="selectedItems"></el-checkbox>
<el-select
v-model="item.selectedOption"
filterable
remote
reserve-keyword
:remote-method="remoteMethod"
:loading="loading"
>
<el-option
v-for="option in options"
:key="option.value"
:label="option.label"
:value="option.value"
></el-option>
</el-select>
</div>
</el-card>
</div>
</div>
</template>
<script>
import { debounce } from 'lodash';
export default {
data() {
return {
listData: Array.from({ length: 1000 }, (_, i) => ({
id: i,
text: `Item ${i}`,
selectedOption: null
})),
startIndex: 0,
visibleCount: 10,
buffer: 5, // Buffer items to prevent flickering
selectedItems: [],
options: [],
loading: false,
itemHeight: 150, // Ensure this matches the height of el-card
};
},
computed: {
visibleItems() {
const start = Math.max(this.startIndex - this.buffer, 0);
const end = Math.min(this.startIndex + this.visibleCount + this.buffer, this.listData.length);
return this.listData.slice(start, end);
},
spacerHeight() {
return this.listData.length * this.itemHeight;
},
marginTop() {
return this.startIndex * this.itemHeight;
}
},
methods: {
onScroll: debounce(function () {
const container = this.$refs.container;
const scrollTop = container.scrollTop;
this.startIndex = Math.floor(scrollTop / this.itemHeight);
}, 50), // Adjust debounce time as needed
deleteSelected() {
this.listData = this.listData.filter(item => !this.selectedItems.includes(item.id));
this.selectedItems = [];
this.onScroll(); // Recalculate visible items
},
remoteMethod(query) {
if (query !== '') {
this.loading = true;
setTimeout(() => {
this.loading = false;
this.options = this.listData.filter(item => {
return item.text.toLowerCase().includes(query.toLowerCase());
}).map(item => {
return { value: item.id, label: item.text };
});
}, 200);
} else {
this.options = [];
}
}
},
mounted() {
this.onScroll(); // Initialize visible items
}
};
</script>
<style>
.container {
height: 400px; /* Adjust this height as needed */
overflow-y: auto;
position: relative;
}
.spacer {
width: 100%;
background: transparent;
}
.items {
position: absolute;
top: 0;
left: 0;
right: 0;
}
.item {
box-sizing: border-box;
border-bottom: 1px solid #ddd;
display: flex;
align-items: center;
padding: 10px;
cursor: pointer;
height: 150px; /* Ensure this matches itemHeight */
}
.card-content {
display: flex;
flex-direction: column;
height: 100%;
}
</style>
关键点解释
visibleItems
计算属性:- 根据
startIndex
和buffer
计算出需要渲染的条目,确保在可视区域及其附近的条目都能正确渲染。
- 根据
spacerHeight
计算属性:- 用于设置
spacer
的高度,确保滚动条的高度正确。
- 用于设置
marginTop
计算属性:- 根据
startIndex
计算出items
容器的margin-top
,确保条目在正确的位置显示。
- 根据
- 滚动事件处理 (
onScroll
):- 使用防抖(
debounce
)优化滚动事件处理,减少计算频率,提高性能。
- 使用防抖(
调试建议
- 检查控制台:确保没有错误或警告。
- 浏览器开发者工具:查看元素的实际渲染情况,检查
transform
和条目的渲染位置。 - 调整
debounce
时间:根据实际性能调整防抖时间,以确保滚动平滑。
通过这些调整,你应该能确保条目在滚动时正确显示。如果这个实现仍然存在问题,请告诉我具体的表现或错误信息,我会根据情况进行进一步调整。
要实现自适应 itemHeight
的虚拟列表,需要在渲染完成后动态计算每个条目的高度,并根据这些高度来调整滚动和渲染逻辑。这种实现会稍微复杂一些,因为需要处理动态高度的情况。以下是一个解决方案:
更新后的虚拟列表组件实现
VirtualList.vue
<template>
<div class="virtual-list-container" ref="container" @scroll="onScroll">
<div class="spacer" :style="{ height: totalHeight + 'px' }"></div>
<div class="items" :style="{ transform: `translateY(${offsetTop}px)` }" ref="items">
<slot v-for="(item, index) in visibleItems" :item="item" :index="index" :key="item.id" />
</div>
</div>
</template>
<script>
import { debounce } from 'lodash';
export default {
name: 'VirtualList',
props: {
items: {
type: Array,
required: true
},
visibleCount: {
type: Number,
default: 10
},
buffer: {
type: Number,
default: 5
}
},
data() {
return {
startIndex: 0,
heights: [],
offsetTop: 0,
totalHeight: 0,
};
},
computed: {
visibleItems() {
const start = Math.max(this.startIndex - this.buffer, 0);
const end = Math.min(this.startIndex + this.visibleCount + this.buffer, this.items.length);
return this.items.slice(start, end);
}
},
methods: {
onScroll: debounce(function () {
const container = this.$refs.container;
const scrollTop = container.scrollTop;
this.updateVisibleItems(scrollTop);
}, 50),
updateVisibleItems(scrollTop) {
let accumulatedHeight = 0;
let newStartIndex = 0;
for (let i = 0; i < this.heights.length; i++) {
if (accumulatedHeight + this.heights[i] > scrollTop) {
newStartIndex = i;
break;
}
accumulatedHeight += this.heights[i];
}
this.startIndex = newStartIndex;
this.offsetTop = accumulatedHeight;
},
calculateHeights() {
const items = this.$refs.items.children;
this.heights = Array.from(items).map(item => item.getBoundingClientRect().height);
this.totalHeight = this.heights.reduce((a, b) => a + b, 0);
}
},
watch: {
items: {
immediate: true,
handler() {
this.$nextTick(() => {
this.calculateHeights();
this.updateVisibleItems(this.$refs.container.scrollTop);
});
}
}
},
mounted() {
this.$nextTick(() => {
this.calculateHeights();
this.updateVisibleItems(this.$refs.container.scrollTop);
});
window.addEventListener('resize', this.calculateHeights);
},
beforeDestroy() {
window.removeEventListener('resize', this.calculateHeights);
}
};
</script>
<style>
.virtual-list-container {
height: 400px; /* Adjust this height as needed */
overflow-y: auto;
position: relative;
}
.spacer {
width: 100%;
background: transparent;
}
.items {
position: absolute;
top: 0;
left: 0;
right: 0;
}
</style>
使用虚拟列表组件
以下是如何使用 VirtualList
组件的示例,将 el-card
元素作为虚拟列表中的项渲染。
App.vue
<template>
<div id="app">
<VirtualList :items="listData" :visibleCount="10" :buffer="5">
<template v-slot="{ item, index }">
<el-card class="item" :key="item.id">
<div class="card-content">
<el-checkbox :label="item.id" v-model="selectedItems"></el-checkbox>
<el-select
v-model="item.selectedOption"
filterable
remote
reserve-keyword
:remote-method="remoteMethod"
:loading="loading"
>
<el-option
v-for="option in options"
:key="option.value"
:label="option.label"
:value="option.value"
></el-option>
</el-select>
</div>
</el-card>
</template>
</VirtualList>
</div>
</template>
<script>
import VirtualList from './components/VirtualList.vue';
export default {
components: {
VirtualList
},
data() {
return {
listData: Array.from({ length: 1000 }, (_, i) => ({
id: i,
text: `Item ${i}`,
selectedOption: null
})),
selectedItems: [],
options: [],
loading: false
};
},
methods: {
deleteSelected() {
this.listData = this.listData.filter(item => !this.selectedItems.includes(item.id));
this.selectedItems = [];
},
remoteMethod(query) {
if (query !== '') {
this.loading = true;
setTimeout(() => {
this.loading = false;
this.options = this.listData.filter(item => {
return item.text.toLowerCase().includes(query.toLowerCase());
}).map(item => {
return { value: item.id, label: item.text };
});
}, 200);
} else {
this.options = [];
}
}
}
};
</script>
<style>
.item {
box-sizing: border-box;
border-bottom: 1px solid #ddd;
display: flex;
align-items: center;
padding: 10px;
cursor: pointer;
}
.card-content {
display: flex;
flex-direction: column;
height: 100%;
}
</style>
关键点解释
calculateHeights
方法:- 动态计算每个条目的高度,并存储在
heights
数组中,同时计算出totalHeight
。
- 动态计算每个条目的高度,并存储在
updateVisibleItems
方法:- 根据滚动位置计算当前可见的条目起始索引和偏移量
offsetTop
。
- 根据滚动位置计算当前可见的条目起始索引和偏移量
- 插槽使用:
- 在
VirtualList
中使用插槽,可以灵活地传递任意内容作为条目渲染。
- 在
调试建议
- 检查控制台:确保没有错误或警告。
- 浏览器开发者工具:查看元素的实际渲染情况,检查
transform
和条目的渲染位置。 - 调整高度计算:确保
calculateHeights
方法正确计算条目高度,并且updateVisibleItems
正确更新可见条目。
通过这种方式,可以将虚拟列表功能封装为一个可复用的组件,并在任何需要的地方使用,同时支持动态高度的条目。
<template>
<div>
<virtual-list :items="items">
<template v-slot="{ item, index }">
<div :class="index % 2 == 0 ? 'custom-item' : 'custom-item1'">
.
</div>
</template>
</virtual-list>
</div>
</template>
<script>
import VirtualList from '@/components/VirtualList.vue';
export default {
components: {
VirtualList
},
data() {
return {
items: Array.from({ length: 999 }, (_, i) => `Item ${i + 1}`)
};
}
};
</script>
<style>
.custom-item {
padding: 20px;
background-color: #f5f5f5;
margin-bottom: 5px;
border-radius: 5px;
}
.custom-item1 {
padding: 40px;
background-color: #f5f5f5;
margin-bottom: 5px;
border-radius: 5px;
}
</style>
<template>
<div ref="container" class="virtual-list-container" @scroll="onScroll">
<div class="spacer" :style="{ height: totalHeight + 'px' }"></div>
<div class="items" :style="{ transform: `translateY(${offsetTop}px)` }">
<div
v-for="(item, index) in visibleItems"
:key="startIndex + index"
:ref="el => setItemRef(el, startIndex + index)"
class="item"
:class="{ selected: selectedItems.includes(startIndex + index) }"
@click="toggleSelection(startIndex + index)"
>
<slot :item="item" :index="startIndex + index" />
</div>
</div>
</div>
</template>
<script>
export default {
name: 'VirtualList',
props: {
items: {
type: Array,
required: true
},
buffer: {
type: Number,
default: 5
}
},
data() {
return {
startIndex: 0,
offsetTop: 0,
totalHeight: 0,
containerHeight: 0,
visibleCount: 0,
itemHeights: [],
selectedItems: []
};
},
computed: {
visibleItems() {
return this.items.slice(this.startIndex, this.startIndex + this.visibleCount);
}
},
methods: {
onScroll() {
const scrollTop = this.$refs.container.scrollTop;
this.updateVisibleRange(scrollTop);
},
updateVisibleRange(scrollTop) {
let sumHeight = 0;
let found = false;
for (let i = 0; i < this.itemHeights.length; i++) {
sumHeight += this.itemHeights[i];
if (sumHeight > scrollTop) {
this.startIndex = i;
this.offsetTop = sumHeight - this.itemHeights[i];
found = true;
break;
}
}
if (!found) {
this.startIndex = 0;
this.offsetTop = 0;
}
this.updateVisibleCount();
},
updateVisibleCount() {
this.containerHeight = this.$refs.container.clientHeight;
let sumHeight = 0;
let count = 0;
for (let i = this.startIndex; i < this.itemHeights.length; i++) {
sumHeight += this.itemHeights[i];
count++;
if (sumHeight > this.containerHeight) break;
}
this.visibleCount = count + this.buffer;
},
toggleSelection(index) {
const selectedIndex = this.selectedItems.indexOf(index);
if (selectedIndex > -1) {
this.selectedItems.splice(selectedIndex, 1);
} else {
this.selectedItems.push(index);
}
},
setItemRef(el, index) {
if (el) {
this.$nextTick(() => {
this.itemHeights[index] = el.offsetHeight;
this.updateTotalHeight();
this.updateVisibleCount();
});
}
},
updateTotalHeight() {
this.totalHeight = this.itemHeights.reduce((acc, height) => acc + height, 0);
}
},
mounted() {
this.containerHeight = this.$refs.container.clientHeight;
this.updateVisibleCount();
}
};
</script>
<style>
.virtual-list-container {
height: 400px;
overflow-y: auto;
position: relative;
}
.spacer {
width: 100%;
}
.items {
position: absolute;
top: 0;
left: 0;
right: 0;
}
.item {
padding: 10px;
border-bottom: 1px solid #ccc;
box-sizing: border-box;
}
.item.selected {
background-color: #b3d4fc;
}
</style>
明白了,我会提供一个只包含虚拟滚动功能的组件。这个组件将只负责渲染可视区域内的元素,而不处理选择等其他操作。这些额外的操作可以在使用该组件的地方自行实现。
简化版 VirtualScroll.vue 组件
<template>
<div ref="container" class="virtual-scroll-container" @scroll="onScroll">
<div class="spacer" :style="{ height: totalHeight + 'px' }"></div>
<div class="items" :style="{ transform: `translateY(${offsetTop}px)` }">
<div
v-for="(item, index) in visibleItems"
:key="startIndex + index"
:ref="el => setItemRef(el, startIndex + index)"
class="item"
>
<slot :item="item" :index="startIndex + index" />
</div>
</div>
</div>
</template>
<script>
export default {
name: 'VirtualScroll',
props: {
items: {
type: Array,
required: true
},
buffer: {
type: Number,
default: 5
}
},
data() {
return {
startIndex: 0,
offsetTop: 0,
totalHeight: 0,
containerHeight: 0,
visibleCount: 0,
itemHeights: []
};
},
computed: {
visibleItems() {
return this.items.slice(this.startIndex, this.startIndex + this.visibleCount);
}
},
methods: {
onScroll() {
const scrollTop = this.$refs.container.scrollTop;
this.updateVisibleRange(scrollTop);
},
updateVisibleRange(scrollTop) {
let sumHeight = 0;
let found = false;
for (let i = 0; i < this.itemHeights.length; i++) {
sumHeight += this.itemHeights[i];
if (sumHeight > scrollTop) {
this.startIndex = i;
this.offsetTop = sumHeight - this.itemHeights[i];
found = true;
break;
}
}
if (!found) {
this.startIndex = 0;
this.offsetTop = 0;
}
this.updateVisibleCount();
},
updateVisibleCount() {
this.containerHeight = this.$refs.container.clientHeight;
let sumHeight = 0;
let count = 0;
for (let i = this.startIndex; i < this.itemHeights.length; i++) {
sumHeight += this.itemHeights[i];
count++;
if (sumHeight > this.containerHeight) break;
}
this.visibleCount = count + this.buffer;
},
setItemRef(el, index) {
if (el) {
this.$nextTick(() => {
this.itemHeights[index] = el.offsetHeight;
this.updateTotalHeight();
this.updateVisibleCount();
});
}
},
updateTotalHeight() {
this.totalHeight = this.itemHeights.reduce((acc, height) => acc + height, 0);
}
},
mounted() {
this.containerHeight = this.$refs.container.clientHeight;
this.updateVisibleCount();
}
};
</script>
<style>
.virtual-scroll-container {
height: 400px;
overflow-y: auto;
position: relative;
}
.spacer {
width: 100%;
}
.items {
position: absolute;
top: 0;
left: 0;
right: 0;
}
.item {
box-sizing: border-box;
}
</style>
使用组件并实现选择操作
下面是一个使用 VirtualScroll
组件的例子,其中在使用组件中自行实现选择操作:
<template>
<div>
<virtual-scroll :items="items">
<template v-slot="{ item, index }">
<div
class="custom-item"
:class="{ selected: selectedItems.includes(index) }"
@click="toggleSelection(index)"
>
.
</div>
</template>
</virtual-scroll>
</div>
</template>
<script>
import VirtualScroll from './VirtualScroll.vue';
export default {
components: {
VirtualScroll
},
data() {
return {
items: Array.from({ length: 1000 }, (_, i) => `Item ${i + 1}`),
selectedItems: []
};
},
methods: {
toggleSelection(index) {
const selectedIndex = this.selectedItems.indexOf(index);
if (selectedIndex > -1) {
this.selectedItems.splice(selectedIndex, 1); // 取消选择
} else {
this.selectedItems.push(index); // 选择
}
}
}
};
</script>
<style>
.custom-item {
padding: 20px;
background-color: #f5f5f5;
margin-bottom: 5px;
border-radius: 5px;
cursor: pointer;
}
.custom-item.selected {
background-color: #b3d4fc;
}
</style>
说明
- VirtualScroll 组件:只处理虚拟滚动,不包括选择等其他操作。
- 使用组件:在
VirtualScroll
组件中插入自定义内容,并在此处实现选择操作或其他逻辑。
这种方式可以将虚拟滚动和业务逻辑分开,实现更好的模块化和代码复用。
<template>
<div ref="container" class="virtual-scroll-container" @scroll="onScroll">
<div class="spacer" :style="{ height: totalHeight + 'px' }"></div>
<div class="items" :style="{ transform: `translateY(${offsetTop}px)` }">
<div
v-for="(item, index) in visibleItems"
:key="startIndex + index"
:ref="el => setItemRef(el, startIndex + index)"
class="item"
>
<slot :item="item" :index="startIndex + index" />
</div>
</div>
</div>
</template>
<script>
export default {
name: 'VirtualScroll',
props: {
items: {
type: Array,
required: true
},
buffer: {
type: Number,
default: 5
}
},
data() {
return {
startIndex: 0,
offsetTop: 0,
totalHeight: 0,
containerHeight: 0,
visibleCount: 0,
itemHeights: []
};
},
computed: {
visibleItems() {
return this.items.slice(this.startIndex, this.startIndex + this.visibleCount);
}
},
methods: {
onScroll() {
const scrollTop = this.$refs.container.scrollTop;
this.updateVisibleRange(scrollTop);
},
updateVisibleRange(scrollTop) {
let sumHeight = 0;
let found = false;
for (let i = 0; i < this.itemHeights.length; i++) {
sumHeight += this.itemHeights[i];
if (sumHeight > scrollTop) {
this.startIndex = i;
this.offsetTop = sumHeight - this.itemHeights[i];
found = true;
break;
}
}
if (!found) {
this.startIndex = 0;
this.offsetTop = 0;
}
this.updateVisibleCount();
},
updateVisibleCount() {
let sumHeight = 0;
let count = 0;
for (let i = this.startIndex; i < this.itemHeights.length; i++) {
sumHeight += this.itemHeights[i];
count++;
if (sumHeight > this.containerHeight) break;
}
this.visibleCount = count + this.buffer;
},
setItemRef(el, index) {
if (el) {
this.$nextTick(() => {
const height = el.offsetHeight;
if (height > 0) {
// 使用普通赋值来更新数组中的元素
this.$data.itemHeights[index] = height;
this.updateTotalHeight();
this.updateVisibleCount();
}
});
}
},
updateTotalHeight() {
this.totalHeight = this.itemHeights.reduce((acc, height) => acc + height, 0);
},
initializeVisibleCount() {
this.containerHeight = this.$refs.container.clientHeight;
if (this.itemHeights.length > 0) {
this.updateVisibleCount();
} else {
const averageHeight = 50; // 假设初始渲染的默认高度
this.visibleCount = Math.ceil(this.containerHeight / averageHeight) + this.buffer;
}
}
},
mounted() {
this.$nextTick(() => {
this.initializeVisibleCount();
this.updateVisibleRange(0);
});
}
};
</script>
<style>
.virtual-scroll-container {
width: 100%;
height: 100%; /* 确保容器有高度 */
overflow-y: auto;
position: relative;
}
.spacer {
width: 100%;
}
.items {
position: absolute;
top: 0;
left: 0;
right: 0;
}
.item {
box-sizing: border-box;
}
</style>
<template>
<div class="app-container">
<VirtualScroll :items="items">
<template v-slot="{ item, index }">
<div
class="custom-item"
:class="{ selected: selectedItems.includes(index) }"
@click="toggleSelection(index)"
>
.
</div>
</template>
</VirtualScroll>
</div>
</template>
<script>
import { ref } from 'vue';
import VirtualScroll from './VirtualScroll.vue';
export default {
components: {
VirtualScroll,
},
setup() {
const items = ref(Array.from({ length: 1000 }, (_, i) => `Item ${i + 1}`));
const selectedItems = ref([]);
const toggleSelection = (index) => {
const selectedIndex = selectedItems.value.indexOf(index);
if (selectedIndex > -1) {
selectedItems.value.splice(selectedIndex, 1); // 取消选择
} else {
selectedItems.value.push(index); // 选择
}
};
return {
items,
selectedItems,
toggleSelection,
};
},
};
</script>
<style>
.app-container {
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
}
.custom-item {
padding: 20px;
background-color: #f5f5f5;
margin-bottom: 5px;
border-radius: 5px;
cursor: pointer;
}
.custom-item.selected {
background-color: #b3d4fc;
}
</style>
为了让你能够直接在 ElTabPane
中使用通用的虚拟列表组件,下面是一个完整的实现代码。这个虚拟列表组件将独立处理滚动和数据渲染,并在每个 tab 中显示不同的数据。
通用虚拟列表组件:VirtualList.vue
<template>
<div ref="container" class="virtual-scroll-container" @scroll="onScroll">
<div class="spacer" :style="{ height: totalHeight + 'px' }"></div>
<div class="items" :style="{ transform: `translateY(${offsetTop}px)` }">
<div
v-for="(item, index) in visibleItems"
:key="startIndex + index"
class="item"
>
<slot :item="item" :index="startIndex + index"></slot>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'VirtualList',
props: {
items: {
type: Array,
required: true,
},
itemHeight: {
type: Number,
default: 50, // 每个项目的默认高度
},
buffer: {
type: Number,
default: 5, // 缓冲区大小,控制渲染时多加载的项
},
},
data() {
return {
startIndex: 0,
offsetTop: 0,
totalHeight: 0,
visibleCount: 0,
};
},
computed: {
visibleItems() {
return this.items.slice(this.startIndex, this.startIndex + this.visibleCount);
},
},
methods: {
onScroll() {
const scrollTop = this.$refs.container.scrollTop;
this.updateVisibleRange(scrollTop);
},
updateVisibleRange(scrollTop) {
this.startIndex = Math.floor(scrollTop / this.itemHeight);
this.offsetTop = this.startIndex * this.itemHeight;
this.updateVisibleCount();
},
updateVisibleCount() {
const containerHeight = this.$refs.container.clientHeight;
this.visibleCount = Math.ceil(containerHeight / this.itemHeight) + this.buffer;
},
},
mounted() {
this.$nextTick(() => {
const containerHeight = this.$refs.container.clientHeight;
this.visibleCount = Math.ceil(containerHeight / this.itemHeight) + this.buffer;
this.totalHeight = this.items.length * this.itemHeight;
});
},
};
</script>
<style>
.virtual-scroll-container {
width: 100%;
height: 100%;
overflow-y: auto;
position: relative;
}
.spacer {
width: 100%;
}
.items {
position: absolute;
top: 0;
left: 0;
right: 0;
}
.item {
box-sizing: border-box;
padding: 10px;
}
</style>
主页面:App.vue
(直接在 ElTabPane
中使用虚拟组件)
<template>
<el-tabs v-model="activeTab" class="tab-container">
<el-tab-pane label="Tab 1" name="1">
<VirtualList :items="tab1Items" :itemHeight="50">
<template #default="{ item, index }">
<div class="item-content">: </div>
</template>
</VirtualList>
</el-tab-pane>
<el-tab-pane label="Tab 2" name="2">
<VirtualList :items="tab2Items" :itemHeight="50">
<template #default="{ item, index }">
<div class="item-content">: </div>
</template>
</VirtualList>
</el-tab-pane>
</el-tabs>
</template>
<script>
import { ref } from 'vue';
import VirtualList from './components/VirtualList.vue';
export default {
components: {
VirtualList,
},
setup() {
const tab1Items = ref(
Array.from({ length: 1000 }, (v, i) => ({
id: i,
name: `Tab 1 Item ${i + 1}`,
}))
);
const tab2Items = ref(
Array.from({ length: 800 }, (v, i) => ({
id: i,
name: `Tab 2 Item ${i + 1}`,
}))
);
const activeTab = ref('1');
return {
activeTab,
tab1Items,
tab2Items,
};
},
};
</script>
<style>
.el-tabs__content {
height: 400px; /* 设定Tab容器的高度 */
}
.item-content {
padding: 10px;
border-bottom: 1px solid #ddd;
}
.el-tab-pane{
height: 100%;
}
</style>
关键说明:
VirtualList.vue
通用组件:通过props
传递数据items
和每个条目的高度itemHeight
,并通过slot
来灵活定义每个条目的显示内容。- 在
ElTabPane
中直接使用虚拟列表:通过slot
的插槽机制,你可以在ElTabPane
中直接嵌入虚拟列表,而不需要额外的封装。 - 支持多 Tab 独立渲染:每个 tab 页都会有独立的虚拟列表实例,并且在切换 tab 时不会相互影响。
测试:
- 切换 Tab,观察每个 Tab 中的数据是否独立正常渲染。
- 滚动列表,确保列表项不会闪烁,滚动效果正常。
- 在
ElTabPane
中切换时,确保滚动条和数据都不会混乱。
这个结构简洁地将虚拟列表逻辑封装到了通用组件 VirtualList.vue
中,可以复用到其他场景中,满足你的 ElTabs
虚拟列表需求。
虚拟列表hooks
import { ref, onMounted, onBeforeUnmount, nextTick, watch } from "vue";
export default function useVirtualList(config) {
// 获取元素
let actualHeightContainerEl = null,
translateContainerEl = null,
scrollContainerEl = null;
let size = 10; // 可视区展示列表项个数
// 数据源,便于后续直接访问
let dataSource = [];
onMounted(() => {
scrollContainerEl = document.querySelector(config.scrollContainer);
actualHeightContainerEl = document.querySelector(config.actualHeightContainer);
translateContainerEl = document.querySelector(config.translateContainer);
// 获取可视区的高度,用于计算可视区的展示列表项个数
size = Math.ceil(scrollContainerEl.clientHeight / config.itemHeight);
dataSource = config.data.value;
// 根据滚动高度计算需要渲染的数据
updateActualRenderData(0);
});
// 数据源发生变动
watch(
() => config.data.value,
newVal => {
// 更新数据源
dataSource = newVal;
// 计算需要渲染的数据
updateActualRenderData(0);
}
);
// 缓存已渲染元素的高度
const renderedItemsCache = {};
// 获取缓存高度,无缓存,取配置项的 itemHeight
const getItemHeightFromCache = (index) => {
const val = renderedItemsCache[index];
return val === undefined ? config.itemHeight : val;
};
/**
* 更新虚拟区实际高度
*/
const updateActualHeight = () => {
// 计算所有项目的总高度
const totalHeight = dataSource.reduce((accumulator, _, index) => {
try {
// 从缓存中获取每个项目的高度并累加
return accumulator + getItemHeightFromCache(index);
} catch (error) {
// 如果获取项目高度时发生错误,打印错误信息并保持当前累加值不变
console.error(`Error getting height for item ${index}:`, error);
return accumulator;
}
}, 0);
// 更新容器的高度
if (actualHeightContainerEl) {
// 如果容器元素存在,则设置其高度为计算出的总高度
actualHeightContainerEl.style.height = totalHeight + "px";
} else {
// 如果容器元素未定义,则打印错误信息
console.error("actualHeightContainerEl is not defined");
}
};
// 更新已渲染列表项的缓存高度
const updateRenderedItemCache = (index) => {
const start = index;
// 当所有元素的实际高度更新完毕,就不需要重新计算高度
const shouldUpdate = Object.keys(renderedItemsCache).length < dataSource.length;
if (!shouldUpdate) return;
nextTick(() => {
// 获取所有列表项元素
const Items = Array.from(document.querySelectorAll(config.itemContainer));
// 进行缓存
Items.forEach(el => {
if (!renderedItemsCache[index]) {
renderedItemsCache[index] = el.offsetHeight;
}
index++;
});
// 更新实际高度
updateActualHeight();
// 更新偏移值
getOffsetY(start);
});
};
// 实际渲染的数据
const actualRenderData = ref([]);
// 更新实际渲染数据
const updateActualRenderData = (scrollTop) => {
// 异常处理
if (!dataSource || !config) {
console.error("dataSource or config is undefined");
return;
}
let startIndex = 0;
let endIndex = 0;
let offsetHeight = 0;
// 使用二分查找来加速 startIndex 的计算
let low = 0;
let high = dataSource.length - 1;
while (low <= high) {
const mid = Math.floor((low + high) / 2);
offsetHeight = getAccumulatedHeight(mid);
if (offsetHeight >= scrollTop) {
high = mid - 1;
} else {
low = mid + 1;
}
}
startIndex = low;
endIndex = startIndex + size;
// 边界条件处理 确保 startIndex 和 endIndex 在有效范围内
startIndex = Math.max(0, startIndex);
endIndex = Math.min(dataSource.length, endIndex);
// 起始缓冲数量
const aboveCount = Math.min(startIndex, size * config.bufferRatio);
// 终止缓冲数量
const belowCount = Math.min(config.data.value.length - endIndex, size * config.bufferRatio);
// 计算得出的渲染数据
let start = startIndex - aboveCount;
const end = endIndex + belowCount;
actualRenderData.value = dataSource.slice(start, end);
// 缓存渲染列表中各个列表项高度
updateRenderedItemCache(start);
nextTick(() => {
// 更新偏移值
getOffsetY(start);
});
};
// 辅助函数:获取累积高度
const getAccumulatedHeight = (index) => {
let height = 0;
for (let i = 0; i <= index; i++) {
height += getItemHeightFromCache(i);
}
return height;
};
// 获取偏移量
const getOffsetY = (start) => {
let startOffset = 0;
if (start >= 1) {
startOffset = new Array(start).fill("").reduce((acc, _, index) => {
return acc + getItemHeightFromCache(index);
}, 0);
}
translateContainerEl.style.transform = `translateY(${startOffset}px)`;
};
// 滚动事件
const handleScroll = (e) => {
// 渲染正确的数据
updateActualRenderData(e.target.scrollTop);
};
// 注册滚动事件
onMounted(() => {
scrollContainerEl?.addEventListener("scroll", throttle(handleScroll, 100));
});
// 移除滚动事件
onBeforeUnmount(() => {
scrollContainerEl?.removeEventListener("scroll", handleScroll);
});
function throttle(fn, delay) {
// last为上一次触发回调的时间, timer是定时器
let last = 0,
timer = null;
// 将throttle处理结果当作函数返回
return function (...args) {
// 保留调用时的this上下文
const context = this;
// 记录本次触发回调的时间
let now = +new Date();
// 判断上次触发的时间和本次触发的时间差是否小于时间间隔的阈值
if (now - last < delay) {
// 如果时间间隔小于我们设定的时间间隔阈值,则为本次触发操作设立一个新的定时器
clearTimeout(timer);
timer = setTimeout(function () {
last = now;
fn.apply(context, args);
}, delay);
} else {
// 如果时间间隔超出了我们设定的时间间隔阈值,那就不等了,无论如何要反馈给用户一次响应
last = now;
fn.apply(context, args);
}
};
}
return { actualRenderData };
}
用法
<template>
<div class="virtual-list-container" ref="scrollContainer">
<div class="virtual-list-actual" ref="actualContainer">
<div class="virtual-list-translate" ref="translateContainer">
<div v-for="item in actualRenderData"
:key="item.id"
class="list-item">
<div class="item-content">
<h3></h3>
<p></p>
<div class="item-images" v-if="item.images?.length">
<img v-for="(img, index) in item.images"
:key="index"
:src="img"
:alt="item.title">
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import { ref } from 'vue'
import useVirtualList from '@/hooks/useVirtualList'
export default {
name: 'VirtualList',
setup() {
const scrollContainer = ref(null)
const actualContainer = ref(null)
const translateContainer = ref(null)
const listData = ref(Array.from({ length: 1000 }, (_, index) => ({
id: index,
title: `标题 ${index}`,
content: `这是一段动态内容 ${index} ${Math.random() > 0.5 ? '包含很多很多文字'.repeat(Math.floor(Math.random() * 3)) : '短文本'}`,
images: Math.random() > 0.7 ? [
'https://placeholder.com/150',
'https://placeholder.com/150'
] : []
})))
const { actualRenderData } = useVirtualList({
data: listData,
scrollContainer: '.virtual-list-container',
actualHeightContainer: '.virtual-list-actual',
translateContainer: '.virtual-list-translate',
itemContainer: '.list-item',
itemHeight: 100,
bufferRatio: 1
})
return {
actualRenderData
}
}
}
</script>
<style scoped>
.virtual-list-container {
height: 600px;
overflow-y: auto;
}
.virtual-list-actual {
position: relative;
}
.virtual-list-translate {
position: absolute;
width: 100%;
left: 0;
}
.list-item {
padding: 16px;
border-bottom: 1px solid #eee;
}
.item-content h3 {
margin: 0 0 8px;
}
.item-content p {
margin: 0 0 12px;
line-height: 1.5;
}
.item-images {
display: flex;
gap: 8px;
}
.item-images img {
width: 100px;
height: 100px;
object-fit: cover;
}
</style>
<template>
<div class="virtual-table-container" ref="scrollContainer">
<div class="virtual-table-actual" ref="actualContainer">
<div class="virtual-table-translate" ref="translateContainer">
<el-table :data="actualRenderData" style="width: 100%">
<el-table-column prop="name" label="姓名" />
<el-table-column prop="description" label="描述">
<template #default="{ row }">
<div class="dynamic-height-cell">
</div>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" />
</el-table>
</div>
</div>
</div>
</template>
<script>
import { ref } from 'vue'
import useVirtualList from '@/hooks/useVirtualList'
export default {
name: 'VirtualTable',
setup() {
const scrollContainer = ref(null)
const actualContainer = ref(null)
const translateContainer = ref(null)
const tableData = ref(Array.from({ length: 10000 }, (_, index) => ({
id: index,
name: `用户${index}`,
description: `这是一段不定长的描述文本,可能会有${Math.random() > 0.5 ? '很长很长的内容。'.repeat(Math.floor(Math.random() * 5)) : '短文本'}`,
status: Math.random() > 0.5 ? '活跃' : '离线'
})))
const { actualRenderData } = useVirtualList({
data: tableData,
scrollContainer: '.virtual-table-container',
actualHeightContainer: '.virtual-table-actual',
translateContainer: '.virtual-table-translate',
itemContainer: '.el-table__row',
itemHeight: 50,
bufferRatio: 1
})
return {
actualRenderData
}
}
}
</script>
<style scoped>
.virtual-table-container {
height: 400px;
overflow-y: auto;
}
.virtual-table-actual {
position: relative;
}
.virtual-table-translate {
position: absolute;
width: 100%;
left: 0;
}
.dynamic-height-cell {
padding: 8px 0;
line-height: 1.5;
white-space: pre-wrap;
}
</style>