Skip to content

Commit a20c211

Browse files
committed
perf(frontend): add virtual scrolling to DataTable
Replace direct row rendering with @tanstack/vue-virtual. The table now only renders visible rows (~20) via padding <tr> placeholders, eliminating the rendering bottleneck when displaying 100+ rows with heavy cell components. Key changes: - DataTable.vue: integrate useVirtualizer (always-on), virtual row template with measureElement for variable row heights, defineExpose virtualizer/sortedData for external access, overflow-y/flex CSS - useSwipeSelect.ts: dual-mode support via optional SwipeSelectVirtualContext — data-driven row index lookup and selection range when virtualizer is present, original DOM-based path preserved for callers that don't pass virtualContext
1 parent 9f6ab6b commit a20c211

4 files changed

Lines changed: 268 additions & 44 deletions

File tree

frontend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
},
1717
"dependencies": {
1818
"@lobehub/icons": "^4.0.2",
19+
"@tanstack/vue-virtual": "^3.13.23",
1920
"@vueuse/core": "^10.7.0",
2021
"axios": "^1.13.5",
2122
"chart.js": "^4.4.1",

frontend/pnpm-lock.yaml

Lines changed: 18 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/src/components/common/DataTable.vue

Lines changed: 81 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -147,35 +147,54 @@
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">
178196
import { computed, ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
197+
import { useVirtualizer } from '@tanstack/vue-virtual'
179198
import { useI18n } from 'vue-i18n'
180199
import type { Column } from './types'
181200
import 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
304327
const 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+
502552
const 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

Comments
 (0)