️ perf(webview): 降低首屏加载与 WebView2 内存占用

- Monaco Editor 改为首次使用时按需初始化
- AI 面板改为懒加载,延后加载 Markdown 和图表渲染依赖
- 增加 Windows 低内存视觉模式,支持关闭透明 WebView 和 Acrylic
- 补充低内存启动说明与模式解析测试
This commit is contained in:
Syngnat
2026-05-16 11:18:48 +08:00
parent a5be4cc3ae
commit cfbfda4de3
18 changed files with 184 additions and 40 deletions

View File

@@ -137,7 +137,7 @@ GoNavi is designed for developers and DBAs who need a unified desktop experience
### Development Mode
```bash
```shell
# Clone
git clone https://github.com/Syngnat/GoNavi.git
cd GoNavi
@@ -150,6 +150,9 @@ node tools/wails-fast-dev.mjs
# Refresh Wails JS bindings after changing exported Go method signatures
node tools/wails-fast-dev.mjs --refresh-bindings
# Windows PowerShell low-memory visual mode: disables transparent WebView/Acrylic backdrop
$env:GONAVI_LOW_MEMORY_MODE="1"; node tools/wails-fast-dev.mjs
```
### Build

View File

@@ -131,7 +131,7 @@ GoNavi 面向开发者与 DBA核心目标是让数据库操作在桌面端做
### 开发模式
```bash
```shell
# 克隆项目
git clone https://github.com/Syngnat/GoNavi.git
cd GoNavi
@@ -144,6 +144,9 @@ node tools/wails-fast-dev.mjs
# 修改 Go 导出方法签名后刷新 Wails JS 绑定
node tools/wails-fast-dev.mjs --refresh-bindings
# Windows PowerShell 低内存视觉模式:关闭透明 WebView 和 Acrylic 背景
$env:GONAVI_LOW_MEMORY_MODE="1"; node tools/wails-fast-dev.mjs
```
### 编译构建

View File

@@ -11,7 +11,6 @@ import ConnectionPackagePasswordModal from './components/ConnectionPackagePasswo
import DataSyncModal from './components/DataSyncModal';
import DriverManagerModal from './components/DriverManagerModal';
import LogPanel from './components/LogPanel';
import AIChatPanel from './components/AIChatPanel';
import AISettingsModal from './components/AISettingsModal';
import SecurityUpdateBanner from './components/SecurityUpdateBanner';
import SecurityUpdateIntroModal from './components/SecurityUpdateIntroModal';
@@ -85,6 +84,8 @@ import {
import { ApplyDataRootDirectory, GetDataRootDirectoryInfo, GetSavedConnections, OpenDataRootDirectory, SelectDataRootDirectory, SetMacNativeWindowControls, SetWindowTranslucency } from '../wailsjs/go/app/App';
import './App.css';
const AIChatPanel = React.lazy(() => import('./components/AIChatPanel'));
const { Sider, Content } = Layout;
const MIN_UI_SCALE = 0.8;
const MAX_UI_SCALE = 1.25;
@@ -3007,9 +3008,11 @@ function App() {
{renderAIEdgeHandle()}
</div>
)}
<AIChatPanel darkMode={darkMode} bgColor={bgContent} onClose={() => setAIPanelVisible(false)} onOpenSettings={() => {
handleOpenAISettings();
}} overlayTheme={overlayTheme} />
<React.Suspense fallback={<div style={{ width: 360, display: 'flex', alignItems: 'center', justifyContent: 'center' }}><Spin size="small" /></div>}>
<AIChatPanel darkMode={darkMode} bgColor={bgContent} onClose={() => setAIPanelVisible(false)} onOpenSettings={() => {
handleOpenAISettings();
}} overlayTheme={overlayTheme} />
</React.Suspense>
</div>
)}
</div>

View File

