fix(grid): avoid repeated search text allocations
This commit is contained in:
parent
9020a4dd51
commit
bd7065f244
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
Loading…
Reference in New Issue