From c0b231374df66fa558afdee5825cdb1a8668af9d Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Sun, 31 May 2026 01:46:54 +0800 Subject: [PATCH] perf(desktop): optimize DataGrid wide table rendering --- apps/desktop/src/components/grid/DataGrid.vue | 429 +++++++++++++----- .../composables/useDataGridColumnResize.ts | 8 +- 2 files changed, 324 insertions(+), 113 deletions(-) diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 747b45fc5..bd43281e7 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -158,7 +158,7 @@ import { import { useToast } from "@/composables/useToast"; import { useDataGridExport } from "@/composables/useDataGridExport"; -import { useDataGridColumnResize } from "@/composables/useDataGridColumnResize"; +import { DATA_GRID_ROW_NUM_WIDTH, useDataGridColumnResize } from "@/composables/useDataGridColumnResize"; import { useDataGridSelection } from "@/composables/useDataGridSelection"; import { useDataGridEditor } from "@/composables/useDataGridEditor"; import { useSqlHighlighter } from "@/composables/useSqlHighlighter"; @@ -468,7 +468,7 @@ function localFilterKey(value: CellValue): string { } function localFilterLabel(value: CellValue, columnIndex: number): string { - return value === null ? "NULL" : formatCell(value, columnIndex); + return value === null ? "NULL" : formatCellCached(value, columnIndex); } function localFilterActive(colIdx: number): boolean { @@ -1424,11 +1424,16 @@ function scrollToTableInfoColumn(columnName: string) { nextTick(() => { const visibleColIdx = visibleColumnIndexes.value.indexOf(columnIndex); const scroller = gridRef.value?.querySelector(".data-grid-scroller"); - const headerCell = headerRef.value?.querySelector(`[data-grid-column-index="${columnIndex}"]`); - if (visibleColIdx < 0 || !scroller || !headerCell) return; + if (visibleColIdx < 0 || !scroller) return; - const targetLeft = Math.max(0, headerCell.offsetLeft - scroller.clientWidth / 2 + headerCell.offsetWidth / 2); + const targetLeft = Math.max( + 0, + columnContentOffsetLeft(visibleColIdx) - + scroller.clientWidth / 2 + + (renderedColumnWidths.value[visibleColIdx] ?? 0) / 2, + ); scroller.scrollLeft = targetLeft; + updateGridHorizontalViewport(scroller); if (headerRef.value) { headerRef.value.scrollLeft = scroller.scrollLeft; } @@ -1436,31 +1441,117 @@ function scrollToTableInfoColumn(columnName: string) { } // --- Column resize composable --- -const { initColumnWidths, onResizeStart, autoFitColumn, columnVars, getIsResizing } = useDataGridColumnResize({ - columns: visibleColumns, - sourceRows: computed(() => props.result.rows), - columnIndexes: visibleColumnIndexes, - gridRef, -}); +const { initColumnWidths, onResizeStart, autoFitColumn, renderedColumnWidths, columnVars, getIsResizing } = + useDataGridColumnResize({ + columns: visibleColumns, + sourceRows: computed(() => props.result.rows), + columnIndexes: visibleColumnIndexes, + gridRef, + }); const gridStyle = computed(() => ({ ...columnVars.value, "--header-total-w": dataGridHeaderContentWidth("var(--total-w)", gridScrollbarGutter.value), "--grid-scrollbar-gutter": `${gridScrollbarGutter.value}px`, })); +const gridHorizontalScrollLeft = ref(0); +const gridViewportWidth = ref(0); + +function updateGridHorizontalViewport(element: HTMLElement) { + gridHorizontalScrollLeft.value = element.scrollLeft; + gridViewportWidth.value = element.clientWidth; +} + function updateGridScrollbarGutter(element: HTMLElement) { gridScrollbarGutter.value = scrollbarGutterWidth(element); } function syncHeaderScroll(e: Event) { - updateGridScrollbarGutter(e.target as HTMLElement); + const target = e.target as HTMLElement; + updateGridScrollbarGutter(target); + updateGridHorizontalViewport(target); if (headerRef.value) { - headerRef.value.scrollLeft = (e.target as HTMLElement).scrollLeft; + headerRef.value.scrollLeft = target.scrollLeft; } } +const HORIZONTAL_COLUMN_BUFFER_PX = 900; + +interface RenderedGridColumn { + visibleColIdx: number; + actualColIdx: number; + name: string; +} + +const horizontalColumnWindow = computed(() => { + const widths = renderedColumnWidths.value; + const totalColumns = visibleColumnIndexes.value.length; + if (totalColumns === 0 || widths.length === 0) { + return { start: 0, end: 0, beforeWidth: 0, afterWidth: 0 }; + } + + const viewportStart = Math.max( + 0, + gridHorizontalScrollLeft.value - DATA_GRID_ROW_NUM_WIDTH - HORIZONTAL_COLUMN_BUFFER_PX, + ); + const viewportEnd = + Math.max(gridViewportWidth.value, 1) + + Math.max(0, gridHorizontalScrollLeft.value - DATA_GRID_ROW_NUM_WIDTH) + + HORIZONTAL_COLUMN_BUFFER_PX; + let start = 0; + let offset = 0; + + while (start < totalColumns && offset + (widths[start] ?? 0) < viewportStart) { + offset += widths[start] ?? 0; + start++; + } + + let end = start; + let visibleWidth = offset; + while (end < totalColumns && visibleWidth < viewportEnd) { + visibleWidth += widths[end] ?? 0; + end++; + } + + const columnsWidth = widths.reduce((sum, width) => sum + width, 0); + return { + start, + end, + beforeWidth: offset, + afterWidth: Math.max(0, columnsWidth - visibleWidth), + }; +}); + +const renderedGridColumns = computed(() => { + const window = horizontalColumnWindow.value; + const columns: RenderedGridColumn[] = []; + for (let visibleColIdx = window.start; visibleColIdx < window.end; visibleColIdx++) { + const actualColIdx = visibleColumnIndexes.value[visibleColIdx]; + if (actualColIdx === undefined) continue; + columns.push({ + visibleColIdx, + actualColIdx, + name: props.result.columns[actualColIdx] ?? "", + }); + } + return columns; +}); + +function renderedColumnStyle(visibleColIdx: number) { + return { width: `var(--col-w-${visibleColIdx})` }; +} + +function columnContentOffsetLeft(visibleColIdx: number): number { + const widths = renderedColumnWidths.value; + let offset = DATA_GRID_ROW_NUM_WIDTH; + for (let i = 0; i < visibleColIdx; i++) { + offset += widths[i] ?? 0; + } + return offset; +} + let scrollingTimer = 0; const isScrolling = ref(false); -function onScrollerScroll(e: Event) { - syncHeaderScroll(e); + +function markGridScrolling() { if (!isScrolling.value) isScrolling.value = true; clearTimeout(scrollingTimer); scrollingTimer = window.setTimeout(() => { @@ -1468,8 +1559,26 @@ function onScrollerScroll(e: Event) { }, 120); } +function onScrollerScroll(e: Event) { + syncHeaderScroll(e); + markGridScrolling(); +} + +watch(isScrolling, (scrolling) => { + if (scrolling) hoveredDetailCell.value = null; +}); + initColumnWidths(); watch(() => visibleColumns.value.length, initColumnWidths); +watch( + () => [visibleColumnCount.value, renderedColumnWidths.value.length], + () => { + nextTick(() => { + const scrollerEl = gridRef.value?.querySelector(".data-grid-scroller"); + if (scrollerEl) updateGridHorizontalViewport(scrollerEl); + }); + }, +); const localFilterScopeKey = computed(() => [ props.connectionId ?? "", @@ -1815,7 +1924,9 @@ const sortedRows = computed(() => { const rows = props.result.rows; indices = indices.filter((sourceIndex) => { const data = rows[sourceIndex]; - return data.some((cell, columnIndex) => cell !== null && formatCell(cell, columnIndex).toLowerCase().includes(q)); + return data.some( + (cell, columnIndex) => cell !== null && formatCellCached(cell, columnIndex).toLowerCase().includes(q), + ); }); } return indices; @@ -1855,7 +1966,10 @@ watch( () => { nextTick(() => { const scrollerEl = gridRef.value?.querySelector(".data-grid-scroller"); - if (scrollerEl) updateGridScrollbarGutter(scrollerEl); + if (scrollerEl) { + updateGridScrollbarGutter(scrollerEl); + updateGridHorizontalViewport(scrollerEl); + } }); }, ); @@ -1873,7 +1987,7 @@ const searchMatches = computed(() => { for (let r = 0; r < items.length; r++) { const data = items[r].data; for (let c = 0; c < data.length; c++) { - if (data[c] !== null && formatCell(data[c], c).toLowerCase().includes(q)) { + if (data[c] !== null && formatCellCached(data[c], c).toLowerCase().includes(q)) { matches.push({ displayRow: r, col: c }); } } @@ -1894,10 +2008,12 @@ watch(searchMatches, (matches) => { }); function cellIsSearchMatch(displayRow: number, col: number): boolean { + if (isScrolling.value) return false; return searchMatchSet.value.has(`${displayRow}:${col}`); } function cellIsCurrentMatch(displayRow: number, col: number): boolean { + if (isScrolling.value) return false; const idx = currentMatchIndex.value; if (idx < 0 || idx >= searchMatches.value.length) return false; const m = searchMatches.value[idx]; @@ -1915,6 +2031,8 @@ function scrollToCurrentMatch() { const idx = currentMatchIndex.value; if (idx < 0 || idx >= searchMatches.value.length) return; const match = searchMatches.value[idx]; + const visibleColIdx = visibleColumnIndexes.value.indexOf(match.col); + if (visibleColIdx >= 0) scrollGridColumnIntoView(visibleColIdx); const scrollEl = gridRef.value; if (!scrollEl) return; const rowEl = scrollEl.querySelector(`[data-row-index="${match.displayRow}"]`) as HTMLElement | null; @@ -2014,17 +2132,19 @@ const multiRowCount = computed(() => { const isMultiRow = computed(() => multiRowCount.value > 1); function onCellMouseenter(rowIndex: number, visibleColIdx: number, actualColIdx: number) { - hoveredDetailCell.value = { rowIndex, col: actualColIdx }; + if (!isScrolling.value) hoveredDetailCell.value = { rowIndex, col: actualColIdx }; extendCellSelection(rowIndex, visibleColIdx); } function onCellMouseleave(rowIndex: number, actualColIdx: number) { + if (isScrolling.value) return; if (hoveredDetailCell.value?.rowIndex === rowIndex && hoveredDetailCell.value.col === actualColIdx) { hoveredDetailCell.value = null; } } function cellDetailButtonVisible(rowIndex: number, actualColIdx: number) { + if (isScrolling.value) return false; return ( (hoveredDetailCell.value?.rowIndex === rowIndex && hoveredDetailCell.value.col === actualColIdx) || (showCellDetail.value && detailCell.value?.rowIndex === rowIndex && detailCell.value.col === actualColIdx) @@ -2089,7 +2209,7 @@ function cellDetailFor(rowIndex: number, columnIndex: number): DataGridCellDetai columnIndex, typeByColumn: columnTypeMap.value, commentByColumn: columnCommentMap.value, - displayValue: (value, index) => formatCell(value, index), + displayValue: (value, index) => formatCellCached(value, index), isEditable: canEditCellItem(item, columnIndex), }); } @@ -2116,7 +2236,7 @@ const rowDetail = computed(() => { columnIndexes: displayableColumnIndexes.value, typeByColumn: columnTypeMap.value, commentByColumn: columnCommentMap.value, - displayValue: (value, index) => formatCell(value, index), + displayValue: (value, index) => formatCellCached(value, index), isEditableColumn: (columnIndex) => canEditCellItem(item, columnIndex), }); }); @@ -2135,7 +2255,7 @@ const columnDetail = computed(() => { columnIndex, typeByColumn: columnTypeMap.value, commentByColumn: columnCommentMap.value, - displayValue: (value, index) => formatCell(value, index), + displayValue: (value, index) => formatCellCached(value, index), }); }); @@ -2594,13 +2714,76 @@ async function applyWhereFilter() { } const CELL_DISPLAY_MAX_LENGTH = 256; +const CELL_FORMAT_CACHE_LIMIT = 20_000; +const CELL_FORMAT_CACHE_PRUNE_COUNT = 5_000; + +const resolvedColumnFormatters = computed(() => + props.result.columns.map((_, columnIndex) => columnFormatter(columnIndex)), +); +const columnFormatterSignatures = computed(() => resolvedColumnFormatters.value.map(formatterSignature)); +const primitiveCellFormatCache = new Map(); +let objectCellFormatCache = new WeakMap>(); + +function formatterSignature(formatter: ColumnFormatterConfig | undefined): string { + return formatter ? JSON.stringify(formatter) : ""; +} + +function clearCellFormatCache() { + primitiveCellFormatCache.clear(); + objectCellFormatCache = new WeakMap>(); +} + +function rememberPrimitiveCellFormat(key: string, display: string): string { + primitiveCellFormatCache.set(key, display); + if (primitiveCellFormatCache.size > CELL_FORMAT_CACHE_LIMIT) { + let removed = 0; + for (const cacheKey of primitiveCellFormatCache.keys()) { + primitiveCellFormatCache.delete(cacheKey); + removed++; + if (removed >= CELL_FORMAT_CACHE_PRUNE_COUNT) break; + } + } + return display; +} + +function primitiveCellFormatKey(value: CellValue, columnIndex?: number): string { + return `${columnIndex ?? -1}\u0000${typeof value}\u0000${String(value)}`; +} function formatCell(value: CellValue, columnIndex?: number): string { - const formatter = columnIndex === undefined ? undefined : columnFormatter(columnIndex); + const formatter = columnIndex === undefined ? undefined : resolvedColumnFormatters.value[columnIndex]; const s = applyColumnFormatter(value, formatter); return s.length > CELL_DISPLAY_MAX_LENGTH ? s.slice(0, CELL_DISPLAY_MAX_LENGTH) : s; } +function formatCellCached(value: CellValue, columnIndex?: number): string { + if (value !== null && typeof (value as unknown) === "object") { + const objectValue = value as unknown as object; + const cacheColumn = columnIndex ?? -1; + const columnCache = objectCellFormatCache.get(objectValue); + const cached = columnCache?.get(cacheColumn); + if (cached !== undefined) return cached; + + const display = formatCell(value, columnIndex); + if (columnCache) { + columnCache.set(cacheColumn, display); + } else { + objectCellFormatCache.set(objectValue, new Map([[cacheColumn, display]])); + } + return display; + } + + const key = primitiveCellFormatKey(value, columnIndex); + const cached = primitiveCellFormatCache.get(key); + if (cached !== undefined) return cached; + return rememberPrimitiveCellFormat(key, formatCell(value, columnIndex)); +} + +watch( + () => [props.result.columns.join("\u0000"), columnFormatterSignatures.value.join("\u0000")], + clearCellFormatCache, +); + function quoteIdent(name: string): string { return quoteTableIdentifier(props.databaseType, name); } @@ -2801,6 +2984,7 @@ function transposeCellIsSelected(rowIndex: number, actualColIdx: number) { } function onTransposeCellMouseenter(rowIndex: number, actualColIdx: number) { + if (isScrolling.value) return; hoveredDetailCell.value = { rowIndex, col: actualColIdx }; } @@ -2914,12 +3098,33 @@ function currentSelectedCellPosition() { function scrollCellIntoView(rowIndex: number, colIndex: number) { nextTick(() => { - const rowEl = gridRef.value?.querySelector(`[data-row-index="${rowIndex}"]`); - const cellEl = rowEl?.querySelector(`[data-visible-col-index="${colIndex}"]`); - (cellEl ?? rowEl)?.scrollIntoView({ block: "nearest", inline: "nearest" }); + scrollGridColumnIntoView(colIndex); + nextTick(() => { + const rowEl = gridRef.value?.querySelector(`[data-row-index="${rowIndex}"]`); + const cellEl = rowEl?.querySelector(`[data-visible-col-index="${colIndex}"]`); + (cellEl ?? rowEl)?.scrollIntoView({ block: "nearest", inline: "nearest" }); + }); }); } +function scrollGridColumnIntoView(visibleColIdx: number) { + const scroller = gridRef.value?.querySelector(".data-grid-scroller"); + if (!scroller) return; + const colLeft = columnContentOffsetLeft(visibleColIdx); + const colRight = colLeft + (renderedColumnWidths.value[visibleColIdx] ?? 0); + const viewportLeft = scroller.scrollLeft + DATA_GRID_ROW_NUM_WIDTH; + const viewportRight = scroller.scrollLeft + scroller.clientWidth; + + if (colLeft < viewportLeft) { + scroller.scrollLeft = Math.max(0, colLeft - DATA_GRID_ROW_NUM_WIDTH); + } else if (colRight > viewportRight) { + scroller.scrollLeft = Math.max(0, colRight - scroller.clientWidth); + } + + updateGridHorizontalViewport(scroller); + if (headerRef.value) headerRef.value.scrollLeft = scroller.scrollLeft; +} + function scrollGridRowIntoView(rowIndex: number) { const target = Math.max(0, Math.min(displayItems.value.length - 1, rowIndex)); nextTick(() => { @@ -3278,7 +3483,7 @@ const transposeRows = computed(() => { recordIndexes: visibleTransposeRecordIndexes.value, valueIndexes: visibleColumnIndexes.value, typeByColumn: columnTypeMap.value, - displayValue: (value, _column, index) => formatCell(value, visibleColumnIndexes.value[index]), + displayValue: (value, _column, index) => formatCellCached(value, visibleColumnIndexes.value[index]), }); }); const isTransposeMode = computed(() => showTranspose.value && transposeRows.value.length > 0); @@ -3301,6 +3506,7 @@ function updateTransposeViewport() { function onTransposeScroll() { updateTransposeViewport(); + markGridScrolling(); } function scrollTransposeRecordIntoView(rowIndex: number) { @@ -4706,6 +4912,7 @@ const gridContextMenuItems = computed(() => { +
+
- {{ col }} + {{ col.name }} - {{ headerColumnComment(col) }} + {{ headerColumnComment(col.name) }} @@ -4936,8 +5144,7 @@ const gridContextMenuItems = computed(() => { type="button" class="flex h-4 w-4 shrink-0 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground" :class=" - columnHasFormatter(actualColumnIndex(colIdx)) || - localFilterActive(actualColumnIndex(colIdx)) + columnHasFormatter(col.actualColIdx) || localFilterActive(col.actualColIdx) ? 'text-primary opacity-90' : 'opacity-80' " @@ -4955,15 +5162,15 @@ const gridContextMenuItems = computed(() => { > {{ t("grid.columnFormatter") }} {{ t("grid.localFilter") }} @@ -4971,10 +5178,8 @@ const gridContextMenuItems = computed(() => { @@ -4984,11 +5189,9 @@ const gridContextMenuItems = computed(() => { type="button" class="flex h-4 w-4 shrink-0 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground" :class=" - columnHasFormatter(actualColumnIndex(colIdx)) - ? 'text-primary opacity-100' - : 'opacity-80' + columnHasFormatter(col.actualColIdx) ? 'text-primary opacity-100' : 'opacity-80' " - :disabled="!formatterKeyForColumn(col)" + :disabled="!formatterKeyForColumn(col.name)" :title="t('grid.columnFormatter')" @click.stop > @@ -5004,7 +5207,7 @@ const gridContextMenuItems = computed(() => { >
- {{ t("grid.columnFormatterFor", { column: col }) }} + {{ t("grid.columnFormatterFor", { column: col.name }) }}
{{ t("grid.columnFormatterHint") }} @@ -5153,7 +5356,7 @@ const gridContextMenuItems = computed(() => {
@@ -5170,8 +5373,8 @@ const gridContextMenuItems = computed(() => { variant="ghost" size="sm" class="h-7 px-2 text-xs" - :disabled="!columnHasFormatter(actualColumnIndex(colIdx))" - @click="clearColumnFormatter(actualColumnIndex(colIdx))" + :disabled="!columnHasFormatter(col.actualColIdx)" + @click="clearColumnFormatter(col.actualColIdx)" > {{ t("grid.clearFormatter") }} @@ -5188,7 +5391,7 @@ const gridContextMenuItems = computed(() => { size="sm" class="h-7 px-2 text-xs" :disabled="!formatterDraftIsSavable()" - @click="saveColumnFormatter(actualColumnIndex(colIdx))" + @click="saveColumnFormatter(col.actualColIdx)" > {{ t("grid.saveFormatter") }} @@ -5197,10 +5400,8 @@ const gridContextMenuItems = computed(() => { @@ -5209,11 +5410,7 @@ const gridContextMenuItems = computed(() => { @@ -5343,8 +5540,8 @@ const gridContextMenuItems = computed(() => {
@@ -5354,26 +5551,29 @@ const gridContextMenuItems = computed(() => { > {{ t("grid.columnName") }} - {{ col }} + {{ col.name }} -