fix(grid): avoid repeated search text allocations

This commit is contained in:
vrustx 2026-07-18 21:40:08 +08:00 committed by GitHub
parent 9020a4dd51
commit bd7065f244
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 134 additions and 12 deletions

View File

@ -131,7 +131,8 @@ import { dataGridHeaderContentWidth, scrollbarGutterWidth } from "@/lib/dataGrid
import { canGoNextDataGridPage } from "@/lib/dataGrid/dataGridPagination";
import { dataGridCountQueryOptions } from "@/lib/dataGrid/dataGridQueryOptions";
import { dataGridBottomScrollTop, dataGridScrollPosition, isDataGridAtScrollBottom, isDataGridNearScrollBottom, shouldCheckInfiniteScrollAfterScroll, type DataGridScrollPosition } from "@/lib/dataGrid/dataGridInfiniteScroll";
import { CANVAS_DATA_GRID_ROW_HEIGHT, drawCanvasDataGrid } from "@/lib/dataGrid/canvasDataGridRenderer";
import { CANVAS_DATA_GRID_ROW_HEIGHT, dataGridSearchMatchKey, drawCanvasDataGrid } from "@/lib/dataGrid/canvasDataGridRenderer";
import { createRowLowerTextCache } from "@/lib/dataGrid/dataGridRowLowerText";
import { dataGridPreviewLabelKey, dataGridSaveActionMode, dataGridSaveToolbarState } from "@/lib/dataGrid/dataGridSaveUi";
import type { QueryEditabilityReason } from "@/lib/sql/sqlAnalysis";
import { EDITOR_FONT_FAMILY_CSS_VAR } from "@/lib/editor/editorThemes";
@ -558,7 +559,7 @@ const dataGridSearch = useDataGridSearch({
columns: () => props.result.columns,
suggestionColumns: () => props.tableMeta?.columns.map((column) => column.name) ?? props.result.columns,
rows: () => displayItems.value,
getCellText: (row, columnIndex) => (row.data[columnIndex] === null ? "" : formatCellCached(row.data[columnIndex], columnIndex)),
getCellSearchText: (row, columnIndex) => (row.data[columnIndex] === null ? "" : rowLowerTextCache.get(row.data, columnIndex)),
onNavigate: () => nextTick(scrollToCurrentMatch),
});
const { searchText, deferredSearchText: deferredClientSearchText, overlayVisible: searchOverlayVisible, currentMatchIndex, suggestions: searchSuggestions, suggestionIndex, matches: searchMatches, matchSet: searchMatchSet, currentMatch: currentSearchMatch } = dataGridSearch;
@ -3061,7 +3062,7 @@ const sortedRows = computed(() => {
const rows = props.result.rows;
indices = indices.filter((sourceIndex) => {
const data = rows[sourceIndex];
return data.some((cell, columnIndex) => cell !== null && formatCellCached(cell, columnIndex).toLowerCase().includes(q));
return data.some((cell, columnIndex) => cell !== null && rowLowerTextCache.get(data, columnIndex).includes(q));
});
}
return indices;
@ -3203,7 +3204,7 @@ watch(
function cellIsSearchMatch(displayRow: number, col: number): boolean {
if (isScrolling.value) return false;
return searchMatchSet.value.has(`cell:${displayRow}:${col}`);
return searchMatchSet.value.has(dataGridSearchMatchKey(displayRow, col));
}
function cellIsCurrentMatch(displayRow: number, col: number): boolean {
@ -3217,7 +3218,7 @@ function cellIsCurrentMatch(displayRow: number, col: number): boolean {
// maps to the field row header at the field's column index.
function transposeHeaderIsSearchMatch(fieldIndex: number): boolean {
if (isScrolling.value) return false;
return searchMatchSet.value.has(`column:-1:${fieldIndex}`);
return searchMatchSet.value.has(dataGridSearchMatchKey(-1, fieldIndex));
}
function transposeHeaderIsCurrentMatch(fieldIndex: number): boolean {
@ -4148,6 +4149,9 @@ const resolvedColumnFormatters = computed(() => props.result.columns.map((_, col
const columnFormatterSignatures = computed(() => resolvedColumnFormatters.value.map(formatterSignature));
const primitiveCellFormatCache = new Map<string, string>();
let objectCellFormatCache = new WeakMap<object, Map<number, string>>();
// WeakMap=GC
// LRU
const rowLowerTextCache = createRowLowerTextCache(formatCellCached);
function formatterSignature(formatter: ColumnFormatterConfig | undefined): string {
return formatter ? JSON.stringify(formatter) : "";
@ -4156,6 +4160,7 @@ function formatterSignature(formatter: ColumnFormatterConfig | undefined): strin
function clearCellFormatCache() {
primitiveCellFormatCache.clear();
objectCellFormatCache = new WeakMap<object, Map<number, string>>();
rowLowerTextCache.clear();
}
function rememberPrimitiveCellFormat(key: string, display: string): string {

View File

@ -5,19 +5,34 @@ import { useDataGridSearch } from "@/composables/useDataGridSearch";
describe("useDataGridSearch", () => {
it("debounces matching across columns and cells", async () => {
vi.useFakeTimers();
const search = useDataGridSearch({ columns: ["id", "name"], rows: [[1, "Alice"]], getCellText: (row, column) => String(row[column] ?? "") });
// getCellSearchText 契约:返回小写文本(调用方负责缓存小写副本)
const search = useDataGridSearch({ columns: ["id", "name"], rows: [[1, "Alice"]], getCellSearchText: (row, column) => String(row[column] ?? "").toLowerCase() });
search.searchText.value = "ali";
await nextTick();
expect(search.matches.value).toEqual([]);
vi.advanceTimersByTime(150);
await nextTick();
expect(search.matches.value).toEqual([{ kind: "cell", displayRow: 0, col: 1 }]);
// matchSet 用数值 key(displayRow+1)*65536+col
expect(search.matchSet.value.has((0 + 1) * 65536 + 1)).toBe(true);
vi.useRealTimers();
});
it("keys column-name matches with displayRow -1", async () => {
vi.useFakeTimers();
const search = useDataGridSearch({ columns: ["id", "name"], rows: [], getCellSearchText: () => "" });
search.searchText.value = "nam";
await nextTick();
vi.advanceTimersByTime(150);
await nextTick();
expect(search.matches.value).toEqual([{ kind: "column", displayRow: -1, col: 1 }]);
expect(search.matchSet.value.has((-1 + 1) * 65536 + 1)).toBe(true);
vi.useRealTimers();
});
it("suggests columns and replaces only the active token", async () => {
const columns = ref(["customer_id", "created_at"]);
const search = useDataGridSearch({ columns, rows: [], getCellText: () => "" });
const search = useDataGridSearch({ columns, rows: [], getCellSearchText: () => "" });
search.searchText.value = "status = cus";
await nextTick();
expect(search.suggestions.value).toEqual(["customer_id"]);

View File

@ -1,4 +1,5 @@
import { computed, getCurrentScope, nextTick, onScopeDispose, ref, toValue, watch, type MaybeRefOrGetter } from "vue";
import { dataGridSearchMatchKey } from "@/lib/dataGrid/canvasDataGridRenderer";
export type DataGridSearchMatch = {
kind: "cell" | "column";
@ -10,7 +11,9 @@ export type UseDataGridSearchOptions<Row> = {
columns: MaybeRefOrGetter<readonly string[]>;
suggestionColumns?: MaybeRefOrGetter<readonly string[]>;
rows: MaybeRefOrGetter<readonly Row[]>;
getCellText: (row: Row, columnIndex: number) => string;
/**
* toLowerCase */
getCellSearchText: (row: Row, columnIndex: number) => string;
debounceMs?: number;
onNavigate?: (match: DataGridSearchMatch) => void;
};
@ -38,12 +41,13 @@ export function useDataGridSearch<Row>(options: UseDataGridSearchOptions<Row>) {
});
toValue(options.rows).forEach((row, displayRow) => {
columns.forEach((_, col) => {
if (options.getCellText(row, col).toLowerCase().includes(query)) result.push({ kind: "cell", displayRow, col });
if (options.getCellSearchText(row, col).includes(query)) result.push({ kind: "cell", displayRow, col });
});
});
return result;
});
const matchSet = computed(() => new Set(matches.value.map((match) => `${match.kind}:${match.displayRow}:${match.col}`)));
// 数值 key列头匹配 displayRow=-1避免每匹配一次字符串拼接
const matchSet = computed(() => new Set(matches.value.map((match) => dataGridSearchMatchKey(match.displayRow, match.col))));
const currentMatch = computed(() => matches.value[currentMatchIndex.value] ?? null);
function clearTimer() {

View File

@ -25,6 +25,13 @@ export interface CanvasEditingCell {
col: number;
}
/** key displayRow -1 key
* matchSet
* ponytail: 列数上限 65536 */
export function dataGridSearchMatchKey(displayRow: number, col: number): number {
return (displayRow + 1) * 65536 + col;
}
export interface CanvasSearchMatch {
kind: "cell" | "column";
displayRow: number;
@ -50,7 +57,7 @@ export interface DrawCanvasDataGridOptions {
hoverCell: CanvasHoverCell | null;
isScrolling: boolean;
editingCell: CanvasEditingCell | null;
searchMatchKeys: ReadonlySet<string>;
searchMatchKeys: ReadonlySet<number>;
currentSearchMatch: CanvasSearchMatch | null;
formatCell: (value: CellValue, columnIndex: number) => string;
draftCellPlaceholder?: string;
@ -334,7 +341,7 @@ export function drawCanvasDataGrid(options: DrawCanvasDataGridOptions) {
const isDirtyCell = item.isDirtyCol[actualColIdx];
const selectedFillVisual = rowSelectionVisual || selectedCell;
const selectedBorderVisual = rowSelectionVisual || selectedCell;
const isSearchMatch = paintSearchMatches && searchMatchKeys.has(`cell:${item.displayIndex}:${actualColIdx}`);
const isSearchMatch = paintSearchMatches && searchMatchKeys.has(dataGridSearchMatchKey(item.displayIndex, actualColIdx));
const isCurrentSearchMatch = paintSearchMatches && currentSearchMatch?.displayRow === item.displayIndex && currentSearchMatch.col === actualColIdx;
const clippedX = Math.max(drawX, rowNumberWidth);
const cellPaintWidth = Math.min(width, drawX + colWidth) - clippedX;

View File

@ -0,0 +1,38 @@
import type { CellValue } from "@/lib/dataGrid/cellValue";
type RowLowerTextEntry = {
/** 每列缓存时的源值引用:单元格被原地编辑后引用不再相等,仅重算该格 */
values: CellValue[];
lowers: (string | undefined)[];
};
/**
* WeakMap
* - markRaw
* LRU GC
* - / clear()
*/
export function createRowLowerTextCache(format: (value: CellValue, columnIndex: number) => string) {
let cache = new WeakMap<object, RowLowerTextEntry>();
function get(rowData: CellValue[], columnIndex: number): string {
let entry = cache.get(rowData);
if (!entry) {
entry = { values: [], lowers: [] };
cache.set(rowData, entry);
}
const value = rowData[columnIndex];
const cached = entry.lowers[columnIndex];
if (cached !== undefined && Object.is(entry.values[columnIndex], value)) return cached;
const lower = format(value, columnIndex).toLowerCase();
entry.values[columnIndex] = value;
entry.lowers[columnIndex] = lower;
return lower;
}
function clear() {
cache = new WeakMap<object, RowLowerTextEntry>();
}
return { get, clear };
}

View File

@ -0,0 +1,53 @@
import { strict as assert } from "node:assert";
import { test } from "vitest";
import { createRowLowerTextCache } from "../../apps/desktop/src/lib/dataGrid/dataGridRowLowerText.ts";
import type { CellValue } from "../../apps/desktop/src/lib/dataGrid/cellValue.ts";
test("memoizes lowercase text per cell across repeated full scans", () => {
let formatCalls = 0;
const cache = createRowLowerTextCache((value) => {
formatCalls++;
return String(value);
});
const rows: CellValue[][] = Array.from({ length: 1000 }, (_, i) => [i, `Name-${i}`, `MAIL${i}@X.COM`]);
// 第一遍全量扫描:每格格式化一次
for (const row of rows) for (let col = 0; col < 3; col++) cache.get(row, col);
assert.equal(formatCalls, 3000);
assert.equal(cache.get(rows[0]!, 2), "mail0@x.com");
// 第二遍相同顺序扫描:必须全部命中(固定容量 LRU 在此场景会零命中)
for (const row of rows) for (let col = 0; col < 3; col++) cache.get(row, col);
assert.equal(formatCalls, 3000);
});
test("recomputes only the edited cell after in-place mutation", () => {
let formatCalls = 0;
const cache = createRowLowerTextCache((value) => {
formatCalls++;
return String(value);
});
const row: CellValue[] = ["A", "B"];
assert.equal(cache.get(row, 0), "a");
assert.equal(cache.get(row, 1), "b");
assert.equal(formatCalls, 2);
// 保存后原地写回单元格:仅该格重算
row[0] = "Changed";
assert.equal(cache.get(row, 0), "changed");
assert.equal(cache.get(row, 1), "b");
assert.equal(formatCalls, 3);
});
test("clear() invalidates everything (formatter/column-type changes)", () => {
let formatCalls = 0;
const cache = createRowLowerTextCache((value) => {
formatCalls++;
return String(value);
});
const row: CellValue[] = ["X"];
cache.get(row, 0);
cache.clear();
cache.get(row, 0);
assert.equal(formatCalls, 2);
});