fix(grid): avoid repeated search text allocations
This commit is contained in:
1 parent
9020a4dd51
commit
bd7065f244
6 files changed
+134
-12
No files matched your search
@@ -131,7 +131,8 @@ import { dataGridHeaderContentWidth, scrollbarGutterWidth } from "@/lib/dataGrid
|
|||||||
import { canGoNextDataGridPage } from "@/lib/dataGrid/dataGridPagination";
|
import { canGoNextDataGridPage } from "@/lib/dataGrid/dataGridPagination";
|
||||||
import { dataGridCountQueryOptions } from "@/lib/dataGrid/dataGridQueryOptions";
|
import { dataGridCountQueryOptions } from "@/lib/dataGrid/dataGridQueryOptions";
|
||||||
import { dataGridBottomScrollTop, dataGridScrollPosition, isDataGridAtScrollBottom, isDataGridNearScrollBottom, shouldCheckInfiniteScrollAfterScroll, type DataGridScrollPosition } from "@/lib/dataGrid/dataGridInfiniteScroll";
|
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 { dataGridPreviewLabelKey, dataGridSaveActionMode, dataGridSaveToolbarState } from "@/lib/dataGrid/dataGridSaveUi";
|
||||||
import type { QueryEditabilityReason } from "@/lib/sql/sqlAnalysis";
|
import type { QueryEditabilityReason } from "@/lib/sql/sqlAnalysis";
|
||||||
import { EDITOR_FONT_FAMILY_CSS_VAR } from "@/lib/editor/editorThemes";
|
import { EDITOR_FONT_FAMILY_CSS_VAR } from "@/lib/editor/editorThemes";
|
||||||
@@ -558,7 +559,7 @@ const dataGridSearch = useDataGridSearch({
|
|||||||
columns: () => props.result.columns,
|
columns: () => props.result.columns,
|
||||||
suggestionColumns: () => props.tableMeta?.columns.map((column) => column.name) ?? props.result.columns,
|
suggestionColumns: () => props.tableMeta?.columns.map((column) => column.name) ?? props.result.columns,
|
||||||
rows: () => displayItems.value,
|
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),
|
onNavigate: () => nextTick(scrollToCurrentMatch),
|
||||||
});
|
});
|
||||||
const { searchText, deferredSearchText: deferredClientSearchText, overlayVisible: searchOverlayVisible, currentMatchIndex, suggestions: searchSuggestions, suggestionIndex, matches: searchMatches, matchSet: searchMatchSet, currentMatch: currentSearchMatch } = dataGridSearch;
|
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;
|
const rows = props.result.rows;
|
||||||
indices = indices.filter((sourceIndex) => {
|
indices = indices.filter((sourceIndex) => {
|
||||||
const data = rows[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;
|
return indices;
|
||||||
@@ -3203,7 +3204,7 @@ watch(
|
|||||||
|
|
||||||
function cellIsSearchMatch(displayRow: number, col: number): boolean {
|
function cellIsSearchMatch(displayRow: number, col: number): boolean {
|
||||||
if (isScrolling.value) return false;
|
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 {
|
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.
|
// maps to the field row header at the field's column index.
|
||||||
function transposeHeaderIsSearchMatch(fieldIndex: number): boolean {
|
function transposeHeaderIsSearchMatch(fieldIndex: number): boolean {
|
||||||
if (isScrolling.value) return false;
|
if (isScrolling.value) return false;
|
||||||
return searchMatchSet.value.has(`column:-1:${fieldIndex}`);
|
return searchMatchSet.value.has(dataGridSearchMatchKey(-1, fieldIndex));
|
||||||
}
|
}
|
||||||
|
|
||||||
function transposeHeaderIsCurrentMatch(fieldIndex: number): boolean {
|
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 columnFormatterSignatures = computed(() => resolvedColumnFormatters.value.map(formatterSignature));
|
||||||
const primitiveCellFormatCache = new Map<string, string>();
|
const primitiveCellFormatCache = new Map<string, string>();
|
||||||
let objectCellFormatCache = new WeakMap<object, Map<number, string>>();
|
let objectCellFormatCache = new WeakMap<object, Map<number, string>>();
|
||||||
|
// 搜索用小写文本缓存:按行数组身份挂 WeakMap(工作集=当前结果行,GC 回收),
|
||||||
|
// 不用固定容量 LRU——搜索是全量顺序扫描,超限修剪会导致第二遍零命中抖动
|
||||||
|
const rowLowerTextCache = createRowLowerTextCache(formatCellCached);
|
||||||
|
|
||||||
function formatterSignature(formatter: ColumnFormatterConfig | undefined): string {
|
function formatterSignature(formatter: ColumnFormatterConfig | undefined): string {
|
||||||
return formatter ? JSON.stringify(formatter) : "";
|
return formatter ? JSON.stringify(formatter) : "";
|
||||||
@@ -4156,6 +4160,7 @@ function formatterSignature(formatter: ColumnFormatterConfig | undefined): strin
|
|||||||
function clearCellFormatCache() {
|
function clearCellFormatCache() {
|
||||||
primitiveCellFormatCache.clear();
|
primitiveCellFormatCache.clear();
|
||||||
objectCellFormatCache = new WeakMap<object, Map<number, string>>();
|
objectCellFormatCache = new WeakMap<object, Map<number, string>>();
|
||||||
|
rowLowerTextCache.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
function rememberPrimitiveCellFormat(key: string, display: string): string {
|
function rememberPrimitiveCellFormat(key: string, display: string): string {
|
||||||
|
|||||||
@@ -5,19 +5,34 @@ import { useDataGridSearch } from "@/composables/useDataGridSearch";
|
|||||||
describe("useDataGridSearch", () => {
|
describe("useDataGridSearch", () => {
|
||||||
it("debounces matching across columns and cells", async () => {
|
it("debounces matching across columns and cells", async () => {
|
||||||
vi.useFakeTimers();
|
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";
|
search.searchText.value = "ali";
|
||||||
await nextTick();
|
await nextTick();
|
||||||
expect(search.matches.value).toEqual([]);
|
expect(search.matches.value).toEqual([]);
|
||||||
vi.advanceTimersByTime(150);
|
vi.advanceTimersByTime(150);
|
||||||
await nextTick();
|
await nextTick();
|
||||||
expect(search.matches.value).toEqual([{ kind: "cell", displayRow: 0, col: 1 }]);
|
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();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("suggests columns and replaces only the active token", async () => {
|
it("suggests columns and replaces only the active token", async () => {
|
||||||
const columns = ref(["customer_id", "created_at"]);
|
const columns = ref(["customer_id", "created_at"]);
|
||||||
const search = useDataGridSearch({ columns, rows: [], getCellText: () => "" });
|
const search = useDataGridSearch({ columns, rows: [], getCellSearchText: () => "" });
|
||||||
search.searchText.value = "status = cus";
|
search.searchText.value = "status = cus";
|
||||||
await nextTick();
|
await nextTick();
|
||||||
expect(search.suggestions.value).toEqual(["customer_id"]);
|
expect(search.suggestions.value).toEqual(["customer_id"]);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { computed, getCurrentScope, nextTick, onScopeDispose, ref, toValue, watch, type MaybeRefOrGetter } from "vue";
|
import { computed, getCurrentScope, nextTick, onScopeDispose, ref, toValue, watch, type MaybeRefOrGetter } from "vue";
|
||||||
|
import { dataGridSearchMatchKey } from "@/lib/dataGrid/canvasDataGridRenderer";
|
||||||
|
|
||||||
export type DataGridSearchMatch = {
|
export type DataGridSearchMatch = {
|
||||||
kind: "cell" | "column";
|
kind: "cell" | "column";
|
||||||
@@ -10,7 +11,9 @@ export type UseDataGridSearchOptions<Row> = {
|
|||||||
columns: MaybeRefOrGetter<readonly string[]>;
|
columns: MaybeRefOrGetter<readonly string[]>;
|
||||||
suggestionColumns?: MaybeRefOrGetter<readonly string[]>;
|
suggestionColumns?: MaybeRefOrGetter<readonly string[]>;
|
||||||
rows: MaybeRefOrGetter<readonly Row[]>;
|
rows: MaybeRefOrGetter<readonly Row[]>;
|
||||||
getCellText: (row: Row, columnIndex: number) => string;
|
/** 必须返回小写文本(查询词已小写)。调用方可据此缓存小写副本,
|
||||||
|
* 避免每次按键对全部单元格重新分配 toLowerCase 字符串。 */
|
||||||
|
getCellSearchText: (row: Row, columnIndex: number) => string;
|
||||||
debounceMs?: number;
|
debounceMs?: number;
|
||||||
onNavigate?: (match: DataGridSearchMatch) => void;
|
onNavigate?: (match: DataGridSearchMatch) => void;
|
||||||
};
|
};
|
||||||
@@ -38,12 +41,13 @@ export function useDataGridSearch<Row>(options: UseDataGridSearchOptions<Row>) {
|
|||||||
});
|
});
|
||||||
toValue(options.rows).forEach((row, displayRow) => {
|
toValue(options.rows).forEach((row, displayRow) => {
|
||||||
columns.forEach((_, col) => {
|
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;
|
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);
|
const currentMatch = computed(() => matches.value[currentMatchIndex.value] ?? null);
|
||||||
|
|
||||||
function clearTimer() {
|
function clearTimer() {
|
||||||
|
|||||||
@@ -25,6 +25,13 @@ export interface CanvasEditingCell {
|
|||||||
col: number;
|
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 {
|
export interface CanvasSearchMatch {
|
||||||
kind: "cell" | "column";
|
kind: "cell" | "column";
|
||||||
displayRow: number;
|
displayRow: number;
|
||||||
@@ -50,7 +57,7 @@ export interface DrawCanvasDataGridOptions {
|
|||||||
hoverCell: CanvasHoverCell | null;
|
hoverCell: CanvasHoverCell | null;
|
||||||
isScrolling: boolean;
|
isScrolling: boolean;
|
||||||
editingCell: CanvasEditingCell | null;
|
editingCell: CanvasEditingCell | null;
|
||||||
searchMatchKeys: ReadonlySet<string>;
|
searchMatchKeys: ReadonlySet<number>;
|
||||||
currentSearchMatch: CanvasSearchMatch | null;
|
currentSearchMatch: CanvasSearchMatch | null;
|
||||||
formatCell: (value: CellValue, columnIndex: number) => string;
|
formatCell: (value: CellValue, columnIndex: number) => string;
|
||||||
draftCellPlaceholder?: string;
|
draftCellPlaceholder?: string;
|
||||||
@@ -334,7 +341,7 @@ export function drawCanvasDataGrid(options: DrawCanvasDataGridOptions) {
|
|||||||
const isDirtyCell = item.isDirtyCol[actualColIdx];
|
const isDirtyCell = item.isDirtyCol[actualColIdx];
|
||||||
const selectedFillVisual = rowSelectionVisual || selectedCell;
|
const selectedFillVisual = rowSelectionVisual || selectedCell;
|
||||||
const selectedBorderVisual = 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 isCurrentSearchMatch = paintSearchMatches && currentSearchMatch?.displayRow === item.displayIndex && currentSearchMatch.col === actualColIdx;
|
||||||
const clippedX = Math.max(drawX, rowNumberWidth);
|
const clippedX = Math.max(drawX, rowNumberWidth);
|
||||||
const cellPaintWidth = Math.min(width, drawX + colWidth) - clippedX;
|
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 };
|
||||||
|
}
|
||||||
+53
@@ -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);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user