️ perf(query-editor): 优化输入与选区交互流畅度

- 为 Monaco 编辑器及语言服务接入独立 Worker
- 限制大规模对象元数据与列装饰扫描范围
- 仅在按下导航修饰键时更新悬停状态
- 避免同一数据库上下文重复重建装饰并补充回归测试
This commit is contained in:
Syngnat
2026-07-13 12:54:02 +08:00
parent 1ae2b74279
commit 4bee3cefa6
5 changed files with 139 additions and 6 deletions

View File

@@ -20,6 +20,32 @@ const isTestRuntime = (): boolean => {
return env.MODE === 'test' || env.VITEST === true || env.VITEST === 'true';
};
type MonacoWorkerFactory = () => Worker;
interface MonacoWorkerFactories {
editor: MonacoWorkerFactory;
json: MonacoWorkerFactory;
css: MonacoWorkerFactory;
html: MonacoWorkerFactory;
typescript: MonacoWorkerFactory;
}
export const installMonacoWorkerEnvironment = (
scope: Record<string, any>,
workers: MonacoWorkerFactories,
) => {
scope.MonacoEnvironment = {
...(scope.MonacoEnvironment || {}),
getWorker(_moduleId: string, label: string) {
if (label === 'json') return workers.json();
if (label === 'css' || label === 'scss' || label === 'less') return workers.css();
if (label === 'html' || label === 'handlebars' || label === 'razor') return workers.html();
if (label === 'typescript' || label === 'javascript') return workers.typescript();
return workers.editor();
},
};
};
const sameEditorPosition = (left: any, right: any): boolean => (
Number(left?.lineNumber) === Number(right?.lineNumber)
&& Number(left?.column) === Number(right?.column)
@@ -539,8 +565,22 @@ const ensureMonacoConfigured = (): Promise<void> => {
if (!monacoConfiguredPromise) {
monacoConfiguredPromise = import('monaco-editor/esm/nls.messages.zh-cn')
.then(() => import('monaco-editor'))
.then((monaco) => {
.then(() => Promise.all([
import('monaco-editor'),
import('monaco-editor/esm/vs/editor/editor.worker?worker'),
import('monaco-editor/esm/vs/language/json/json.worker?worker'),
import('monaco-editor/esm/vs/language/css/css.worker?worker'),
import('monaco-editor/esm/vs/language/html/html.worker?worker'),
import('monaco-editor/esm/vs/language/typescript/ts.worker?worker'),
]))
.then(([monaco, editorWorker, jsonWorker, cssWorker, htmlWorker, typescriptWorker]) => {
installMonacoWorkerEnvironment(globalThis as unknown as Record<string, any>, {
editor: () => new editorWorker.default(),
json: () => new jsonWorker.default(),
css: () => new cssWorker.default(),
html: () => new htmlWorker.default(),
typescript: () => new typescriptWorker.default(),
});
loader.config({ monaco });
});
}

View File

@@ -0,0 +1,28 @@
import { describe, expect, it, vi } from 'vitest';
import { installMonacoWorkerEnvironment } from './MonacoEditor';
describe('MonacoEditor worker environment', () => {
it('routes Monaco languages to bundled workers', () => {
const createWorker = (name: string) => vi.fn(() => ({ name } as unknown as Worker));
const workers = {
editor: createWorker('editor'),
json: createWorker('json'),
css: createWorker('css'),
html: createWorker('html'),
typescript: createWorker('typescript'),
};
const scope: Record<string, any> = {};
installMonacoWorkerEnvironment(scope, workers);
expect(scope.MonacoEnvironment.getWorker('', 'json')).toEqual({ name: 'json' });
expect(scope.MonacoEnvironment.getWorker('', 'css')).toEqual({ name: 'css' });
expect(scope.MonacoEnvironment.getWorker('', 'scss')).toEqual({ name: 'css' });
expect(scope.MonacoEnvironment.getWorker('', 'html')).toEqual({ name: 'html' });
expect(scope.MonacoEnvironment.getWorker('', 'handlebars')).toEqual({ name: 'html' });
expect(scope.MonacoEnvironment.getWorker('', 'typescript')).toEqual({ name: 'typescript' });
expect(scope.MonacoEnvironment.getWorker('', 'javascript')).toEqual({ name: 'typescript' });
expect(scope.MonacoEnvironment.getWorker('', 'sql')).toEqual({ name: 'editor' });
});
});

View File

@@ -6017,6 +6017,27 @@ describe('QueryEditor external SQL save', () => {
expect(editorState.editor.deltaDecorations).not.toHaveBeenCalled();
});
it('does not churn decorations while selecting text without a navigation modifier', async () => {
editorState.value = 'select users.id from users';
await act(async () => {
create(<QueryEditor tab={createTab({ query: editorState.value })} />);
});
editorState.editor.updateOptions.mockClear();
editorState.editor.deltaDecorations.mockClear();
await act(async () => {
editorState.mouseMoveListeners[0]?.({
target: { position: { lineNumber: 1, column: 10 } },
event: { ctrlKey: false, metaKey: false },
});
});
expect(editorState.editor.updateOptions).not.toHaveBeenCalled();
expect(editorState.editor.deltaDecorations).not.toHaveBeenCalled();
});
it('ignores candidate number keys while a composition session is active', async () => {
const windowListeners: Record<string, ((event?: any) => void)[]> = {};
vi.stubGlobal('window', {
@@ -6654,6 +6675,35 @@ describe('QueryEditor external SQL save', () => {
expect(editorState.editor.getModel().getValue).not.toHaveBeenCalled();
});
it('does not rescan object decorations after repeated edits in the same database context', async () => {
vi.useFakeTimers();
try {
await act(async () => {
create(<QueryEditor tab={createTab({ query: 'select 1;' })} />);
});
const emitChange = async (value: string) => {
editorState.value = value;
editorState.latestOnChange?.(value);
editorState.modelContentListeners.forEach((listener) => listener({
changes: [{ text: value }],
}));
await act(async () => {
vi.advanceTimersByTime(450);
await Promise.resolve();
});
};
await emitChange('select users.id from users;');
editorState.editor.deltaDecorations.mockClear();
await emitChange('select users.id, users.name from users;');
expect(editorState.editor.deltaDecorations).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
});
it('ignores focused local tab query echoes so IME candidate commits are not overwritten', async () => {
let renderer!: ReactTestRenderer;

View File

@@ -2113,6 +2113,20 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
const decorations: any[] = [];
const seen = new Set<string>();
const candidates = collectQueryEditorObjectDecorationCandidates(text);
const objectMetadataCount = tablesRef.current.length
+ viewsRef.current.length
+ materializedViewsRef.current.length
+ triggersRef.current.length
+ routinesRef.current.length
+ sequencesRef.current.length
+ packagesRef.current.length;
if (objectMetadataCount > 5_000) {
objectDecorationIdsRef.current = editor.deltaDecorations(objectDecorationIdsRef.current, []);
return;
}
const decorationColumns = allColumnsRef.current.length <= 2_000
? allColumnsRef.current
: [];
for (const candidate of candidates) {
const hoverTarget = resolveQueryEditorHoverTarget(
@@ -2122,7 +2136,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
currentDbRef.current,
visibleDbsRef.current,
tablesRef.current,
allColumnsRef.current,
decorationColumns,
viewsRef.current,
materializedViewsRef.current,
triggersRef.current,
@@ -3958,6 +3972,9 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
const applyNavigationHoverState = (event: any) => {
const targetPosition = normalizeEditorPosition(event?.target?.position);
lastHoverTargetPositionRef.current = targetPosition;
if (!ctrlMetaPressedRef.current) {
return;
}
applyNavigationHoverStateAtPosition(targetPosition);
};
@@ -4298,8 +4315,6 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
...referencedDbs.map((dbName) => String(dbName || '').toLowerCase()).sort(),
].join('\u0000');
if (nextKey === lastSqlReferencedMetadataKeyRef.current) {
// 库集合未变:仍刷新装饰(可能表列表已异步到位)
refreshObjectDecorations(QUERY_EDITOR_LIVE_DECORATION_MAX_TEXT_LENGTH);
return;
}
lastSqlReferencedMetadataKeyRef.current = nextKey;

View File

@@ -1109,7 +1109,7 @@ export const QUERY_EDITOR_SQL_ALIAS_REFERENCE_REGEX = new RegExp(
export const QUERY_EDITOR_SQL_LEADING_IDENTIFIER_PATH_REGEX = new RegExp(`^(${QUERY_EDITOR_SQL_IDENTIFIER_PATH_PATTERN})([\\s\\S]*)$`);
export const QUERY_EDITOR_HOVER_DELAY_MS = 1000;
export const QUERY_EDITOR_OBJECT_DECORATION_MAX_TEXT_LENGTH = 200_000;
export const QUERY_EDITOR_OBJECT_DECORATION_MAX_IDENTIFIERS = 800;
export const QUERY_EDITOR_OBJECT_DECORATION_MAX_IDENTIFIERS = 200;
export const QUERY_EDITOR_OBJECT_DECORATION_MAX_LINES = 1_000;
export const QUERY_EDITOR_LIVE_DECORATION_MAX_TEXT_LENGTH = 50_000;
export const QUERY_EDITOR_PERSISTED_DRAFT_MAX_TEXT_LENGTH = 50_000;