mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
Merge pull request #4048 from iMouseWu/fix/datatable-scroll-jank
fix(frontend): 小数据量关闭 DataTable 虚拟化并按行主键缓存行高,消除账号列表滚动抖动
This commit is contained in:
@@ -154,7 +154,7 @@
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Data rows (virtual scroll) -->
|
||||
<!-- Data rows: windowed when large, fully rendered when small (shared row/cell template) -->
|
||||
<template v-else>
|
||||
<tr v-if="virtualPaddingTop > 0" aria-hidden="true">
|
||||
<td :colspan="columns.length"
|
||||
@@ -162,14 +162,14 @@
|
||||
</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"
|
||||
v-for="item in renderRows"
|
||||
:key="resolveRowKey(item.row, item.index)"
|
||||
:data-row-id="resolveRowKey(item.row, item.index)"
|
||||
:data-index="item.index"
|
||||
:ref="item.measure ? measureElement : undefined"
|
||||
class="hover:bg-gray-50 dark:hover:bg-dark-800"
|
||||
:class="{ 'cursor-pointer': clickableRows }"
|
||||
@click="clickableRows && emit('rowClick', sortedData[virtualRow.index])"
|
||||
@click="clickableRows && emit('rowClick', item.row)"
|
||||
>
|
||||
<td
|
||||
v-for="(column, colIndex) in columns"
|
||||
@@ -182,12 +182,12 @@
|
||||
]"
|
||||
>
|
||||
<slot :name="`cell-${column.key}`"
|
||||
:row="sortedData[virtualRow.index]"
|
||||
:value="sortedData[virtualRow.index][column.key]"
|
||||
:row="item.row"
|
||||
:value="item.row[column.key]"
|
||||
:expanded="actionsExpanded">
|
||||
{{ column.formatter
|
||||
? column.formatter(sortedData[virtualRow.index][column.key], sortedData[virtualRow.index])
|
||||
: sortedData[virtualRow.index][column.key] }}
|
||||
? column.formatter(item.row[column.key], item.row)
|
||||
: item.row[column.key] }}
|
||||
</slot>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -397,6 +397,12 @@ interface Props {
|
||||
estimateRowHeight?: number
|
||||
/** Number of rows to render beyond the visible area (default 5) */
|
||||
overscan?: number
|
||||
/**
|
||||
* Only virtualize when the row count exceeds this threshold (default 100).
|
||||
* Smaller lists render in full, avoiding the scroll-compensation jank caused by
|
||||
* estimated-vs-actual row heights when rows have variable height.
|
||||
*/
|
||||
virtualizeThreshold?: number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
@@ -627,9 +633,21 @@ const sortedData = computed(() => {
|
||||
})
|
||||
|
||||
// --- Virtual scrolling ---
|
||||
// 是否启用虚拟化:仅桌面端且行数超过阈值时开启。小列表全量渲染,彻底绕开虚拟器的
|
||||
// 估算/测量/滚动补偿链路,消除可变行高导致的滚动抖动。
|
||||
const shouldVirtualize = computed(() =>
|
||||
isDesktopViewport.value && (sortedData.value?.length ?? 0) > (props.virtualizeThreshold ?? 100)
|
||||
)
|
||||
|
||||
const rowVirtualizer = useVirtualizer(computed(() => ({
|
||||
count: isDesktopViewport.value ? (sortedData.value?.length ?? 0) : 0,
|
||||
count: shouldVirtualize.value ? (sortedData.value?.length ?? 0) : 0,
|
||||
getScrollElement: () => tableWrapperRef.value,
|
||||
// 用行主键(与模板 :key 一致)而非默认的 index 作为 itemSizeCache 键,
|
||||
// 这样排序/筛选/跨阈值来回都能复用正确的已测行高,而不是残留的按 index 缓存 → 消除高度校正抖动。
|
||||
getItemKey: (index: number) => {
|
||||
const row = sortedData.value?.[index]
|
||||
return row != null ? resolveRowKey(row, index) : index
|
||||
},
|
||||
estimateSize: () => props.estimateRowHeight ?? 56,
|
||||
overscan: props.overscan ?? 5,
|
||||
// 兜底高度:首个有效高度读数到来前,先按一屏渲染,避免空白帧
|
||||
@@ -659,6 +677,16 @@ const measureElement = (el: any) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 统一的渲染行列表:虚拟化开启时只取窗口内的行(需 measure 交给虚拟器测量),
|
||||
// 关闭时取全部行(无需测量)。模板据此渲染,两种模式共用同一套单元格结构。
|
||||
const renderRows = computed<Array<{ index: number; row: any; measure: boolean }>>(() => {
|
||||
const data = sortedData.value ?? []
|
||||
if (shouldVirtualize.value) {
|
||||
return virtualItems.value.map(vr => ({ index: vr.index, row: data[vr.index], measure: true }))
|
||||
}
|
||||
return data.map((row, index) => ({ index, row, measure: false }))
|
||||
})
|
||||
|
||||
const hasActionsColumn = computed(() => {
|
||||
return props.columns.some(column => column.key === 'actions')
|
||||
})
|
||||
@@ -758,6 +786,7 @@ watch(
|
||||
|
||||
defineExpose({
|
||||
virtualizer: rowVirtualizer,
|
||||
shouldVirtualize,
|
||||
sortedData,
|
||||
resolveRowKey,
|
||||
tableWrapperEl: tableWrapperRef,
|
||||
|
||||
@@ -62,4 +62,63 @@ describe('DataTable', () => {
|
||||
expect(nameHeader.findAll('svg')[0].classes()).toContain('text-gray-300')
|
||||
expect(nameHeader.findAll('svg')[1].classes()).toContain('text-primary-600')
|
||||
})
|
||||
|
||||
it('renders every row with no virtual padding spacer for small datasets (virtualization off)', async () => {
|
||||
const data = Array.from({ length: 8 }, (_, i) => ({ id: i + 1, name: `Row ${i + 1}` }))
|
||||
const wrapper = mount(DataTable, {
|
||||
props: {
|
||||
columns: [{ key: 'name', label: 'Name' }],
|
||||
data
|
||||
}
|
||||
})
|
||||
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
// Virtualization is OFF for a small list…
|
||||
expect((wrapper.vm as any).shouldVirtualize).toBe(false)
|
||||
// …every row is in the DOM…
|
||||
expect(wrapper.findAll('tbody tr[data-index]')).toHaveLength(data.length)
|
||||
// …and there are no aria-hidden virtual padding spacer rows.
|
||||
expect(wrapper.findAll('tbody tr[aria-hidden="true"]')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('switches to windowed rendering once row count exceeds virtualizeThreshold', async () => {
|
||||
const data = Array.from({ length: 12 }, (_, i) => ({ id: i + 1, name: `Row ${i + 1}` }))
|
||||
const wrapper = mount(DataTable, {
|
||||
props: {
|
||||
columns: [{ key: 'name', label: 'Name' }],
|
||||
data,
|
||||
virtualizeThreshold: 3
|
||||
}
|
||||
})
|
||||
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
// Virtualization is ON: the mode-switch decision flipped…
|
||||
expect((wrapper.vm as any).shouldVirtualize).toBe(true)
|
||||
// …and the virtualizer drives off the full row count.
|
||||
const exposed = (wrapper.vm as any).virtualizer
|
||||
const instance = exposed?.value ?? exposed
|
||||
expect(instance.options.count).toBe(data.length)
|
||||
})
|
||||
|
||||
it('keys the virtualizer size cache by row identity, not index (avoids stale heights on sort/filter)', async () => {
|
||||
const data = Array.from({ length: 12 }, (_, i) => ({ id: 100 + i, name: `Row ${i + 1}` }))
|
||||
const wrapper = mount(DataTable, {
|
||||
props: {
|
||||
columns: [{ key: 'name', label: 'Name' }],
|
||||
data,
|
||||
rowKey: 'id',
|
||||
virtualizeThreshold: 3
|
||||
}
|
||||
})
|
||||
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const exposed = (wrapper.vm as any).virtualizer
|
||||
const instance = exposed?.value ?? exposed
|
||||
// getItemKey must resolve to the row's stable key (id), not the positional index.
|
||||
expect(instance.options.getItemKey(0)).toBe(100)
|
||||
expect(instance.options.getItemKey(5)).toBe(105)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
import { findRowIndexByDomPosition } from '../useSwipeSelect'
|
||||
|
||||
/**
|
||||
* Build a fake scroll element whose `tbody tr[data-index]` rows expose stubbed
|
||||
* vertical rects, so we can exercise findRowIndexByDomPosition without a real DOM.
|
||||
* `index` is the value placed in the row's data-index attribute (its absolute
|
||||
* position in the sorted data), which need not equal the array position.
|
||||
*/
|
||||
function makeScrollEl(rows: Array<{ index: number; top: number; bottom: number }>): Element {
|
||||
const trs = rows.map((r) => ({
|
||||
getAttribute: (name: string) => (name === 'data-index' ? String(r.index) : null),
|
||||
getBoundingClientRect: () => ({
|
||||
top: r.top,
|
||||
bottom: r.bottom,
|
||||
left: 0,
|
||||
right: 0,
|
||||
width: 0,
|
||||
height: r.bottom - r.top,
|
||||
x: 0,
|
||||
y: r.top,
|
||||
toJSON: () => ({})
|
||||
})
|
||||
})) as unknown as HTMLElement[]
|
||||
|
||||
return {
|
||||
querySelectorAll: (sel: string) =>
|
||||
(sel === 'tbody tr[data-index]' ? trs : []) as unknown as NodeListOf<Element>
|
||||
} as unknown as Element
|
||||
}
|
||||
|
||||
describe('findRowIndexByDomPosition (swipe-select full-render fallback)', () => {
|
||||
// Variable row heights on purpose — the third row is taller.
|
||||
const rows = [
|
||||
{ index: 0, top: 100, bottom: 200 },
|
||||
{ index: 1, top: 200, bottom: 300 },
|
||||
{ index: 2, top: 300, bottom: 450 }
|
||||
]
|
||||
const el = makeScrollEl(rows)
|
||||
|
||||
it('returns -1 when no rows are rendered', () => {
|
||||
expect(findRowIndexByDomPosition(makeScrollEl([]), 250)).toBe(-1)
|
||||
})
|
||||
|
||||
it('locates the row whose rect contains the Y coordinate', () => {
|
||||
expect(findRowIndexByDomPosition(el, 150)).toBe(0)
|
||||
expect(findRowIndexByDomPosition(el, 250)).toBe(1)
|
||||
expect(findRowIndexByDomPosition(el, 400)).toBe(2) // inside the tall row
|
||||
})
|
||||
|
||||
it('clamps to the first/last row when Y is outside the rendered range', () => {
|
||||
expect(findRowIndexByDomPosition(el, 50)).toBe(0) // above the first row
|
||||
expect(findRowIndexByDomPosition(el, 999)).toBe(2) // below the last row
|
||||
})
|
||||
|
||||
it('picks the closer row when Y falls in a gap between rows', () => {
|
||||
const gapped = makeScrollEl([
|
||||
{ index: 0, top: 100, bottom: 180 },
|
||||
{ index: 1, top: 220, bottom: 300 }
|
||||
])
|
||||
expect(findRowIndexByDomPosition(gapped, 190)).toBe(0) // 10px from row0.bottom vs 30px from row1.top
|
||||
expect(findRowIndexByDomPosition(gapped, 215)).toBe(1) // 35px from row0.bottom vs 5px from row1.top
|
||||
})
|
||||
|
||||
it('returns the data-index attribute value, not the array position', () => {
|
||||
const remapped = makeScrollEl([
|
||||
{ index: 5, top: 100, bottom: 200 },
|
||||
{ index: 9, top: 200, bottom: 300 }
|
||||
])
|
||||
expect(findRowIndexByDomPosition(remapped, 150)).toBe(5)
|
||||
expect(findRowIndexByDomPosition(remapped, 250)).toBe(9)
|
||||
})
|
||||
})
|
||||
@@ -38,6 +38,41 @@ export interface SwipeSelectVirtualContext {
|
||||
getRowId: (row: any, index: number) => number
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate a row index from a viewport Y coordinate using the real DOM rows
|
||||
* (`tbody tr[data-index]`). Used when the virtualizer window is empty because the
|
||||
* list is small enough to be rendered in full (virtualization disabled). Rows are
|
||||
* vertically ordered, so this binary-searches — mirroring findRowIndexAtY — and
|
||||
* returns each row's `data-index` (its absolute index in the sorted data), or -1
|
||||
* when no rows are rendered. Exported for unit testing.
|
||||
*/
|
||||
export function findRowIndexByDomPosition(scrollEl: Element, clientY: number): number {
|
||||
const domRows = Array.from(scrollEl.querySelectorAll('tbody tr[data-index]')) as HTMLElement[]
|
||||
const len = domRows.length
|
||||
if (len === 0) return -1
|
||||
const idxOf = (el: HTMLElement) => Number(el.getAttribute('data-index'))
|
||||
|
||||
// Boundary checks
|
||||
if (clientY < domRows[0].getBoundingClientRect().top) return idxOf(domRows[0])
|
||||
if (clientY > domRows[len - 1].getBoundingClientRect().bottom) return idxOf(domRows[len - 1])
|
||||
|
||||
// Binary search — rows are vertically ordered
|
||||
let lo = 0, hi = len - 1
|
||||
while (lo <= hi) {
|
||||
const mid = (lo + hi) >>> 1
|
||||
const rect = domRows[mid].getBoundingClientRect()
|
||||
if (clientY < rect.top) hi = mid - 1
|
||||
else if (clientY > rect.bottom) lo = mid + 1
|
||||
else return idxOf(domRows[mid])
|
||||
}
|
||||
// In a gap between rows — pick the closer one
|
||||
if (hi < 0) return idxOf(domRows[0])
|
||||
if (lo >= len) return idxOf(domRows[len - 1])
|
||||
const rHi = domRows[hi].getBoundingClientRect()
|
||||
const rLo = domRows[lo].getBoundingClientRect()
|
||||
return (clientY - rHi.bottom < rLo.top - clientY) ? idxOf(domRows[hi]) : idxOf(domRows[lo])
|
||||
}
|
||||
|
||||
export function useSwipeSelect(
|
||||
containerRef: Ref<HTMLElement | null>,
|
||||
adapter: SwipeSelectAdapter,
|
||||
@@ -125,6 +160,13 @@ export function useSwipeSelect(
|
||||
if (contentY >= item.start && contentY < item.end) return item.index
|
||||
}
|
||||
|
||||
// Virtualization disabled (small list rendered in full): the window is empty, so
|
||||
// locate the row via real DOM rows instead of the (now-misleading) height estimate.
|
||||
if (items.length === 0) {
|
||||
const domIdx = findRowIndexByDomPosition(scrollEl, clientY)
|
||||
if (domIdx >= 0) return domIdx
|
||||
}
|
||||
|
||||
// Outside visible range: estimate
|
||||
const totalCount = virtualContext!.getSortedData().length
|
||||
if (totalCount === 0) return -1
|
||||
|
||||
@@ -195,8 +195,9 @@
|
||||
default-sort-key="name"
|
||||
default-sort-order="asc"
|
||||
:sort-storage-key="ACCOUNT_SORT_STORAGE_KEY"
|
||||
:estimate-row-height="72"
|
||||
:estimate-row-height="156"
|
||||
:overscan="5"
|
||||
:virtualize-threshold="50"
|
||||
>
|
||||
<template #header-select>
|
||||
<input
|
||||
|
||||
Reference in New Issue
Block a user