147147 </td >
148148 </tr >
149149
150- <!-- Data rows -->
151- <tr
152- v-else
153- v-for =" (row, index) in sortedData"
154- :key =" resolveRowKey(row, index)"
155- :data-row-id =" resolveRowKey(row, index)"
156- class =" hover:bg-gray-50 dark:hover:bg-dark-800"
157- >
158- <td
159- v-for =" (column, colIndex) in columns"
160- :key =" column.key"
161- :class =" [
162- 'whitespace-nowrap py-4 text-sm text-gray-900 dark:text-gray-100',
163- getAdaptivePaddingClass(),
164- getStickyColumnClass(column, colIndex)
165- ]"
150+ <!-- Data rows (virtual scroll) -->
151+ <template v-else >
152+ <tr v-if =" virtualPaddingTop > 0" aria-hidden =" true" >
153+ <td :colspan =" columns.length"
154+ :style =" { height: virtualPaddingTop + 'px', padding: 0, border: 'none' }" >
155+ </td >
156+ </tr >
157+ <tr
158+ v-for =" virtualRow in virtualItems"
159+ :key =" resolveRowKey(sortedData[virtualRow.index], virtualRow.index)"
160+ :data-row-id =" resolveRowKey(sortedData[virtualRow.index], virtualRow.index)"
161+ :data-index =" virtualRow.index"
162+ :ref =" measureElement"
163+ class =" hover:bg-gray-50 dark:hover:bg-dark-800"
166164 >
167- <slot :name =" `cell-${column.key}`" :row =" row" :value =" row[column.key]" :expanded =" actionsExpanded" >
168- {{ column.formatter ? column.formatter(row[column.key], row) : row[column.key] }}
169- </slot >
170- </td >
171- </tr >
165+ <td
166+ v-for =" (column, colIndex) in columns"
167+ :key =" column.key"
168+ :class =" [
169+ 'whitespace-nowrap py-4 text-sm text-gray-900 dark:text-gray-100',
170+ getAdaptivePaddingClass(),
171+ getStickyColumnClass(column, colIndex)
172+ ]"
173+ >
174+ <slot :name =" `cell-${column.key}`"
175+ :row =" sortedData[virtualRow.index]"
176+ :value =" sortedData[virtualRow.index][column.key]"
177+ :expanded =" actionsExpanded" >
178+ {{ column.formatter
179+ ? column.formatter(sortedData[virtualRow.index][column.key], sortedData[virtualRow.index])
180+ : sortedData[virtualRow.index][column.key] }}
181+ </slot >
182+ </td >
183+ </tr >
184+ <tr v-if =" virtualPaddingBottom > 0" aria-hidden =" true" >
185+ <td :colspan =" columns.length"
186+ :style =" { height: virtualPaddingBottom + 'px', padding: 0, border: 'none' }" >
187+ </td >
188+ </tr >
189+ </template >
172190 </tbody >
173191 </table >
174192 </div >
175193</template >
176194
177195<script setup lang="ts">
178196import { computed , ref , onMounted , onUnmounted , watch , nextTick } from ' vue'
197+ import { useVirtualizer } from ' @tanstack/vue-virtual'
179198import { useI18n } from ' vue-i18n'
180199import type { Column } from ' ./types'
181200import Icon from ' @/components/icons/Icon.vue'
@@ -299,6 +318,10 @@ interface Props {
299318 * will emit 'sort' events instead of performing client-side sorting.
300319 */
301320 serverSideSort? : boolean
321+ /** Estimated row height in px for the virtualizer (default 56) */
322+ estimateRowHeight? : number
323+ /** Number of rows to render beyond the visible area (default 5) */
324+ overscan? : number
302325}
303326
304327const props = withDefaults (defineProps <Props >(), {
@@ -499,6 +522,33 @@ const sortedData = computed(() => {
499522 .map (item => item .row )
500523})
501524
525+ // --- Virtual scrolling ---
526+ const rowVirtualizer = useVirtualizer (computed (() => ({
527+ count: sortedData .value ?.length ?? 0 ,
528+ getScrollElement : () => tableWrapperRef .value ,
529+ estimateSize : () => props .estimateRowHeight ?? 56 ,
530+ overscan: props .overscan ?? 5 ,
531+ })))
532+
533+ const virtualItems = computed (() => rowVirtualizer .value .getVirtualItems ())
534+
535+ const virtualPaddingTop = computed (() => {
536+ const items = virtualItems .value
537+ return items .length > 0 ? items [0 ].start : 0
538+ })
539+
540+ const virtualPaddingBottom = computed (() => {
541+ const items = virtualItems .value
542+ if (items .length === 0 ) return 0
543+ return rowVirtualizer .value .getTotalSize () - items [items .length - 1 ].end
544+ })
545+
546+ const measureElement = (el : any ) => {
547+ if (el ) {
548+ rowVirtualizer .value .measureElement (el as Element )
549+ }
550+ }
551+
502552const hasActionsColumn = computed (() => {
503553 return props .columns .some (column => column .key === ' actions' )
504554})
@@ -595,6 +645,13 @@ watch(
595645 },
596646 { flush: ' post' }
597647)
648+
649+ defineExpose ({
650+ virtualizer: rowVirtualizer ,
651+ sortedData ,
652+ resolveRowKey ,
653+ tableWrapperEl: tableWrapperRef ,
654+ })
598655 </script >
599656
600657<style scoped>
@@ -603,6 +660,9 @@ watch(
603660 --select-col-width : 52px ; /* 勾选列宽度:px-6 (24px*2) + checkbox (16px) */
604661 position : relative ;
605662 overflow-x : auto ;
663+ overflow-y : auto ;
664+ flex : 1 ;
665+ min-height : 0 ;
606666 isolation : isolate ;
607667}
608668
0 commit comments