️ perf(ui): 优化数据页滚动与编辑响应

- 优化 DataGrid 虚拟滚动横向同步与外部滚动条宽度计算
- 降低 v2 数据表内容容器的重绘与持久化写入开销
- 拆分 Tab 内容渲染并收敛 QueryEditor 对活跃标签的订阅
- 修复虚拟编辑态与单元格右键菜单的共享渲染路径
- 调整 v2 数据表编辑态样式并补齐性能复现 harness 对照能力
- 补充 DataGrid 布局与滚动相关回归测试
This commit is contained in:
Syngnat
2026-05-27 19:56:14 +08:00
parent 17695c361d
commit ccd12742d3
10 changed files with 721 additions and 125 deletions

View File

@@ -1,15 +1,111 @@
import React, { useMemo, useState } from 'react';
import { Alert, Button, Card, InputNumber, Select, Space, Typography } from 'antd';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { Alert, Button, Card, InputNumber, Segmented, Select, Space, Typography } from 'antd';
import DataGrid, { GONAVI_ROW_KEY } from '../components/DataGrid';
import { useStore } from '../store';
import type { EditRowLocator } from '../utils/rowLocator';
import type { DataTableDensity } from '../utils/dataGridDisplay';
const { Text } = Typography;
type HarnessUiVersion = 'legacy' | 'v2';
type HarnessTheme = 'light' | 'dark';
type HarnessRow = Record<string, any> & {
[GONAVI_ROW_KEY]: string;
};
type HarnessRuntimeConfig = {
uiVersion: HarnessUiVersion;
density: DataTableDensity;
theme: HarnessTheme;
uiScale: number;
fontSize: number;
};
type HarnessRestoreSnapshot = {
appearance: ReturnType<typeof useStore.getState>['appearance'];
theme: ReturnType<typeof useStore.getState>['theme'];
uiScale: number;
fontSize: number;
bodyUiVersion: string | null;
bodyTheme: string | null;
bodyFontSize: string;
rootVars: Record<string, string>;
};
const hasHarnessAppearanceDrift = (
appearance: ReturnType<typeof useStore.getState>['appearance'],
uiVersion: HarnessUiVersion,
density: DataTableDensity,
): boolean => (
appearance.uiVersion !== uiVersion
|| appearance.dataTableDensity !== density
|| appearance.dataTableFontSize !== null
|| appearance.dataTableFontSizeFollowGlobal !== true
);
const DEFAULT_HARNESS_CONFIG: HarnessRuntimeConfig = {
uiVersion: 'legacy',
density: 'comfortable',
theme: 'light',
uiScale: 1,
fontSize: 14,
};
const clampHarnessUiScale = (value: unknown): number => {
if (value === null || value === undefined || value === '') {
return DEFAULT_HARNESS_CONFIG.uiScale;
}
const numeric = Number(value);
if (!Number.isFinite(numeric)) return DEFAULT_HARNESS_CONFIG.uiScale;
return Math.min(1.25, Math.max(0.8, numeric));
};
const clampHarnessFontSize = (value: unknown): number => {
if (value === null || value === undefined || value === '') {
return DEFAULT_HARNESS_CONFIG.fontSize;
}
const numeric = Number(value);
if (!Number.isFinite(numeric)) return DEFAULT_HARNESS_CONFIG.fontSize;
return Math.min(20, Math.max(12, Math.round(numeric)));
};
const readHarnessRuntimeConfig = (): HarnessRuntimeConfig => {
if (typeof window === 'undefined') {
return { ...DEFAULT_HARNESS_CONFIG };
}
try {
const searchParams = new URLSearchParams(window.location.search);
const uiVersion = searchParams.get('uiVersion') === 'v2' ? 'v2' : DEFAULT_HARNESS_CONFIG.uiVersion;
const densityRaw = searchParams.get('density');
const density: DataTableDensity = densityRaw === 'compact' || densityRaw === 'standard'
? densityRaw
: DEFAULT_HARNESS_CONFIG.density;
const theme = searchParams.get('theme') === 'dark' ? 'dark' : DEFAULT_HARNESS_CONFIG.theme;
return {
uiVersion,
density,
theme,
uiScale: clampHarnessUiScale(searchParams.get('uiScale')),
fontSize: clampHarnessFontSize(searchParams.get('fontSize')),
};
} catch {
return { ...DEFAULT_HARNESS_CONFIG };
}
};
const DOCUMENT_ROOT_VAR_KEYS = [
'--gonavi-font-size',
'--gn-ui-scale',
'--gn-font-size',
'--gn-font-size-sm',
'--gn-font-size-xs',
'--gn-font-size-mono',
'--gn-data-table-font-size',
'--gn-sidebar-tree-font-size',
] as const;
const buildHarnessColumns = (count: number): string[] => {
const safeCount = Math.max(8, Math.min(64, Math.trunc(count || 0)));
return Array.from({ length: safeCount }, (_, index) => {
@@ -62,12 +158,113 @@ const HARNESS_EDIT_LOCATOR: EditRowLocator = {
};
const PerfDataGridHarness: React.FC = () => {
const initialConfig = useMemo(() => readHarnessRuntimeConfig(), []);
const setAppearance = useStore((state) => state.setAppearance);
const setTheme = useStore((state) => state.setTheme);
const setUiScale = useStore((state) => state.setUiScale);
const setFontSize = useStore((state) => state.setFontSize);
const [rowCount, setRowCount] = useState(10000);
const [columnCount, setColumnCount] = useState(24);
const [density, setDensity] = useState<'compact' | 'comfortable' | 'spacious'>('comfortable');
const [uiVersion, setUiVersion] = useState<HarnessUiVersion>(initialConfig.uiVersion);
const [density, setDensity] = useState<DataTableDensity>(initialConfig.density);
const restoreSnapshotRef = useRef<HarnessRestoreSnapshot | null>(null);
const columnNames = useMemo(() => buildHarnessColumns(columnCount), [columnCount]);
const data = useMemo(() => buildHarnessData(rowCount, columnNames), [rowCount, columnNames]);
const effectiveUiScale = clampHarnessUiScale(initialConfig.uiScale);
const effectiveFontSize = clampHarnessFontSize(initialConfig.fontSize);
const effectiveDataTableFontSize = effectiveFontSize;
useEffect(() => {
if (restoreSnapshotRef.current) return;
const currentState = useStore.getState();
restoreSnapshotRef.current = {
appearance: { ...currentState.appearance },
theme: currentState.theme,
uiScale: currentState.uiScale,
fontSize: currentState.fontSize,
bodyUiVersion: document.body.getAttribute('data-ui-version'),
bodyTheme: document.body.getAttribute('data-theme'),
bodyFontSize: document.body.style.fontSize,
rootVars: Object.fromEntries(
DOCUMENT_ROOT_VAR_KEYS.map((key) => [key, document.documentElement.style.getPropertyValue(key)])
),
};
return () => {
const snapshot = restoreSnapshotRef.current;
if (!snapshot) return;
useStore.getState().setAppearance(snapshot.appearance);
useStore.getState().setTheme(snapshot.theme);
useStore.getState().setUiScale(snapshot.uiScale);
useStore.getState().setFontSize(snapshot.fontSize);
if (snapshot.bodyUiVersion) {
document.body.setAttribute('data-ui-version', snapshot.bodyUiVersion);
} else {
document.body.removeAttribute('data-ui-version');
}
if (snapshot.bodyTheme) {
document.body.setAttribute('data-theme', snapshot.bodyTheme);
} else {
document.body.removeAttribute('data-theme');
}
document.body.style.fontSize = snapshot.bodyFontSize;
DOCUMENT_ROOT_VAR_KEYS.forEach((key) => {
const value = snapshot.rootVars[key];
if (value) {
document.documentElement.style.setProperty(key, value);
return;
}
document.documentElement.style.removeProperty(key);
});
restoreSnapshotRef.current = null;
};
}, []);
useEffect(() => {
const currentState = useStore.getState();
if (hasHarnessAppearanceDrift(currentState.appearance, uiVersion, density)) {
setAppearance({
uiVersion,
dataTableDensity: density,
dataTableFontSize: null,
dataTableFontSizeFollowGlobal: true,
});
}
if (currentState.theme !== initialConfig.theme) {
setTheme(initialConfig.theme);
}
if (Math.abs(currentState.uiScale - initialConfig.uiScale) > 0.0001) {
setUiScale(initialConfig.uiScale);
}
if (currentState.fontSize !== initialConfig.fontSize) {
setFontSize(initialConfig.fontSize);
}
}, [
density,
initialConfig.fontSize,
initialConfig.theme,
initialConfig.uiScale,
setAppearance,
setFontSize,
setTheme,
setUiScale,
uiVersion,
]);
useEffect(() => {
document.body.setAttribute('data-theme', initialConfig.theme);
document.body.setAttribute('data-ui-version', uiVersion);
document.body.style.fontSize = `${effectiveFontSize}px`;
document.documentElement.style.setProperty('--gonavi-font-size', `${effectiveFontSize}px`);
document.documentElement.style.setProperty('--gn-ui-scale', `${effectiveUiScale}`);
document.documentElement.style.setProperty('--gn-font-size', `${effectiveFontSize}px`);
document.documentElement.style.setProperty('--gn-font-size-sm', `${Math.max(10, Math.round(effectiveFontSize * 0.86))}px`);
document.documentElement.style.setProperty('--gn-font-size-xs', `${Math.max(9, Math.round(effectiveFontSize * 0.76))}px`);
document.documentElement.style.setProperty('--gn-font-size-mono', `${Math.max(10, Math.round(effectiveDataTableFontSize * 0.92))}px`);
document.documentElement.style.setProperty('--gn-data-table-font-size', `${effectiveDataTableFontSize}px`);
document.documentElement.style.setProperty('--gn-sidebar-tree-font-size', `${effectiveFontSize}px`);
}, [effectiveDataTableFontSize, effectiveFontSize, effectiveUiScale, initialConfig.theme, uiVersion]);
return (
<div style={{ height: '100vh', overflow: 'hidden', background: '#0b1220', padding: 16, boxSizing: 'border-box' }}>
@@ -90,6 +287,14 @@ const PerfDataGridHarness: React.FC = () => {
>
<Space wrap align="center" size={12}>
<Text strong>DataGrid </Text>
<Segmented
value={uiVersion}
onChange={(value) => setUiVersion(value as HarnessUiVersion)}
options={[
{ label: '旧版 UI', value: 'legacy' },
{ label: '新版 UI', value: 'v2' },
]}
/>
<InputNumber
min={200}
max={50000}
@@ -111,9 +316,9 @@ const PerfDataGridHarness: React.FC = () => {
style={{ width: 140 }}
onChange={(value) => setDensity(value)}
options={[
{ value: 'compact', label: '紧凑' },
{ value: 'comfortable', label: '标准' },
{ value: 'spacious', label: '宽松' },
{ value: 'standard', label: '紧凑' },
{ value: 'compact', label: '极紧凑' },
]}
/>
<Button
@@ -128,7 +333,7 @@ const PerfDataGridHarness: React.FC = () => {
type="info"
showIcon
message="这个页面只用于开发态滚动性能采样"
description={`当前 ${data.length} 行 / ${columnNames.length} 列。直接在表格区域做纵向、横向、Shift+滚轮滚动采样。`}
description={`当前 ${uiVersion === 'v2' ? '新版' : '旧版'} UI${data.length} 行 / ${columnNames.length} 列。直接在表格区域做纵向、横向、Shift+滚轮滚动采样。`}
/>
<div style={{ flex: '1 1 auto', minHeight: 0, display: 'flex', flexDirection: 'column' }}>
<DataGrid