@@ -5,7 +5,7 @@ import { Table, message, Input, Button, Dropdown, MenuProps, Form, Pagination, S
import dayjs from 'dayjs';
import type { SortOrder, ColumnType } from 'antd/es/table/interface';
import { ReloadOutlined, ImportOutlined, ExportOutlined, DownOutlined, PlusOutlined, DeleteOutlined, SaveOutlined, UndoOutlined, FilterOutlined, CloseOutlined, ConsoleSqlOutlined, FileTextOutlined, CopyOutlined, ClearOutlined, EditOutlined, VerticalAlignBottomOutlined, LeftOutlined, RightOutlined, RobotOutlined, SearchOutlined } from '@ant-design/icons';
import Editor from '@monaco-editor/react';
import Editor from './MonacoEditor';
import {
DndContext,
DragEndEvent,

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react';
import Editor from '@monaco-editor/react';
import Editor from './MonacoEditor';
import { Spin, Alert } from 'antd';
import { TabData } from '../types';
import { useStore } from '../store';

View File

@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import Editor, { type BeforeMount, type OnMount } from "@monaco-editor/react";
import Editor, { type BeforeMount, type OnMount } from "./MonacoEditor";
import {
Alert,
Button,

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import Editor from "@monaco-editor/react";
import Editor from "./MonacoEditor";
import {
Alert,
Button,

View File

@@ -0,0 +1,106 @@
import React, { useCallback, useEffect, useState } from 'react';
import Editor, { loader, type BeforeMount, type EditorProps } from '@monaco-editor/react';
export type { BeforeMount, OnMount } from '@monaco-editor/react';
let monacoConfiguredPromise: Promise<void> | null = null;
let transparentThemesRegistered = false;
const isTestRuntime = (): boolean => {
const env = (import.meta as unknown as { env?: Record<string, unknown> }).env || {};
return env.MODE === 'test' || env.VITEST === true || env.VITEST === 'true';
};
export const registerGonaviMonacoThemes: BeforeMount = (monaco) => {
if (transparentThemesRegistered) {
return;
}
monaco.editor.defineTheme('transparent-dark', {
base: 'vs-dark',
inherit: true,
rules: [],
colors: {
'editor.background': '#00000000',
'editor.lineHighlightBackground': '#ffffff10',
'editorGutter.background': '#00000000',
'editorStickyScroll.background': '#1e1e1e',
'editorStickyScrollHover.background': '#2a2a2a',
},
});
monaco.editor.defineTheme('transparent-light', {
base: 'vs',
inherit: true,
rules: [],
colors: {
'editor.background': '#00000000',
'editor.lineHighlightBackground': '#00000010',
'editorGutter.background': '#00000000',
'editorStickyScroll.background': '#ffffff',
'editorStickyScrollHover.background': '#f5f5f5',
},
});
transparentThemesRegistered = true;
};
const ensureMonacoConfigured = (): Promise<void> => {
if (isTestRuntime()) {
return Promise.resolve();
}
if (!monacoConfiguredPromise) {
monacoConfiguredPromise = import('monaco-editor/esm/nls.messages.zh-cn')
.then(() => import('monaco-editor'))
.then((monaco) => {
loader.config({ monaco });
});
}
return monacoConfiguredPromise;
};
const MonacoEditor: React.FC<EditorProps> = ({ beforeMount, loading, ...props }) => {
const [ready, setReady] = useState(isTestRuntime);
useEffect(() => {
let cancelled = false;
void ensureMonacoConfigured()
.then(() => {
if (!cancelled) {
setReady(true);
}
})
.catch((error) => {
console.error('Failed to configure Monaco Editor', error);
if (!cancelled) {
setReady(true);
}
});
return () => {
cancelled = true;
};
}, []);
const handleBeforeMount: BeforeMount = useCallback((monaco) => {
registerGonaviMonacoThemes(monaco);
beforeMount?.(monaco);
}, [beforeMount]);
if (!ready) {
return (
<div
data-monaco-editor-loading="true"
style={{ height: props.height || '100%', width: props.width || '100%' }}
>
{loading || null}
</div>
);
}
return <Editor {...props} loading={loading} beforeMount={handleBeforeMount} />;
};
export default MonacoEditor;

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect, useRef, useMemo } from 'react';
import Editor, { OnMount } from '@monaco-editor/react';
import Editor, { type OnMount } from './MonacoEditor';
import { Button, message, Modal, Input, Form, Dropdown, MenuProps, Tooltip, Select, Tabs } from 'antd';
import { PlayCircleOutlined, SaveOutlined, FormatPainterOutlined, SettingOutlined, CloseOutlined, StopOutlined, RobotOutlined } from '@ant-design/icons';
import { format } from 'sql-formatter';
@@ -924,7 +924,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
editorRef.current = editor;
monacoRef.current = monaco;
// 应用透明主题(主题已在 main.tsx 全局注册)
// 应用透明主题(主题由 MonacoEditor 包装组件按需注册)
monaco.editor.setTheme(darkMode ? 'transparent-dark' : 'transparent-light');
// 注册 AI 右键菜单操作

View File

@@ -3,7 +3,7 @@ import { Button, Space, message } from 'antd';
import { PlayCircleOutlined, ClearOutlined } from '@ant-design/icons';
import { useStore } from '../store';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
import Editor, { OnMount } from '@monaco-editor/react';
import Editor, { type OnMount } from './MonacoEditor';
interface RedisCommandEditorProps {
connectionId: string;

View File

@@ -5,7 +5,7 @@ import type { RadioChangeEvent } from 'antd';
import { ReloadOutlined, DeleteOutlined, PlusOutlined, EditOutlined, SearchOutlined, ClockCircleOutlined, CopyOutlined, FolderOpenOutlined, KeyOutlined, RightOutlined, DownOutlined } from '@ant-design/icons';
import { useStore } from '../store';
import { RedisKeyInfo, RedisValue, StreamEntry } from '../types';
import Editor from '@monaco-editor/react';
import Editor from './MonacoEditor';
import type { DataNode } from 'antd/es/tree';
import {
blurToFilter,

View File

@@ -4,7 +4,7 @@ import { ReloadOutlined, SaveOutlined, PlusOutlined, DeleteOutlined, MenuOutline
import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, DragOverlay } from '@dnd-kit/core';
import { arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import Editor, { loader } from '@monaco-editor/react';
import Editor from './MonacoEditor';
import { TabData, ColumnDefinition, IndexDefinition, ForeignKeyDefinition, TriggerDefinition } from '../types';
import { useStore } from '../store';
import { DBGetColumns, DBGetIndexes, DBQuery, DBGetForeignKeys, DBGetTriggers, DBShowCreateTable } from '../../wailsjs/go/app/App';
@@ -460,7 +460,7 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
setCommentEditorValue('');
}, []);
// 透明 Monaco Editor 主题已在 main.tsx 全局注册(含 stickyScroll 不透明背景)
// 透明 Monaco Editor 主题由 MonacoEditor 包装组件按需注册(含 stickyScroll 不透明背景)
// 监听字段 Tab 容器高度,为所有 Tab 内表格计算 scroll.y
// 当 Tab 切换时,字段 Tab 被 display:none 导致 height=0跳过该次更新保持有效值

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef } from 'react';
import Editor, { type BeforeMount, type OnMount } from '@monaco-editor/react';
import Editor, { type BeforeMount, type OnMount } from './MonacoEditor';
interface TableDesignerSqlPreviewProps {
sql: string;

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react';
import Editor from '@monaco-editor/react';
import Editor from './MonacoEditor';
import { Spin, Alert } from 'antd';
import { TabData } from '../types';
import { useStore } from '../store';
@@ -20,7 +20,7 @@ const TriggerViewer: React.FC<TriggerViewerProps> = ({ tab }) => {
const theme = useStore(state => state.theme);
const darkMode = theme === 'dark';
// 透明 Monaco Editor 主题已在 main.tsx 全局注册(含 stickyScroll 不透明背景)
// 透明 Monaco Editor 主题由 MonacoEditor 包装组件按需注册(含 stickyScroll 不透明背景)
const escapeSQLLiteral = (raw: string): string => String(raw || '').replace(/'/g, "''");
const quoteSqlServerIdentifier = (raw: string): string => `[${String(raw || '').replace(/]/g, ']]')}]`;

View File

@@ -9,14 +9,7 @@ import dayjs from 'dayjs'
import 'dayjs/locale/zh-cn'
dayjs.locale('zh-cn')
// 全局配置 Monaco Editor 使用本地打包的文件,避免从 CDN (jsdelivr) 加载。
// Windows WebView2 环境下访问外部 CDN 可能失败,导致编辑器一直显示 Loading。
// 中文语言包必须在 monaco-editor 主包之前导入,否则右键菜单等 UI 仍为英文。
import 'monaco-editor/esm/nls.messages.zh-cn'
import { loader } from '@monaco-editor/react'
import * as monaco from 'monaco-editor'
import { cloneBrowserMockValue, duplicateBrowserMockConnection, resolveBrowserMockSecretFlag } from './utils/browserMockConnections'
loader.config({ monaco })
if (typeof window !== 'undefined' && !(window as any).go) {
const mockConnections: any[] = [];
@@ -168,16 +161,6 @@ if (typeof window !== 'undefined' && !(window as any).go) {
}
};
}
// 全局注册透明主题,避免每个 Editor 组件 beforeMount 中重复定义
monaco.editor.defineTheme('transparent-dark', {
base: 'vs-dark', inherit: true, rules: [],
colors: { 'editor.background': '#00000000', 'editor.lineHighlightBackground': '#ffffff10', 'editorGutter.background': '#00000000', 'editorStickyScroll.background': '#1e1e1e', 'editorStickyScrollHover.background': '#2a2a2a' }
})
monaco.editor.defineTheme('transparent-light', {
base: 'vs', inherit: true, rules: [],
colors: { 'editor.background': '#00000000', 'editor.lineHighlightBackground': '#00000010', 'editorGutter.background': '#00000000', 'editorStickyScroll.background': '#ffffff', 'editorStickyScrollHover.background': '#f5f5f5' }
})
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />

View File

@@ -1,5 +1,7 @@
/// <reference types="vite/client" />
declare module 'monaco-editor/esm/nls.messages.zh-cn';
interface ImportMetaEnv {
readonly VITE_GONAVI_ENABLE_MAC_WINDOW_DIAGNOSTICS?: string;
}

26
main.go
View File

@@ -2,6 +2,8 @@ package main
import (
"context"
"os"
"strings"
aiservice "GoNavi-Wails/internal/ai/service"
"GoNavi-Wails/internal/app"
@@ -18,6 +20,13 @@ func main() {
// Create an instance of the app structure
application := app.NewApp()
aiService := aiservice.NewService()
lowMemoryMode := isLowMemoryMode()
backgroundColour := &options.RGBA{R: 0, G: 0, B: 0, A: 0}
windowsBackdrop := windows.Acrylic
if lowMemoryMode {
backgroundColour = &options.RGBA{R: 255, G: 255, B: 255, A: 255}
windowsBackdrop = windows.None
}
// Create application with options
err := wails.Run(&options.App{
@@ -28,7 +37,7 @@ func main() {
AssetServer: &assetserver.Options{
Assets: assets,
},
BackgroundColour: &options.RGBA{R: 0, G: 0, B: 0, A: 0},
BackgroundColour: backgroundColour,
OnStartup: func(ctx context.Context) {
app.InitializeLifecycle(application, ctx)
aiservice.InitializeLifecycle(aiService, ctx)
@@ -39,9 +48,9 @@ func main() {
aiService,
},
Windows: &windows.Options{
WebviewIsTransparent: true,
WindowIsTranslucent: true,
BackdropType: windows.Acrylic,
WebviewIsTransparent: !lowMemoryMode,
WindowIsTranslucent: !lowMemoryMode,
BackdropType: windowsBackdrop,
DisableWindowIcon: false,
DisableFramelessWindowDecorations: false,
WebviewUserDataPath: resolveWindowsWebviewUserDataPath(),
@@ -56,3 +65,12 @@ func main() {
logger.Error(err, "应用启动失败")
}
}
func isLowMemoryMode() bool {
switch strings.ToLower(strings.TrimSpace(os.Getenv("GONAVI_LOW_MEMORY_MODE"))) {
case "1", "true", "yes", "on":
return true
default:
return false
}
}

26
main_test.go Normal file
View File

@@ -0,0 +1,26 @@
package main
import "testing"
func TestIsLowMemoryMode(t *testing.T) {
tests := []struct {
name string
env string
want bool
}{
{name: "disabled by default", env: "", want: false},
{name: "enabled with one", env: "1", want: true},
{name: "enabled with true", env: "true", want: true},
{name: "enabled with yes and whitespace", env: " yes ", want: true},
{name: "disabled with false", env: "false", want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Setenv("GONAVI_LOW_MEMORY_MODE", tt.env)
if got := isLowMemoryMode(); got != tt.want {
t.Fatalf("isLowMemoryMode() = %v, want %v", got, tt.want)
}
})
}
}