mirror of
https://gitee.com/wanwujie/sub2api
synced 2026-04-02 22:42:14 +08:00
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
This commit is contained in:
@@ -16,6 +16,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@lobehub/icons": "^4.0.2",
|
||||
"@tanstack/vue-virtual": "^3.13.23",
|
||||
"@vueuse/core": "^10.7.0",
|
||||
"axios": "^1.13.5",
|
||||
"chart.js": "^4.4.1",
|
||||
|
||||
18
frontend/pnpm-lock.yaml
generated
18
frontend/pnpm-lock.yaml
generated
@@ -11,6 +11,9 @@ importers:
|
||||
'@lobehub/icons':
|
||||
specifier: ^4.0.2
|
||||
version: 4.0.2(@lobehub/ui@4.9.2)(@types/react@19.2.7)(antd@6.1.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@tanstack/vue-virtual':
|
||||
specifier: ^3.13.23
|
||||
version: 3.13.23(vue@3.5.26(typescript@5.6.3))
|
||||
'@vueuse/core':
|
||||
specifier: ^10.7.0
|
||||
version: 10.11.1(vue@3.5.26(typescript@5.6.3))
|
||||
@@ -1376,6 +1379,14 @@ packages:
|
||||
peerDependencies:
|
||||
react: '>= 16.3.0'
|
||||
|
||||
'@tanstack/virtual-core@3.13.23':
|
||||
resolution: {integrity: sha512-zSz2Z2HNyLjCplANTDyl3BcdQJc2k1+yyFoKhNRmCr7V7dY8o8q5m8uFTI1/Pg1kL+Hgrz6u3Xo6eFUB7l66cg==}
|
||||
|
||||
'@tanstack/vue-virtual@3.13.23':
|
||||
resolution: {integrity: sha512-b5jPluAR6U3eOq6GWAYSpj3ugnAIZgGR0e6aGAgyRse0Yu6MVQQ0ZWm9SArSXWtageogn6bkVD8D//c4IjW3xQ==}
|
||||
peerDependencies:
|
||||
vue: ^2.7.0 || ^3.0.0
|
||||
|
||||
'@types/d3-array@3.2.2':
|
||||
resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
|
||||
|
||||
@@ -5808,6 +5819,13 @@ snapshots:
|
||||
dependencies:
|
||||
react: 19.2.3
|
||||
|
||||
'@tanstack/virtual-core@3.13.23': {}
|
||||
|
||||
'@tanstack/vue-virtual@3.13.23(vue@3.5.26(typescript@5.6.3))':
|
||||
dependencies:
|
||||
'@tanstack/virtual-core': 3.13.23
|
||||
vue: 3.5.26(typescript@5.6.3)
|
||||
|
||||
'@types/d3-array@3.2.2': {}
|
||||
|
||||
'@types/d3-axis@3.0.6':
|
||||
|
||||
@@ -147,28 +147,46 @@
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Data rows -->
|
||||
<tr
|
||||
v-else
|
||||
v-for="(row, index) in sortedData"
|
||||
:key="resolveRowKey(row, index)"
|
||||
:data-row-id="resolveRowKey(row, index)"
|
||||
class="hover:bg-gray-50 dark:hover:bg-dark-800"
|
||||
>
|
||||
<td
|
||||
v-for="(column, colIndex) in columns"
|
||||
:key="column.key"
|
||||
:class="[
|
||||
'whitespace-nowrap py-4 text-sm text-gray-900 dark:text-gray-100',
|
||||
getAdaptivePaddingClass(),
|
||||
getStickyColumnClass(column, colIndex)
|
||||
]"
|
||||
<!-- Data rows (virtual scroll) -->
|
||||
<template v-else>
|
||||
<tr v-if="virtualPaddingTop > 0" aria-hidden="true">
|
||||
<td :colspan="columns.length"
|
||||
:style="{ height: virtualPaddingTop + 'px', padding: 0, border: 'none' }">
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
v-for="virtualRow in virtualItems"
|
||||
:key="resolveRowKey(sortedData[virtualRow.index], virtualRow.index)"
|
||||
:data-row-id="resolveRowKey(sortedData[virtualRow.index], virtualRow.index)"
|
||||
:data-index="virtualRow.index"
|
||||
:ref="measureElement"
|
||||
class="hover:bg-gray-50 dark:hover:bg-dark-800"
|
||||
>
|
||||
<slot :name="`cell-${column.key}`" :row="row" :value="row[column.key]" :expanded="actionsExpanded">
|
||||
{{ column.formatter ? column.formatter(row[column.key], row) : row[column.key] }}
|
||||
</slot>
|
||||
</td>
|
||||
</tr>
|
||||
<td
|
||||
v-for="(column, colIndex) in columns"
|
||||
:key="column.key"
|
||||
:class="[
|
||||
'whitespace-nowrap py-4 text-sm text-gray-900 dark:text-gray-100',
|
||||
getAdaptivePaddingClass(),
|
||||
getStickyColumnClass(column, colIndex)
|
||||
]"
|
||||
>
|
||||
<slot :name="`cell-${column.key}`"
|
||||
:row="sortedData[virtualRow.index]"
|
||||
:value="sortedData[virtualRow.index][column.key]"
|
||||
:expanded="actionsExpanded">
|
||||
{{ column.formatter
|
||||
? column.formatter(sortedData[virtualRow.index][column.key], sortedData[virtualRow.index])
|
||||
: sortedData[virtualRow.index][column.key] }}
|
||||
</slot>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="virtualPaddingBottom > 0" aria-hidden="true">
|
||||
<td :colspan="columns.length"
|
||||
:style="{ height: virtualPaddingBottom + 'px', padding: 0, border: 'none' }">
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -176,6 +194,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||
import { useVirtualizer } from '@tanstack/vue-virtual'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { Column } from './types'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
@@ -299,6 +318,10 @@ interface Props {
|
||||
* will emit 'sort' events instead of performing client-side sorting.
|
||||
*/
|
||||
serverSideSort?: boolean
|
||||
/** Estimated row height in px for the virtualizer (default 56) */
|
||||
estimateRowHeight?: number
|
||||
/** Number of rows to render beyond the visible area (default 5) */
|
||||
overscan?: number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
@@ -499,6 +522,33 @@ const sortedData = computed(() => {
|
||||
.map(item => item.row)
|
||||
})
|
||||
|
||||
// --- Virtual scrolling ---
|
||||
const rowVirtualizer = useVirtualizer(computed(() => ({
|
||||
count: sortedData.value?.length ?? 0,
|
||||
getScrollElement: () => tableWrapperRef.value,
|
||||
estimateSize: () => props.estimateRowHeight ?? 56,
|
||||
overscan: props.overscan ?? 5,
|
||||
})))
|
||||
|
||||
const virtualItems = computed(() => rowVirtualizer.value.getVirtualItems())
|
||||
|
||||
const virtualPaddingTop = computed(() => {
|
||||
const items = virtualItems.value
|
||||
return items.length > 0 ? items[0].start : 0
|
||||
})
|
||||
|
||||
const virtualPaddingBottom = computed(() => {
|
||||
const items = virtualItems.value
|
||||
if (items.length === 0) return 0
|
||||
return rowVirtualizer.value.getTotalSize() - items[items.length - 1].end
|
||||
})
|
||||
|
||||
const measureElement = (el: any) => {
|
||||
if (el) {
|
||||
rowVirtualizer.value.measureElement(el as Element)
|
||||
}
|
||||
}
|
||||
|
||||
const hasActionsColumn = computed(() => {
|
||||
return props.columns.some(column => column.key === 'actions')
|
||||
})
|
||||
@@ -595,6 +645,13 @@ watch(
|
||||
},
|
||||
{ flush: 'post' }
|
||||
)
|
||||
|
||||
defineExpose({
|
||||
virtualizer: rowVirtualizer,
|
||||
sortedData,
|
||||
resolveRowKey,
|
||||
tableWrapperEl: tableWrapperRef,
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -603,6 +660,9 @@ watch(
|
||||
--select-col-width: 52px; /* 勾选列宽度:px-6 (24px*2) + checkbox (16px) */
|
||||
position: relative;
|
||||
overflow-x: auto;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ref, onMounted, onUnmounted, type Ref } from 'vue'
|
||||
import type { Virtualizer } from '@tanstack/vue-virtual'
|
||||
|
||||
/**
|
||||
* WeChat-style swipe/drag to select rows in a DataTable,
|
||||
@@ -25,11 +26,22 @@ export interface SwipeSelectAdapter {
|
||||
isSelected: (id: number) => boolean
|
||||
select: (id: number) => void
|
||||
deselect: (id: number) => void
|
||||
batchUpdate?: (updater: (draft: Set<number>) => void) => void
|
||||
}
|
||||
|
||||
export interface SwipeSelectVirtualContext {
|
||||
/** Get the virtualizer instance */
|
||||
getVirtualizer: () => Virtualizer<HTMLElement, Element> | null
|
||||
/** Get all sorted data */
|
||||
getSortedData: () => any[]
|
||||
/** Get row ID from data row */
|
||||
getRowId: (row: any, index: number) => number
|
||||
}
|
||||
|
||||
export function useSwipeSelect(
|
||||
containerRef: Ref<HTMLElement | null>,
|
||||
adapter: SwipeSelectAdapter
|
||||
adapter: SwipeSelectAdapter,
|
||||
virtualContext?: SwipeSelectVirtualContext
|
||||
) {
|
||||
const isDragging = ref(false)
|
||||
|
||||
@@ -95,6 +107,32 @@ export function useSwipeSelect(
|
||||
return (clientY - rHi.bottom < rLo.top - clientY) ? hi : lo
|
||||
}
|
||||
|
||||
/** Virtual mode: find row index from Y coordinate using virtualizer data */
|
||||
function findRowIndexAtYVirtual(clientY: number): number {
|
||||
const virt = virtualContext!.getVirtualizer()
|
||||
if (!virt) return -1
|
||||
const scrollEl = virt.scrollElement
|
||||
if (!scrollEl) return -1
|
||||
|
||||
const scrollRect = scrollEl.getBoundingClientRect()
|
||||
const thead = scrollEl.querySelector('thead')
|
||||
const theadHeight = thead ? thead.getBoundingClientRect().height : 0
|
||||
const contentY = clientY - scrollRect.top - theadHeight + scrollEl.scrollTop
|
||||
|
||||
// Search in rendered virtualItems first
|
||||
const items = virt.getVirtualItems()
|
||||
for (const item of items) {
|
||||
if (contentY >= item.start && contentY < item.end) return item.index
|
||||
}
|
||||
|
||||
// Outside visible range: estimate
|
||||
const totalCount = virtualContext!.getSortedData().length
|
||||
if (totalCount === 0) return -1
|
||||
const est = virt.options.estimateSize(0)
|
||||
const guess = Math.floor(contentY / est)
|
||||
return Math.max(0, Math.min(totalCount - 1, guess))
|
||||
}
|
||||
|
||||
// --- Prevent text selection via selectstart (no body style mutation) ---
|
||||
function onSelectStart(e: Event) { e.preventDefault() }
|
||||
|
||||
@@ -140,16 +178,68 @@ export function useSwipeSelect(
|
||||
const lo = Math.min(rangeMin, prevMin)
|
||||
const hi = Math.max(rangeMax, prevMax)
|
||||
|
||||
for (let i = lo; i <= hi && i < cachedRows.length; i++) {
|
||||
const id = getRowId(cachedRows[i])
|
||||
if (id === null) continue
|
||||
if (i >= rangeMin && i <= rangeMax) {
|
||||
if (dragMode === 'select') adapter.select(id)
|
||||
else adapter.deselect(id)
|
||||
} else {
|
||||
const wasSelected = initialSelectedSnapshot.get(id) ?? false
|
||||
if (wasSelected) adapter.select(id)
|
||||
else adapter.deselect(id)
|
||||
if (adapter.batchUpdate) {
|
||||
adapter.batchUpdate((draft) => {
|
||||
for (let i = lo; i <= hi && i < cachedRows.length; i++) {
|
||||
const id = getRowId(cachedRows[i])
|
||||
if (id === null) continue
|
||||
const shouldBeSelected = (i >= rangeMin && i <= rangeMax)
|
||||
? (dragMode === 'select')
|
||||
: (initialSelectedSnapshot.get(id) ?? false)
|
||||
if (shouldBeSelected) draft.add(id)
|
||||
else draft.delete(id)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
for (let i = lo; i <= hi && i < cachedRows.length; i++) {
|
||||
const id = getRowId(cachedRows[i])
|
||||
if (id === null) continue
|
||||
if (i >= rangeMin && i <= rangeMax) {
|
||||
if (dragMode === 'select') adapter.select(id)
|
||||
else adapter.deselect(id)
|
||||
} else {
|
||||
const wasSelected = initialSelectedSnapshot.get(id) ?? false
|
||||
if (wasSelected) adapter.select(id)
|
||||
else adapter.deselect(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
lastEndIndex = endIndex
|
||||
}
|
||||
|
||||
/** Virtual mode: apply selection range using data array instead of DOM */
|
||||
function applyRangeVirtual(endIndex: number) {
|
||||
if (startRowIndex < 0 || endIndex < 0) return
|
||||
const rangeMin = Math.min(startRowIndex, endIndex)
|
||||
const rangeMax = Math.max(startRowIndex, endIndex)
|
||||
const prevMin = lastEndIndex >= 0 ? Math.min(startRowIndex, lastEndIndex) : rangeMin
|
||||
const prevMax = lastEndIndex >= 0 ? Math.max(startRowIndex, lastEndIndex) : rangeMax
|
||||
const lo = Math.min(rangeMin, prevMin)
|
||||
const hi = Math.max(rangeMax, prevMax)
|
||||
const data = virtualContext!.getSortedData()
|
||||
|
||||
if (adapter.batchUpdate) {
|
||||
adapter.batchUpdate((draft) => {
|
||||
for (let i = lo; i <= hi && i < data.length; i++) {
|
||||
const id = virtualContext!.getRowId(data[i], i)
|
||||
const shouldBeSelected = (i >= rangeMin && i <= rangeMax)
|
||||
? (dragMode === 'select')
|
||||
: (initialSelectedSnapshot.get(id) ?? false)
|
||||
if (shouldBeSelected) draft.add(id)
|
||||
else draft.delete(id)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
for (let i = lo; i <= hi && i < data.length; i++) {
|
||||
const id = virtualContext!.getRowId(data[i], i)
|
||||
if (i >= rangeMin && i <= rangeMax) {
|
||||
if (dragMode === 'select') adapter.select(id)
|
||||
else adapter.deselect(id)
|
||||
} else {
|
||||
const wasSelected = initialSelectedSnapshot.get(id) ?? false
|
||||
if (wasSelected) adapter.select(id)
|
||||
else adapter.deselect(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
lastEndIndex = endIndex
|
||||
@@ -234,8 +324,14 @@ export function useSwipeSelect(
|
||||
if (shouldPreferNativeTextSelection(target)) return
|
||||
if (shouldPreferNativeSelectionOutsideRows(target)) return
|
||||
|
||||
cachedRows = getDataRows()
|
||||
if (cachedRows.length === 0) return
|
||||
if (virtualContext) {
|
||||
// Virtual mode: check data availability instead of DOM rows
|
||||
const data = virtualContext.getSortedData()
|
||||
if (data.length === 0) return
|
||||
} else {
|
||||
cachedRows = getDataRows()
|
||||
if (cachedRows.length === 0) return
|
||||
}
|
||||
|
||||
pendingStartY = e.clientY
|
||||
// Prevent text selection as soon as the mouse is down,
|
||||
@@ -253,13 +349,19 @@ export function useSwipeSelect(
|
||||
document.removeEventListener('mousemove', onThresholdMove)
|
||||
document.removeEventListener('mouseup', onThresholdUp)
|
||||
|
||||
beginDrag(pendingStartY)
|
||||
if (virtualContext) {
|
||||
beginDragVirtual(pendingStartY)
|
||||
} else {
|
||||
beginDrag(pendingStartY)
|
||||
}
|
||||
|
||||
// Process the move that crossed the threshold
|
||||
lastMouseY = e.clientY
|
||||
updateMarquee(e.clientY)
|
||||
const rowIdx = findRowIndexAtY(e.clientY)
|
||||
if (rowIdx >= 0) applyRange(rowIdx)
|
||||
const findIdx = virtualContext ? findRowIndexAtYVirtual : findRowIndexAtY
|
||||
const apply = virtualContext ? applyRangeVirtual : applyRange
|
||||
const rowIdx = findIdx(e.clientY)
|
||||
if (rowIdx >= 0) apply(rowIdx)
|
||||
autoScroll(e)
|
||||
|
||||
document.addEventListener('mousemove', onMouseMove)
|
||||
@@ -306,22 +408,62 @@ export function useSwipeSelect(
|
||||
window.getSelection()?.removeAllRanges()
|
||||
}
|
||||
|
||||
/** Virtual mode: begin drag using data array */
|
||||
function beginDragVirtual(clientY: number) {
|
||||
startRowIndex = findRowIndexAtYVirtual(clientY)
|
||||
const data = virtualContext!.getSortedData()
|
||||
const startRowId = startRowIndex >= 0 && startRowIndex < data.length
|
||||
? virtualContext!.getRowId(data[startRowIndex], startRowIndex)
|
||||
: null
|
||||
dragMode = (startRowId !== null && adapter.isSelected(startRowId)) ? 'deselect' : 'select'
|
||||
|
||||
// Build full snapshot from all data rows
|
||||
initialSelectedSnapshot = new Map()
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const id = virtualContext!.getRowId(data[i], i)
|
||||
initialSelectedSnapshot.set(id, adapter.isSelected(id))
|
||||
}
|
||||
|
||||
isDragging.value = true
|
||||
startY = clientY
|
||||
lastMouseY = clientY
|
||||
lastEndIndex = -1
|
||||
|
||||
// In virtual mode, scroll parent is the virtualizer's scroll element
|
||||
const virt = virtualContext!.getVirtualizer()
|
||||
cachedScrollParent = virt?.scrollElement ?? (containerRef.value ? getScrollParent(containerRef.value) : null)
|
||||
|
||||
createMarquee()
|
||||
updateMarquee(clientY)
|
||||
applyRangeVirtual(startRowIndex)
|
||||
window.getSelection()?.removeAllRanges()
|
||||
}
|
||||
|
||||
let moveRAF = 0
|
||||
|
||||
function onMouseMove(e: MouseEvent) {
|
||||
if (!isDragging.value) return
|
||||
lastMouseY = e.clientY
|
||||
updateMarquee(e.clientY)
|
||||
const rowIdx = findRowIndexAtY(e.clientY)
|
||||
if (rowIdx >= 0 && rowIdx !== lastEndIndex) applyRange(rowIdx)
|
||||
const findIdx = virtualContext ? findRowIndexAtYVirtual : findRowIndexAtY
|
||||
const apply = virtualContext ? applyRangeVirtual : applyRange
|
||||
cancelAnimationFrame(moveRAF)
|
||||
moveRAF = requestAnimationFrame(() => {
|
||||
updateMarquee(lastMouseY)
|
||||
const rowIdx = findIdx(lastMouseY)
|
||||
if (rowIdx >= 0 && rowIdx !== lastEndIndex) apply(rowIdx)
|
||||
})
|
||||
autoScroll(e)
|
||||
}
|
||||
|
||||
function onWheel() {
|
||||
if (!isDragging.value) return
|
||||
const findIdx = virtualContext ? findRowIndexAtYVirtual : findRowIndexAtY
|
||||
const apply = virtualContext ? applyRangeVirtual : applyRange
|
||||
// After wheel scroll, rows shift in viewport — re-check selection
|
||||
requestAnimationFrame(() => {
|
||||
if (!isDragging.value) return // guard: drag may have ended before this frame
|
||||
const rowIdx = findRowIndexAtY(lastMouseY)
|
||||
if (rowIdx >= 0) applyRange(rowIdx)
|
||||
const rowIdx = findIdx(lastMouseY)
|
||||
if (rowIdx >= 0) apply(rowIdx)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -332,6 +474,7 @@ export function useSwipeSelect(
|
||||
cachedRows = []
|
||||
initialSelectedSnapshot.clear()
|
||||
cachedScrollParent = null
|
||||
cancelAnimationFrame(moveRAF)
|
||||
stopAutoScroll()
|
||||
removeMarquee()
|
||||
document.removeEventListener('selectstart', onSelectStart)
|
||||
@@ -372,13 +515,15 @@ export function useSwipeSelect(
|
||||
}
|
||||
|
||||
if (dy !== 0) {
|
||||
const findIdx = virtualContext ? findRowIndexAtYVirtual : findRowIndexAtY
|
||||
const apply = virtualContext ? applyRangeVirtual : applyRange
|
||||
const step = () => {
|
||||
const prevScrollTop = scrollEl.scrollTop
|
||||
scrollEl.scrollTop += dy
|
||||
// Only re-check selection if scroll actually moved
|
||||
if (scrollEl.scrollTop !== prevScrollTop) {
|
||||
const rowIdx = findRowIndexAtY(lastMouseY)
|
||||
if (rowIdx >= 0 && rowIdx !== lastEndIndex) applyRange(rowIdx)
|
||||
const rowIdx = findIdx(lastMouseY)
|
||||
if (rowIdx >= 0 && rowIdx !== lastEndIndex) apply(rowIdx)
|
||||
}
|
||||
scrollRAF = requestAnimationFrame(step)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user