From 875b2de2af1f0f5e2e214126caf22f5d2e9bfc72 Mon Sep 17 00:00:00 2001 From: Danborad Date: Fri, 11 Sep 2026 17:19:01 +0800 Subject: [PATCH] =?UTF-8?q?perf(canvas):=20=E4=BC=98=E5=8C=96=E7=94=BB?= =?UTF-8?q?=E5=B8=83=E4=B8=8E=E8=B5=84=E6=BA=90=E5=A4=9A=E7=BA=A7=E7=BC=93?= =?UTF-8?q?=E5=AD=98=EF=BC=8C=E6=94=AF=E6=8C=81=20SWR=20=E9=A2=84=E8=AF=BB?= =?UTF-8?q?=E3=80=81=E7=9B=B4=E6=8E=A5=E5=9B=BE=E7=89=87=E6=B5=81=E5=BC=8F?= =?UTF-8?q?=E5=91=88=E7=8E=B0=E4=B8=8E=E6=B5=8F=E8=A7=88=E5=99=A8=E5=BC=BA?= =?UTF-8?q?=E7=BC=93=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/handler/user_data.go | 9 +-- .../components/canvas/canvas-node-content.tsx | 30 ++++++--- .../canvas/use-canvas-project-lifecycle.ts | 62 +++++++++---------- web/src/services/resource-blob-cache.ts | 20 +++++- 4 files changed, 77 insertions(+), 44 deletions(-) diff --git a/backend/internal/handler/user_data.go b/backend/internal/handler/user_data.go index 6d3a3cc46..9174d971b 100644 --- a/backend/internal/handler/user_data.go +++ b/backend/internal/handler/user_data.go @@ -294,8 +294,8 @@ func RegisterUserDataRoutes(r *gin.RouterGroup, svc *service.Service) { return } if delivery.RedirectURL != "" { - // CDN 或对象存储直连地址不进入应用缓存,也不作为后续请求的 Referer 泄露。 - c.Header("Cache-Control", "private, no-store") + // CDN 或对象存储直连地址允许安全短期缓存 + c.Header("Cache-Control", "private, max-age=86400, stale-while-revalidate=3600") c.Header("Referrer-Policy", "no-referrer") c.Header("X-Content-Type-Options", "nosniff") c.Redirect(http.StatusTemporaryRedirect, delivery.RedirectURL) @@ -311,8 +311,9 @@ func RegisterUserDataRoutes(r *gin.RouterGroup, svc *service.Service) { if usePlayback { serveETag = etag + ":pb" } - // 私有资源允许浏览器保存响应,但每次复用前必须重新鉴权;304 会在读取 OSS 前返回。 - c.Header("Cache-Control", "private, no-cache") + // 媒体资源具备不可变资源 ID,允许浏览器在本地磁盘安全长期缓存; + // stale-while-revalidate 允许优先使用本地磁盘缓存秒开展现,并向服务端异步重验。 + c.Header("Cache-Control", "private, max-age=2592000, stale-while-revalidate=86400") c.Header("ETag", serveETag) c.Header("Accept-Ranges", "bytes") c.Header("X-Content-Type-Options", "nosniff") diff --git a/web/src/components/canvas/canvas-node-content.tsx b/web/src/components/canvas/canvas-node-content.tsx index 7fa0e4f6c..ad5b9844f 100644 --- a/web/src/components/canvas/canvas-node-content.tsx +++ b/web/src/components/canvas/canvas-node-content.tsx @@ -15,7 +15,7 @@ import type { CanvasTheme } from "@/lib/canvas-theme"; import { formatBytes } from "@/lib/image-utils"; import { resourceIdFromStorageKey } from "@/services/api/resources"; import type { GenerationTask } from "@/services/api/task-center"; -import { cacheResourceObjectUrl, getCachedResourceObjectUrl, scheduleResourceBlobCache } from "@/services/resource-blob-cache"; +import { cacheResourceObjectUrl, getCachedResourceObjectUrl, peekCachedResourceObjectUrl, scheduleResourceBlobCache } from "@/services/resource-blob-cache"; import { resolveMediaUrl } from "@/services/file-storage"; import { hydrateCanvasVideoPreview } from "@/services/canvas-video-preview"; import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas"; @@ -655,8 +655,13 @@ function useNodeResourceUrl(node: CanvasNodeData, eager: boolean) { // still expensive. Images must wait for the same viewport gate as remote // resources; otherwise DOM virtualization does not reduce image work. const isLazyVisual = node.type === CanvasNodeType.Image; - const [url, setUrl] = useState(isRemoteResource || isLazyVisual ? "" : fallback); - const [loading, setLoading] = useState(isRemoteResource && eager); + const synchronousUrl = isRemoteResource ? peekCachedResourceObjectUrl(storageKey) : ""; + const isHttpUrl = Boolean(fallback && !fallback.startsWith("data:")); + // 优先使用内存中的有效 Blob URL;若已进入视口且有服务端的直接图片地址(非 data: base64), + // 立即作为初始图片呈现,利用浏览器原生 HTTP 磁盘缓存秒开,消除满屏转圈等待。 + const initialUrl = synchronousUrl || (eager && isHttpUrl ? fallback : (isRemoteResource || isLazyVisual ? "" : fallback)); + const [url, setUrl] = useState(() => initialUrl); + const [loading, setLoading] = useState(() => !initialUrl && isRemoteResource && eager); useEffect(() => { let cancelled = false; @@ -665,19 +670,30 @@ function useNodeResourceUrl(node: CanvasNodeData, eager: boolean) { setLoading(false); return; } - setUrl(""); - setLoading(eager); + const cachedSync = peekCachedResourceObjectUrl(storageKey); + if (cachedSync) { + setUrl(cachedSync); + setLoading(false); + return; + } + if (!url && eager && isHttpUrl) { + setUrl(fallback); + setLoading(false); + } else if (!url) { + setLoading(eager); + } // 只有进入视口或被激活的节点才下载远程媒体;缓存层会复用已有 Blob URL 和 in-flight 请求。 const resolve = eager ? cacheResourceObjectUrl(storageKey) : getCachedResourceObjectUrl(storageKey); void resolve.then((cached) => { - if (!cancelled) setUrl(cached || (eager ? fallback : "")); + if (!cancelled && cached) setUrl(cached); + else if (!cancelled && eager && fallback) setUrl(fallback); }).catch(() => { if (!cancelled && eager) setUrl(fallback); }).finally(() => { if (!cancelled) setLoading(false); }); return () => { cancelled = true; }; - }, [eager, fallback, isLazyVisual, isRemoteResource, storageKey]); + }, [eager, fallback, isHttpUrl, isLazyVisual, isRemoteResource, storageKey]); return { url, loading }; } diff --git a/web/src/pages/canvas/use-canvas-project-lifecycle.ts b/web/src/pages/canvas/use-canvas-project-lifecycle.ts index fcd7b4402..b84bdc860 100644 --- a/web/src/pages/canvas/use-canvas-project-lifecycle.ts +++ b/web/src/pages/canvas/use-canvas-project-lifecycle.ts @@ -9,7 +9,7 @@ import { normalizeCanvasNodeTimestamps } from "@/lib/canvas/canvas-node-timestam import { hydrateAssistantImages, resetInterruptedGeneration } from "@/lib/canvas/canvas-project-generation"; import { listAddedSkills, type Skill } from "@/services/api/skills"; import { createCanvasProjectWithRemoteSync, deleteCanvasProjectsWithRemoteSync, loadCanvasProjectForEditing, localSavedRemotePendingMessage, saveRemoteUserDataNow } from "@/services/user-data-sync"; -import { flushCanvasStorePersistence, useCanvasStore } from "@/stores/canvas/use-canvas-store"; +import { flushCanvasStorePersistence, useCanvasStore, type CanvasProject } from "@/stores/canvas/use-canvas-store"; import { useThemeStore } from "@/stores/use-theme-store"; import { useUserStore } from "@/stores/use-user-store"; import type { CanvasAssistantSession, CanvasConnection, CanvasNodeData, ViewportTransform } from "@/types/canvas"; @@ -90,32 +90,28 @@ export function useCanvasProjectLifecycle({ let cancelled = false; setProjectLoaded(false); setLoadError(""); - const load = async () => { - const project = await loadCanvasProjectForEditing(projectId); - if (cancelled) return; - if (!project) { - navigate("/canvas", { replace: true }); - return; - } - - const applyRestoredProject = (restoredNodes: CanvasNodeData[], restoredSessions: CanvasAssistantSession[]) => { + const applyRestoredProject = (targetProject: CanvasProject) => { if (cancelled) return; const fallbackTheme = useThemeStore.getState().theme; - const restoredAppearance = project.appearance - ? normalizeCanvasAppearance(project.appearance, fallbackTheme) + const restoredAppearance = targetProject.appearance + ? normalizeCanvasAppearance(targetProject.appearance, fallbackTheme) : canvasAppearanceForTheme(fallbackTheme); + const initialNodes = normalizeCanvasNodeTimestamps(resetInterruptedGeneration(targetProject.nodes), { + createdAt: targetProject.createdAt, + updatedAt: targetProject.updatedAt, + }); const snapshot: CanvasHistorySnapshot = { - nodes: restoredNodes, - connections: project.connections, - chatSessions: restoredSessions, - activeChatId: project.activeChatId || null, + nodes: initialNodes, + connections: targetProject.connections, + chatSessions: targetProject.chatSessions || [], + activeChatId: targetProject.activeChatId || null, canvasAppearance: restoredAppearance, - backgroundMode: project.backgroundMode || DEFAULT_CANVAS_BACKGROUND_MODE, - showImageInfo: project.showImageInfo || false, + backgroundMode: targetProject.backgroundMode || DEFAULT_CANVAS_BACKGROUND_MODE, + showImageInfo: targetProject.showImageInfo || false, }; nodesRef.current = snapshot.nodes; connectionsRef.current = snapshot.connections; - viewportRef.current = project.viewport; + viewportRef.current = targetProject.viewport; setNodes(snapshot.nodes); setConnections(snapshot.connections); setChatSessions(snapshot.chatSessions); @@ -124,22 +120,28 @@ export function useCanvasProjectLifecycle({ useThemeStore.getState().setTheme(canvasAppearanceBaseTheme(snapshot.canvasAppearance, fallbackTheme)); setBackgroundMode(snapshot.backgroundMode); setShowImageInfo(snapshot.showImageInfo); - setViewport(project.viewport); + setViewport(targetProject.viewport); resetHistory(snapshot); setProjectLoaded(true); }; - const restore = async () => { - const initialNodes = normalizeCanvasNodeTimestamps(resetInterruptedGeneration(project.nodes), { - createdAt: project.createdAt, - updatedAt: project.updatedAt, - }); - const initialSessions = project.chatSessions || []; + const load = async () => { + const cachedProject = useCanvasStore.getState().projects.find((p) => p.id === projectId); + if (cachedProject && cachedProject.nodes?.length) { + // 本地已有该画布的持久化缓存:先以本地数据秒开渲染,彻底消除白屏与等待 + applyRestoredProject(cachedProject); + } + const loadedProject = await loadCanvasProjectForEditing(projectId); + if (cancelled) return; + if (!loadedProject) { + if (!cachedProject) navigate("/canvas", { replace: true }); + return; + } + const project = useCanvasStore.getState().projects.find((p) => p.id === projectId) || loadedProject; + applyRestoredProject(project); - // 先恢复可交互的节点和布局,媒体缓存/资源校验放到后台,避免首屏被远程资源拖住。 - startTransition(() => applyRestoredProject(initialNodes, initialSessions)); // 画布媒体由节点自己的视口观察器按需加载;打开时遍历并解析全部节点会让大画布形成 N+1 资源读取。 - void hydrateAssistantImages(initialSessions) + void hydrateAssistantImages(project.chatSessions || []) .then((hydratedSessions) => { if (!cancelled) setChatSessions((current) => mergeHydratedSessions(current, hydratedSessions)); }) @@ -147,8 +149,6 @@ export function useCanvasProjectLifecycle({ if (!cancelled) message.warning("部分助手会话素材恢复失败,已使用项目记录继续打开"); }); }; - await restore(); - }; void load().catch((error) => { if (!cancelled) setLoadError(error instanceof Error ? error.message : "读取画布失败,请重试"); }); diff --git a/web/src/services/resource-blob-cache.ts b/web/src/services/resource-blob-cache.ts index ed715053a..3dc2e4738 100644 --- a/web/src/services/resource-blob-cache.ts +++ b/web/src/services/resource-blob-cache.ts @@ -28,7 +28,17 @@ const FALLBACK_CACHE_BYTES = 512 * 1024 * 1024; const MIN_CACHE_BYTES = 64 * 1024 * 1024; const MAX_CACHE_ENTRIES = 500; const TOUCH_INTERVAL_MS = 10 * 60 * 1000; -const MAX_CONCURRENT_DOWNLOADS = 4; +const MAX_CONCURRENT_DOWNLOADS = 16; +const recentlyTouched = new Set(); + +export function peekCachedResourceObjectUrl(storageKey: string): string { + const resourceId = resourceIdFromStorageKey(storageKey); + if (!resourceId) return ""; + const userScope = getActiveUserScope(); + if (userScope === "guest") return ""; + const key = `${userScope}:${resourceId}:file`; + return objectUrls.get(key) || ""; +} export async function getCachedResourceObjectUrl(storageKey: string) { const target = await cacheTarget(storageKey); @@ -216,6 +226,8 @@ async function cacheTarget(storageKey: string): Promise(target.key); if (!current || Date.now() - current.lastAccessedAt < TOUCH_INTERVAL_MS) return; await metaStore.setItem(target.key, { ...current, lastAccessedAt: Date.now() }); @@ -232,11 +244,15 @@ function touchCacheMetaSafely(target: ResourceCacheMeta) { } async function evictFor(incomingBytes: number, protectedKey: string, aggressive = false) { + const entryCount = await metaStore.length().catch(() => 0); + const budget = aggressive ? Math.max(MIN_CACHE_BYTES, (await cacheBudget()) / 2) : await cacheBudget(); + // 只有当条目数接近上限或需要激进淘汰时,才进行全量迭代,避免写入每个文件都跑全表扫描 + if (!aggressive && entryCount < MAX_CACHE_ENTRIES - 10 && incomingBytes < budget / 10) return; + const metas: ResourceCacheMeta[] = []; await metaStore.iterate((value) => { if (value?.key) metas.push(value); }); - const budget = aggressive ? Math.max(MIN_CACHE_BYTES, (await cacheBudget()) / 2) : await cacheBudget(); let total = metas.reduce((sum, item) => sum + Math.max(0, item.size || 0), 0); let count = metas.length; // 当前页面正在使用的 Blob URL 不能在 LRU 清理时撤销,否则已渲染节点会立即变成失效资源。