Skip to content

Commit 2554a38

Browse files
committed
✨ feat: 优化虚拟滚动组件
1 parent b14b29f commit 2554a38

2 files changed

Lines changed: 63 additions & 58 deletions

File tree

src/components/Layout/Menu.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ const menuOptions = computed<MenuOption[] | MenuGroupOption[]>(() => {
105105
h(NText, null, () => "我喜欢的音乐"),
106106
!settingStore.hideHeartbeatMode
107107
? h(NButton, {
108-
type: "primary",
108+
type: statusStore.playHeartbeatMode ? "primary" : "default",
109109
round: true,
110110
strong: true,
111111
secondary: true,

src/components/UI/VirtualScroll.vue

Lines changed: 62 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,5 @@
11
<template>
2-
<div
3-
ref="wrapperRef"
4-
class="virtual-scroll-wrapper"
5-
:style="{ height: containerHeightStyle }"
6-
>
2+
<div ref="wrapperRef" class="virtual-scroll-wrapper" :style="{ height: containerHeightStyle }">
73
<n-scrollbar
84
ref="scrollbarRef"
95
class="custom-virtual-list"
@@ -39,7 +35,6 @@
3935
</template>
4036

4137
<script setup lang="ts">
42-
import { computed, ref, watch, onMounted, onUpdated, nextTick } from "vue";
4338
import { NScrollbar } from "naive-ui";
4439
import { useElementSize } from "@vueuse/core";
4540
@@ -76,12 +71,10 @@ const emit = defineEmits<{
7671
(e: "reachBottom"): void;
7772
}>();
7873
79-
// 容器引用(现在指向外层div)
8074
const wrapperRef = ref<HTMLElement | null>(null);
81-
// 滚动条引用
8275
const scrollbarRef = ref<InstanceType<typeof NScrollbar> | null>(null);
8376
84-
// 使用 VueUse 测量外层容器高度,而不是尝试测量 NScrollbar 的实例
77+
// 测量外层容器高度
8578
const { height: containerHeight } = useElementSize(wrapperRef);
8679
8780
// 项目元素引用
@@ -91,9 +84,9 @@ const itemRefs = ref<HTMLElement[]>([]);
9184
const scrollTop = ref(0);
9285
9386
// 存储每个项目的实际高度
94-
const itemHeights = ref<number[]>([]);
87+
const itemHeights = shallowRef<number[]>([]);
9588
// 存储每个项目的累积高度(用于定位)
96-
const itemTops = ref<number[]>([]);
89+
const itemTops = shallowRef<number[]>([]);
9790
9891
// 当前可见的起始索引
9992
const actualStartIndex = ref(0);
@@ -113,23 +106,20 @@ const viewportHeight = computed(() => {
113106
114107
// 初始化高度数组
115108
const initializeHeights = () => {
116-
if (props.itemFixed) return; // 定高模式无需初始化数组
109+
if (props.itemFixed) return;
117110
118111
const length = props.items.length;
119112
// 如果之前已经有数据,尽量复用,否则重置
120113
if (itemHeights.value.length !== length) {
121114
const oldHeights = itemHeights.value;
122-
itemHeights.value = Array.from(
123-
{ length },
124-
(_, i) => oldHeights[i] || props.itemHeight,
125-
);
115+
itemHeights.value = Array.from({ length }, (_, i) => oldHeights[i] || props.itemHeight);
126116
}
127117
updateTops();
128118
};
129119
130120
// 更新累积高度
131121
const updateTops = () => {
132-
if (props.itemFixed) return; // 定高模式无需更新累积高度数组
122+
if (props.itemFixed) return;
133123
134124
itemTops.value = [];
135125
let top = 0;
@@ -146,11 +136,7 @@ const totalHeight = computed(() => {
146136
}
147137
if (itemTops.value.length === 0) return props.paddingBottom;
148138
const lastIndex = itemTops.value.length - 1;
149-
return (
150-
itemTops.value[lastIndex] +
151-
itemHeights.value[lastIndex] +
152-
props.paddingBottom
153-
);
139+
return itemTops.value[lastIndex] + itemHeights.value[lastIndex] + props.paddingBottom;
154140
});
155141
156142
// 计算可见区域
@@ -168,12 +154,12 @@ const calculateVisibleRange = (currentScrollTop: number) => {
168154
let endIndex = 0;
169155
170156
if (props.itemFixed) {
171-
// 定高模式:直接数学计算
157+
// 定高模式
172158
startIndex = Math.floor(currentScrollTop / props.itemHeight);
173159
const visibleCount = Math.ceil(vHeight / props.itemHeight);
174160
endIndex = startIndex + visibleCount;
175161
} else {
176-
// 动态高度模式:二分查找
162+
// 动态高度模式
177163
let start = 0;
178164
let end = itemTops.value.length - 1;
179165
@@ -204,11 +190,13 @@ const calculateVisibleRange = (currentScrollTop: number) => {
204190
}
205191
206192
// 应用缓冲区
207-
actualStartIndex.value = Math.max(0, startIndex - props.bufferSize);
208-
actualEndIndex.value = Math.min(
209-
props.items.length - 1,
210-
endIndex + props.bufferSize,
211-
);
193+
const newStart = Math.max(0, startIndex - props.bufferSize);
194+
const newEnd = Math.min(props.items.length - 1, endIndex + props.bufferSize);
195+
196+
if (newStart !== actualStartIndex.value || newEnd !== actualEndIndex.value) {
197+
actualStartIndex.value = newStart;
198+
actualEndIndex.value = newEnd;
199+
}
212200
};
213201
214202
// 可见项
@@ -227,9 +215,9 @@ const offsetY = computed(() => {
227215
return itemTops.value[actualStartIndex.value];
228216
});
229217
230-
// 测量项目高度(保留动态高度支持)
218+
// 测量项目高度
231219
const measureItemHeights = () => {
232-
if (props.itemFixed) return; // 定高模式无需测量
220+
if (props.itemFixed) return;
233221
if (!itemRefs.value.length || props.items.length === 0) return;
234222
235223
let hasChanges = false;
@@ -240,13 +228,10 @@ const measureItemHeights = () => {
240228
if (actualIndex < 0 || actualIndex >= props.items.length) return;
241229
242230
try {
243-
const height = el.getBoundingClientRect().height; // 使用 getBoundingClientRect 更精确
231+
const height = el.getBoundingClientRect().height;
244232
245233
// 允许 1px 误差,避免频繁更新
246-
if (
247-
height > 0 &&
248-
Math.abs(height - itemHeights.value[actualIndex]) > 0.5
249-
) {
234+
if (height > 0 && Math.abs(height - itemHeights.value[actualIndex]) > 0.5) {
250235
itemHeights.value[actualIndex] = height;
251236
hasChanges = true;
252237
}
@@ -256,23 +241,33 @@ const measureItemHeights = () => {
256241
});
257242
258243
if (hasChanges) {
244+
triggerRef(itemHeights);
259245
updateTops();
260246
}
261247
};
262248
263249
// 处理滚动事件
250+
let ticking = false;
264251
const handleScroll = (event: Event) => {
265252
const target = event.target as HTMLElement;
266-
if (target) {
267-
const { scrollTop: st, scrollHeight, clientHeight } = target;
268-
scrollTop.value = st;
269-
calculateVisibleRange(st);
270-
emit("scroll", event);
271-
272-
// 触底检测
273-
if (scrollHeight - st - clientHeight < 50) {
274-
emit("reachBottom");
275-
}
253+
if (!target) return;
254+
255+
// 触发外部事件
256+
emit("scroll", event);
257+
258+
if (!ticking) {
259+
requestAnimationFrame(() => {
260+
const { scrollTop: st, scrollHeight, clientHeight } = target;
261+
scrollTop.value = st;
262+
calculateVisibleRange(st);
263+
264+
// 触底检测
265+
if (scrollHeight - st - clientHeight < 50) {
266+
emit("reachBottom");
267+
}
268+
ticking = false;
269+
});
270+
ticking = true;
276271
}
277272
};
278273
@@ -323,11 +318,13 @@ watch(
323318
() => {
324319
initializeHeights();
325320
calculateVisibleRange(scrollTop.value);
321+
// 重新测量高度
322+
nextTick(measureItemHeights);
326323
},
327324
{ deep: false },
328-
); // items 引用变化时触发
325+
);
329326
330-
// 监听数据长度变化(针对数组变更)
327+
// 监听数据长度变化
331328
watch(
332329
() => props.items.length,
333330
() => {
@@ -341,6 +338,17 @@ watch(viewportHeight, () => {
341338
calculateVisibleRange(scrollTop.value);
342339
});
343340
341+
// 监听可见区域变化,仅在非定高模式下触发测量
342+
watch(
343+
() => [actualStartIndex.value, actualEndIndex.value],
344+
() => {
345+
if (!props.itemFixed) {
346+
nextTick(measureItemHeights);
347+
}
348+
},
349+
{ flush: "post" },
350+
);
351+
344352
// 组件挂载后初始化
345353
onMounted(() => {
346354
initializeHeights();
@@ -350,26 +358,23 @@ onMounted(() => {
350358
if (props.defaultScrollIndex) {
351359
scrollToIndex(props.defaultScrollIndex);
352360
}
353-
});
354-
});
355-
356-
// 组件更新后测量高度
357-
onUpdated(() => {
358-
nextTick(() => {
359-
measureItemHeights();
361+
// 初始测量
362+
if (!props.itemFixed) measureItemHeights();
360363
});
361364
});
362365
</script>
363366

364367
<style scoped>
365368
.virtual-scroll-wrapper {
366369
width: 100%;
370+
contain: layout paint;
371+
overflow-anchor: none;
367372
}
368373
.custom-virtual-list {
369-
will-change: transform; /* 优化滚动性能 */
374+
/* will-change: transform; */
370375
width: 100%;
371376
}
372377
.virtual-item {
373-
box-sizing: border-box;
378+
contain: layout paint;
374379
}
375380
</style>

0 commit comments

Comments
 (0)