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 (