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"
3935</template >
4036
4137<script setup lang="ts">
42- import { computed , ref , watch , onMounted , onUpdated , nextTick } from " vue" ;
4338import { NScrollbar } from " naive-ui" ;
4439import { useElementSize } from " @vueuse/core" ;
4540
@@ -76,12 +71,10 @@ const emit = defineEmits<{
7671 (e : " reachBottom" ): void ;
7772}>();
7873
79- // 容器引用(现在指向外层div)
8074const wrapperRef = ref <HTMLElement | null >(null );
81- // 滚动条引用
8275const scrollbarRef = ref <InstanceType <typeof NScrollbar > | null >(null );
8376
84- // 使用 VueUse 测量外层容器高度,而不是尝试测量 NScrollbar 的实例
77+ // 测量外层容器高度
8578const { height : containerHeight } = useElementSize (wrapperRef );
8679
8780// 项目元素引用
@@ -91,9 +84,9 @@ const itemRefs = ref<HTMLElement[]>([]);
9184const 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// 当前可见的起始索引
9992const actualStartIndex = ref (0 );
@@ -113,23 +106,20 @@ const viewportHeight = computed(() => {
113106
114107// 初始化高度数组
115108const 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// 更新累积高度
131121const 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+ // 测量项目高度
231219const 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 ;
264251const 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+ // 监听数据长度变化
331328watch (
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// 组件挂载后初始化
345353onMounted (() => {
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