diff --git a/app/api/render/route.ts b/app/api/render/route.ts
index 82efc3a..0d9b4c7 100644
--- a/app/api/render/route.ts
+++ b/app/api/render/route.ts
@@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from 'next/server';
+import { createHash } from 'crypto';
import { getKrokiType, isEngine, isFormat } from '@/lib/diagramConfig';
import { logger } from '@/lib/logger';
import { APP_CONFIG } from '@/lib/config';
@@ -200,6 +201,14 @@ export async function POST(req: NextRequest) {
const k = renderCache.createKey(base, engine, format, code);
const cached = renderCache.get(k);
if (cached && !renderCache.isExpired(cached, now)) {
+ // Add caching headers for client-side cache reuse
+ const cacheControl = 'public, max-age=3600';
+ const etagHash = createHash('sha256')
+ .update(cached.svg || cached.base64 || '')
+ .digest('hex')
+ .substring(0, 16);
+ const etag = `"${etagHash}"`;
+
if (binary) {
if (format === 'svg' && cached.svg) {
return new NextResponse(toArrayBuffer(Buffer.from(cached.svg)), {
@@ -207,6 +216,8 @@ export async function POST(req: NextRequest) {
headers: {
'Content-Type': cached.contentType,
'Content-Disposition': `attachment; filename=diagram.${format}`,
+ 'Cache-Control': cacheControl,
+ ETag: etag,
},
});
}
@@ -217,14 +228,32 @@ export async function POST(req: NextRequest) {
headers: {
'Content-Type': cached.contentType,
'Content-Disposition': `attachment; filename=diagram.${format}`,
+ 'Cache-Control': cacheControl,
+ ETag: etag,
},
});
}
} else {
if (format === 'svg' && cached.svg) {
- return NextResponse.json({ contentType: cached.contentType, svg: cached.svg });
+ return NextResponse.json(
+ { contentType: cached.contentType, svg: cached.svg },
+ {
+ headers: {
+ 'Cache-Control': cacheControl,
+ ETag: etag,
+ },
+ },
+ );
} else if (cached.base64) {
- return NextResponse.json({ contentType: cached.contentType, base64: cached.base64 });
+ return NextResponse.json(
+ { contentType: cached.contentType, base64: cached.base64 },
+ {
+ headers: {
+ 'Cache-Control': cacheControl,
+ ETag: etag,
+ },
+ },
+ );
}
}
}
diff --git a/app/editor/page.tsx b/app/editor/page.tsx
index fc1bb9c..e6ffe41 100644
--- a/app/editor/page.tsx
+++ b/app/editor/page.tsx
@@ -15,7 +15,7 @@ import { AppHeader } from '@/components/layout/AppHeader';
import { DiagramList } from '@/components/sidebar/DiagramList';
import { CollapsedSidebar } from '@/components/layout/CollapsedSidebar';
import { ErrorBoundary } from '@/components/layout/ErrorBoundary';
-import { SidebarTabs, type SidebarTab } from '@/components/sidebar/SidebarTabs';
+import { MemoizedSidebarTabs as SidebarTabs, type SidebarTab } from '@/components/sidebar/SidebarTabs';
import { ConfirmDialog, PromptDialog, AlertDialog } from '@/components/dialogs/Dialogs';
import { StaticExportNotice } from '@/components/landing/StaticExportNotice';
import { useSettings } from '@/hooks/useSettings';
diff --git a/components/editor/EditorPanel.tsx b/components/editor/EditorPanel.tsx
index 67e2ad7..c98cc39 100644
--- a/components/editor/EditorPanel.tsx
+++ b/components/editor/EditorPanel.tsx
@@ -1,6 +1,6 @@
'use client';
-import { memo } from 'react';
+import { memo, useMemo } from 'react';
import type { Engine, Format } from '@/lib/diagramConfig';
import {
ENGINE_LABELS,
@@ -63,18 +63,21 @@ function EditorPanelComponent(props: EditorPanelProps) {
const currentEngineConfig = ENGINE_CONFIGS[engine];
- // 根据 limitEngines 过滤引擎类别
- const filteredCategories = limitEngines
- ? Object.entries(ENGINE_CATEGORIES)
- .map(([category, engines]) => ({
- category,
- engines: engines.filter((eng) => limitEngines.includes(eng)),
- }))
- .filter(({ engines }) => engines.length > 0)
- : Object.entries(ENGINE_CATEGORIES).map(([category, engines]) => ({
+ // Memoize filteredCategories to avoid recalculating on every render
+ const filteredCategories = useMemo(() => {
+ if (!limitEngines) {
+ return Object.entries(ENGINE_CATEGORIES).map(([category, engines]) => ({
category,
engines,
}));
+ }
+ return Object.entries(ENGINE_CATEGORIES)
+ .map(([category, engines]) => ({
+ category,
+ engines: engines.filter((eng) => limitEngines.includes(eng)),
+ }))
+ .filter(({ engines }) => engines.length > 0);
+ }, [limitEngines]);
return (
diff --git a/components/preview/PreviewPanel.tsx b/components/preview/PreviewPanel.tsx
index d06f7b7..f3561e8 100644
--- a/components/preview/PreviewPanel.tsx
+++ b/components/preview/PreviewPanel.tsx
@@ -6,6 +6,7 @@ import {
useMemo,
useRef,
useState,
+ memo,
type MouseEvent,
type WheelEvent,
} from 'react';
@@ -29,7 +30,7 @@ export type PreviewPanelProps = {
onExportError?: (message: string) => void;
};
-export function PreviewPanel(props: PreviewPanelProps) {
+function PreviewPanelComponent(props: PreviewPanelProps) {
const {
svg,
base64,
@@ -317,3 +318,4 @@ export function PreviewPanel(props: PreviewPanelProps) {
);
}
+export const PreviewPanel = memo(PreviewPanelComponent);
diff --git a/components/sidebar/SidebarTabs.tsx b/components/sidebar/SidebarTabs.tsx
index 4de479e..d3fb7a7 100644
--- a/components/sidebar/SidebarTabs.tsx
+++ b/components/sidebar/SidebarTabs.tsx
@@ -1,5 +1,6 @@
'use client';
+import { memo, useCallback } from 'react';
import type { Engine } from '@/lib/diagramConfig';
import type { VersionRecord } from '@/hooks/useVersionHistory';
import type { AIConfig, AIAnalysisResult } from '@/hooks/useAIAssistant';
@@ -82,6 +83,90 @@ const TAB_ITEMS: Array<{
},
];
+// ============================================================================
+// Memoized Panel Components - Prevent unnecessary re-renders when props haven't changed
+// ============================================================================
+
+type EditorPanelProps = {
+ onEngineChange: (engine: Engine, loadSample?: boolean) => void;
+ onCopyCode: () => Promise;
+ onClearCode: () => void;
+ onExportSourceCode: () => Promise;
+ onLivePreviewChange: (enabled: boolean) => void;
+ livePreviewEnabled: boolean;
+ limitEngines?: readonly Engine[];
+};
+
+const MemoizedEditorPanel = memo(function EditorPanelComponent(props: EditorPanelProps) {
+ return ;
+});
+
+type AIPanelProps = {
+ config: AIConfig;
+ isConfigured: boolean;
+ isAnalyzing: boolean;
+ isGenerating: boolean;
+ lastAnalysis: AIAnalysisResult | null;
+ error: string | null;
+ boundaryNotice: string;
+ onUpdateConfig: (config: Partial) => void;
+ onAnalyze: () => void;
+ onFix: () => void;
+ onGenerate: (description: string) => void;
+ onApplyCode: (code: string) => void;
+ onClearError: () => void;
+ onClearAnalysis: () => void;
+};
+
+const MemoizedAIPanel = memo(function AIPanelComponent(props: AIPanelProps) {
+ return (
+
+ );
+});
+
+type HistoryPanelProps = {
+ versions: VersionRecord[];
+ isLoading: boolean;
+ onRestore: (version: VersionRecord) => void;
+ onDelete: (versionId: string) => void;
+ onRename: (versionId: string, newLabel: string) => void;
+ onCreateSnapshot: () => void;
+ onClearAll: () => void;
+};
+
+const MemoizedHistoryPanel = memo(function HistoryPanelComponent(props: HistoryPanelProps) {
+ return (
+
+
+
+ );
+});
+
export function SidebarTabs(props: SidebarTabsProps) {
const {
activeTab,
@@ -119,6 +204,11 @@ export function SidebarTabs(props: SidebarTabsProps) {
onClearAllVersions,
} = props;
+ // Memoize tab change callback to prevent unnecessary re-renders
+ const handleTabChange = useCallback((tab: SidebarTab) => {
+ onTabChange(tab);
+ }, [onTabChange]);
+
return (
<>
{/* Tab 切换栏 */}
@@ -129,7 +219,7 @@ export function SidebarTabs(props: SidebarTabsProps) {
return (