perf(table-overview): 优化大量表搜索渲染性能

- 预计算表概览搜索索引与排序键
- 使用 deferred value 降低搜索输入阻塞
- 限制大结果集首批渲染数量并支持继续加载
- 增加表概览过滤与渲染上限回归测试
This commit is contained in:
Syngnat
2026-04-26 20:42:14 +08:00
parent a06f45da28
commit 3b9116e259
3 changed files with 185 additions and 25 deletions

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { Input, Spin, Empty, Dropdown, message, Tooltip, Modal } from 'antd';
import React, { useState, useEffect, useMemo, useCallback, useDeferredValue } from 'react';
import { Input, Spin, Empty, Dropdown, message, Tooltip, Modal, Button } from 'antd';
import { TableOutlined, SearchOutlined, ReloadOutlined, SortAscendingOutlined, DatabaseOutlined, ConsoleSqlOutlined, EditOutlined, CopyOutlined, SaveOutlined, DeleteOutlined, ExportOutlined, AppstoreOutlined, UnorderedListOutlined, WarningOutlined } from '@ant-design/icons';
import { useStore } from '../store';
import { DBQuery, DBShowCreateTable, ExportTable, DropTable, RenameTable } from '../../wailsjs/go/app/App';
@@ -9,6 +9,14 @@ import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
import { noAutoCapInputProps } from '../utils/inputAutoCap';
import { getTableDataDangerActionMeta, supportsTableTruncateAction, type TableDataDangerActionKind } from './tableDataDangerActions';
import { buildTableSelectQuery } from '../utils/objectQueryTemplates';
import {
TABLE_OVERVIEW_RENDER_BATCH_SIZE,
buildTableOverviewSearchIndex,
filterAndSortTableOverviewRows,
resolveTableOverviewVisibleRows,
type TableOverviewSortField,
type TableOverviewSortOrder,
} from '../utils/tableOverviewFilter';
interface TableOverviewProps {
tab: TabData;
@@ -25,8 +33,8 @@ interface TableStatRow {
updateTime: string;
}
type SortField = 'name' | 'rows' | 'dataSize';
type SortOrder = 'asc' | 'desc';
type SortField = TableOverviewSortField;
type SortOrder = TableOverviewSortOrder;
type ViewMode = 'card' | 'list';
const formatSize = (bytes: number): string => {
@@ -166,6 +174,9 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
const [sortField, setSortField] = useState<SortField>('name');
const [sortOrder, setSortOrder] = useState<SortOrder>('asc');
const [viewMode, setViewMode] = useState<ViewMode>('list');
const [visibleTableLimit, setVisibleTableLimit] = useState(TABLE_OVERVIEW_RENDER_BATCH_SIZE);
const deferredSearchText = useDeferredValue(searchText);
const isSearchPending = searchText !== deferredSearchText;
const connection = useMemo(() => connections.find(c => c.id === tab.connectionId), [connections, tab.connectionId]);
const metadataDialect = useMemo(
@@ -207,21 +218,21 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
void loadData();
}, [autoFetchVisible, loadData]);
const sortedFiltered = useMemo(() => {
let list = [...tables];
if (searchText.trim()) {
const kw = searchText.trim().toLowerCase();
list = list.filter(t => t.name.toLowerCase().includes(kw) || t.comment.toLowerCase().includes(kw));
}
list.sort((a, b) => {
let cmp = 0;
if (sortField === 'name') cmp = a.name.toLowerCase().localeCompare(b.name.toLowerCase());
else if (sortField === 'rows') cmp = a.rows - b.rows;
else if (sortField === 'dataSize') cmp = a.dataSize - b.dataSize;
return sortOrder === 'asc' ? cmp : -cmp;
});
return list;
}, [tables, searchText, sortField, sortOrder]);
const tableSearchIndex = useMemo(() => buildTableOverviewSearchIndex(tables), [tables]);
const sortedFiltered = useMemo(() => (
filterAndSortTableOverviewRows(tableSearchIndex, deferredSearchText, sortField, sortOrder)
), [deferredSearchText, sortField, sortOrder, tableSearchIndex]);
useEffect(() => {
setVisibleTableLimit(TABLE_OVERVIEW_RENDER_BATCH_SIZE);
}, [deferredSearchText, sortField, sortOrder, viewMode, tables]);
const visibleOverview = useMemo(() => (
resolveTableOverviewVisibleRows(sortedFiltered, visibleTableLimit)
), [sortedFiltered, visibleTableLimit]);
const visibleTables = visibleOverview.visibleRows;
const openTable = useCallback((tableName: string) => {
if (!connection) return;
@@ -397,11 +408,11 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
{ key: 'dataSize', label: `按大小${sortField === 'dataSize' ? (sortOrder === 'asc' ? ' ↑' : ' ↓') : ''}`, onClick: () => toggleSort('dataSize') },
];
const totalRows = tables.reduce((s, t) => s + t.rows, 0);
const totalSize = tables.reduce((s, t) => s + t.dataSize + t.indexSize, 0);
const maxCombinedSize = sortedFiltered.reduce((max, table) => {
const totalRows = useMemo(() => tables.reduce((s, t) => s + t.rows, 0), [tables]);
const totalSize = useMemo(() => tables.reduce((s, t) => s + t.dataSize + t.indexSize, 0), [tables]);
const maxCombinedSize = useMemo(() => sortedFiltered.reduce((max, table) => {
return Math.max(max, table.dataSize + table.indexSize);
}, 0);
}, 0), [sortedFiltered]);
const allowTruncate = supportsTableTruncateAction(connection?.config?.type || '', connection?.config?.driver);
if (loading) {
@@ -468,6 +479,31 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
{/* Content Area */}
<div style={{ flex: 1, overflow: 'auto', padding: '0 16px 16px 16px' }}>
{sortedFiltered.length > 0 && (isSearchPending || visibleOverview.hiddenCount > 0 || deferredSearchText.trim()) && (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
marginBottom: 10,
padding: '8px 10px',
borderRadius: 10,
background: darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.025)',
color: textMuted,
fontSize: 12,
}}
>
<span>
{isSearchPending
? '正在更新筛选结果...'
: `匹配 ${sortedFiltered.length} 张表,当前渲染 ${visibleTables.length}`}
</span>
{visibleOverview.hiddenCount > 0 && (
<span> {visibleOverview.hiddenCount} </span>
)}
</div>
)}
{sortedFiltered.length === 0 ? (
<Empty description={searchText ? '无匹配结果' : '暂无表'} style={{ marginTop: 80 }} />
) : viewMode === 'card' ? (
@@ -477,7 +513,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))',
gap: 12,
}}>
{sortedFiltered.map(t => (
{visibleTables.map(t => (
<Dropdown
key={t.name}
trigger={['contextMenu']}
@@ -556,7 +592,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
) : (
/* ========== 行视图 ========== */
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{sortedFiltered.map(t => {
{visibleTables.map(t => {
const combinedSize = t.dataSize + t.indexSize;
const sizeRatio = maxCombinedSize > 0 ? combinedSize / maxCombinedSize : 0;
const fillWidth = maxCombinedSize > 0 ? `${Math.max(10, Math.round(sizeRatio * 100))}%` : '0%';
@@ -695,6 +731,16 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
})}
</div>
)}
{sortedFiltered.length > 0 && visibleOverview.hiddenCount > 0 && (
<div style={{ display: 'flex', justifyContent: 'center', padding: '16px 0 4px' }}>
<Button
size="small"
onClick={() => setVisibleTableLimit(limit => limit + TABLE_OVERVIEW_RENDER_BATCH_SIZE)}
>
{visibleOverview.hiddenCount}
</Button>
</div>
)}
</div>
</div>
);

View File

@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest';
import {
TABLE_OVERVIEW_RENDER_BATCH_SIZE,
buildTableOverviewSearchIndex,
filterAndSortTableOverviewRows,
resolveTableOverviewVisibleRows,
} from './tableOverviewFilter';
const buildRows = (count: number) => Array.from({ length: count }, (_, index) => ({
name: `table_${String(index).padStart(4, '0')}`,
comment: index === count - 1 ? 'target table comment' : 'normal table',
rows: index,
dataSize: count - index,
indexSize: 0,
}));
describe('tableOverviewFilter', () => {
it('filters against the full table set before applying the render limit', () => {
const rows = buildRows(1200);
const indexed = buildTableOverviewSearchIndex(rows);
const filtered = filterAndSortTableOverviewRows(indexed, 'target', 'name', 'asc');
expect(filtered).toHaveLength(1);
expect(filtered[0].name).toBe('table_1199');
});
it('caps initially rendered rows for large overview result sets', () => {
const rows = buildRows(1200);
const visible = resolveTableOverviewVisibleRows(rows, TABLE_OVERVIEW_RENDER_BATCH_SIZE);
expect(visible.visibleRows).toHaveLength(TABLE_OVERVIEW_RENDER_BATCH_SIZE);
expect(visible.hiddenCount).toBe(1200 - TABLE_OVERVIEW_RENDER_BATCH_SIZE);
expect(visible.totalCount).toBe(1200);
});
it('sorts with precomputed normalized table names', () => {
const indexed = buildTableOverviewSearchIndex([
{ name: 'z_table', comment: '', rows: 1, dataSize: 10, indexSize: 0 },
{ name: 'A_table', comment: '', rows: 2, dataSize: 5, indexSize: 0 },
]);
expect(filterAndSortTableOverviewRows(indexed, '', 'name', 'asc').map((item) => item.name)).toEqual([
'A_table',
'z_table',
]);
});
});

View File

@@ -0,0 +1,66 @@
export const TABLE_OVERVIEW_RENDER_BATCH_SIZE = 300;
export type TableOverviewSortField = 'name' | 'rows' | 'dataSize';
export type TableOverviewSortOrder = 'asc' | 'desc';
export interface TableOverviewFilterRow {
name: string;
comment?: string;
rows: number;
dataSize: number;
indexSize: number;
}
export interface TableOverviewSearchIndexItem<T extends TableOverviewFilterRow> {
row: T;
searchText: string;
sortName: string;
}
export const buildTableOverviewSearchIndex = <T extends TableOverviewFilterRow>(
rows: T[],
): TableOverviewSearchIndexItem<T>[] => rows.map((row) => ({
row,
searchText: `${row.name}\n${row.comment || ''}`.toLowerCase(),
sortName: row.name.toLowerCase(),
}));
export const filterAndSortTableOverviewRows = <T extends TableOverviewFilterRow>(
indexedRows: TableOverviewSearchIndexItem<T>[],
rawSearchText: string,
sortField: TableOverviewSortField,
sortOrder: TableOverviewSortOrder,
): T[] => {
const keyword = String(rawSearchText || '').trim().toLowerCase();
const matched = keyword
? indexedRows.filter((item) => item.searchText.includes(keyword))
: [...indexedRows];
matched.sort((a, b) => {
let cmp = 0;
if (sortField === 'name') {
cmp = a.sortName.localeCompare(b.sortName);
} else if (sortField === 'rows') {
cmp = a.row.rows - b.row.rows;
} else if (sortField === 'dataSize') {
cmp = a.row.dataSize - b.row.dataSize;
}
return sortOrder === 'asc' ? cmp : -cmp;
});
return matched.map((item) => item.row);
};
export const resolveTableOverviewVisibleRows = <T>(
rows: T[],
rawLimit: number,
): { visibleRows: T[]; hiddenCount: number; totalCount: number } => {
const limit = Number.isFinite(rawLimit) && rawLimit > 0
? Math.min(Math.floor(rawLimit), rows.length)
: Math.min(TABLE_OVERVIEW_RENDER_BATCH_SIZE, rows.length);
return {
visibleRows: rows.slice(0, limit),
hiddenCount: Math.max(0, rows.length - limit),
totalCount: rows.length,
};
};