perf(frontend): optimize account table selection performance

- Batch state updates in swipe-select: 100 rows drag from 100 Set copies + 100 re-renders to 1 each
- Replace selIds.includes() with Set.has() for checkbox rendering (O(n²) → O(n))
- Add RAF throttle to mousemove handler to cap at 60fps
This commit is contained in:
erio
2026-03-18 19:02:06 +08:00
parent 9587eaeed9
commit c9f30f88a6
4 changed files with 51 additions and 19 deletions
+34 -13
View File
@@ -25,6 +25,7 @@ export interface SwipeSelectAdapter {
isSelected: (id: number) => boolean
select: (id: number) => void
deselect: (id: number) => void
batchUpdate?: (updater: (draft: Set<number>) => void) => void
}
export function useSwipeSelect(
@@ -140,16 +141,30 @@ 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
@@ -306,12 +321,17 @@ export function useSwipeSelect(
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)
cancelAnimationFrame(moveRAF)
moveRAF = requestAnimationFrame(() => {
updateMarquee(lastMouseY)
const rowIdx = findRowIndexAtY(lastMouseY)
if (rowIdx >= 0 && rowIdx !== lastEndIndex) applyRange(rowIdx)
})
autoScroll(e)
}
@@ -332,6 +352,7 @@ export function useSwipeSelect(
cachedRows = []
initialSelectedSnapshot.clear()
cachedScrollParent = null
cancelAnimationFrame(moveRAF)
stopAutoScroll()
removeMarquee()
document.removeEventListener('selectstart', onSelectStart)
@@ -76,6 +76,12 @@ export function useTableSelection<T>({ rows, getId }: UseTableSelectionOptions<T
replaceSelectedSet(next)
}
const batchUpdate = (updater: (draft: Set<number>) => void) => {
const draft = new Set(selectedSet.value)
updater(draft)
replaceSelectedSet(draft)
}
const selectVisible = () => {
toggleVisible(true)
}
@@ -93,6 +99,7 @@ export function useTableSelection<T>({ rows, getId }: UseTableSelectionOptions<T
clear,
removeMany,
toggleVisible,
selectVisible
selectVisible,
batchUpdate
}
}
+5 -3
View File
@@ -152,7 +152,7 @@
/>
</template>
<template #cell-select="{ row }">
<input type="checkbox" :checked="selIds.includes(row.id)" @change="toggleSel(row.id)" class="rounded border-gray-300 text-primary-600 focus:ring-primary-500" />
<input type="checkbox" :checked="isSelected(row.id)" @change="toggleSel(row.id)" class="rounded border-gray-300 text-primary-600 focus:ring-primary-500" />
</template>
<template #cell-name="{ row, value }">
<div class="flex flex-col">
@@ -597,7 +597,8 @@ const {
clear: clearSelection,
removeMany: removeSelectedAccounts,
toggleVisible,
selectVisible: selectPage
selectVisible: selectPage,
batchUpdate
} = useTableSelection<Account>({
rows: accounts,
getId: (account) => account.id
@@ -606,7 +607,8 @@ const {
useSwipeSelect(accountTableRef, {
isSelected,
select,
deselect
deselect,
batchUpdate
})
const resetAutoRefreshCache = () => {
+4 -2
View File
@@ -972,7 +972,8 @@ const {
deselect,
clear: clearSelectedProxies,
removeMany: removeSelectedProxies,
toggleVisible
toggleVisible,
batchUpdate
} = useTableSelection<Proxy>({
rows: proxies,
getId: (proxy) => proxy.id
@@ -980,7 +981,8 @@ const {
useSwipeSelect(proxyTableRef, {
isSelected,
select,
deselect
deselect,
batchUpdate
})
const accountsProxy = ref<Proxy | null>(null)
const proxyAccounts = ref<ProxyAccountSummary[]>([])