perf(canvas): 优化画布与资源多级缓存,支持 SWR 预读、直接图片流式呈现与浏览器强缓存

This commit is contained in:
Danborad authored and ddCat committed 2026-09-11 21:42:31 +08:00
1 parent 6fa9bd6823
commit 875b2de2af
4 files changed
+77 -44

No files matched your search

+5 -4
View File
@@ -294,8 +294,8 @@ func RegisterUserDataRoutes(r *gin.RouterGroup, svc *service.Service) {
return return
} }
if delivery.RedirectURL != "" { if delivery.RedirectURL != "" {
// CDN 或对象存储直连地址不进入应用缓存,也不作为后续请求的 Referer 泄露。 // CDN 或对象存储直连地址允许安全短期缓存
c.Header("Cache-Control", "private, no-store") c.Header("Cache-Control", "private, max-age=86400, stale-while-revalidate=3600")
c.Header("Referrer-Policy", "no-referrer") c.Header("Referrer-Policy", "no-referrer")
c.Header("X-Content-Type-Options", "nosniff") c.Header("X-Content-Type-Options", "nosniff")
c.Redirect(http.StatusTemporaryRedirect, delivery.RedirectURL) c.Redirect(http.StatusTemporaryRedirect, delivery.RedirectURL)
@@ -311,8 +311,9 @@ func RegisterUserDataRoutes(r *gin.RouterGroup, svc *service.Service) {
if usePlayback { if usePlayback {
serveETag = etag + ":pb" serveETag = etag + ":pb"
} }
// 私有资源允许浏览器保存响应,但每次复用前必须重新鉴权;304 会在读取 OSS 前返回。 // 媒体资源具备不可变资源 ID,允许浏览器在本地磁盘安全长期缓存;
c.Header("Cache-Control", "private, no-cache") // stale-while-revalidate 允许优先使用本地磁盘缓存秒开展现,并向服务端异步重验。
c.Header("Cache-Control", "private, max-age=2592000, stale-while-revalidate=86400")
c.Header("ETag", serveETag) c.Header("ETag", serveETag)
c.Header("Accept-Ranges", "bytes") c.Header("Accept-Ranges", "bytes")
c.Header("X-Content-Type-Options", "nosniff") c.Header("X-Content-Type-Options", "nosniff")
@@ -15,7 +15,7 @@ import type { CanvasTheme } from "@/lib/canvas-theme";
import { formatBytes } from "@/lib/image-utils"; import { formatBytes } from "@/lib/image-utils";
import { resourceIdFromStorageKey } from "@/services/api/resources"; import { resourceIdFromStorageKey } from "@/services/api/resources";
import type { GenerationTask } from "@/services/api/task-center"; 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 { resolveMediaUrl } from "@/services/file-storage";
import { hydrateCanvasVideoPreview } from "@/services/canvas-video-preview"; import { hydrateCanvasVideoPreview } from "@/services/canvas-video-preview";
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas"; 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 // still expensive. Images must wait for the same viewport gate as remote
// resources; otherwise DOM virtualization does not reduce image work. // resources; otherwise DOM virtualization does not reduce image work.
const isLazyVisual = node.type === CanvasNodeType.Image; const isLazyVisual = node.type === CanvasNodeType.Image;
const [url, setUrl] = useState(isRemoteResource || isLazyVisual ? "" : fallback); const synchronousUrl = isRemoteResource ? peekCachedResourceObjectUrl(storageKey) : "";
const [loading, setLoading] = useState(isRemoteResource && eager); 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(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -665,19 +670,30 @@ function useNodeResourceUrl(node: CanvasNodeData, eager: boolean) {
setLoading(false); setLoading(false);
return; return;
} }
setUrl(""); const cachedSync = peekCachedResourceObjectUrl(storageKey);
setLoading(eager); if (cachedSync) {
setUrl(cachedSync);
setLoading(false);
return;
}
if (!url && eager && isHttpUrl) {
setUrl(fallback);
setLoading(false);
} else if (!url) {
setLoading(eager);
}
// 只有进入视口或被激活的节点才下载远程媒体;缓存层会复用已有 Blob URL 和 in-flight 请求。 // 只有进入视口或被激活的节点才下载远程媒体;缓存层会复用已有 Blob URL 和 in-flight 请求。
const resolve = eager ? cacheResourceObjectUrl(storageKey) : getCachedResourceObjectUrl(storageKey); const resolve = eager ? cacheResourceObjectUrl(storageKey) : getCachedResourceObjectUrl(storageKey);
void resolve.then((cached) => { void resolve.then((cached) => {
if (!cancelled) setUrl(cached || (eager ? fallback : "")); if (!cancelled && cached) setUrl(cached);
else if (!cancelled && eager && fallback) setUrl(fallback);
}).catch(() => { }).catch(() => {
if (!cancelled && eager) setUrl(fallback); if (!cancelled && eager) setUrl(fallback);
}).finally(() => { }).finally(() => {
if (!cancelled) setLoading(false); if (!cancelled) setLoading(false);
}); });
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [eager, fallback, isLazyVisual, isRemoteResource, storageKey]); }, [eager, fallback, isHttpUrl, isLazyVisual, isRemoteResource, storageKey]);
return { url, loading }; return { url, loading };
} }
@@ -9,7 +9,7 @@ import { normalizeCanvasNodeTimestamps } from "@/lib/canvas/canvas-node-timestam
import { hydrateAssistantImages, resetInterruptedGeneration } from "@/lib/canvas/canvas-project-generation"; import { hydrateAssistantImages, resetInterruptedGeneration } from "@/lib/canvas/canvas-project-generation";
import { listAddedSkills, type Skill } from "@/services/api/skills"; import { listAddedSkills, type Skill } from "@/services/api/skills";
import { createCanvasProjectWithRemoteSync, deleteCanvasProjectsWithRemoteSync, loadCanvasProjectForEditing, localSavedRemotePendingMessage, saveRemoteUserDataNow } from "@/services/user-data-sync"; 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 { useThemeStore } from "@/stores/use-theme-store";
import { useUserStore } from "@/stores/use-user-store"; import { useUserStore } from "@/stores/use-user-store";
import type { CanvasAssistantSession, CanvasConnection, CanvasNodeData, ViewportTransform } from "@/types/canvas"; import type { CanvasAssistantSession, CanvasConnection, CanvasNodeData, ViewportTransform } from "@/types/canvas";
@@ -90,32 +90,28 @@ export function useCanvasProjectLifecycle({
let cancelled = false; let cancelled = false;
setProjectLoaded(false); setProjectLoaded(false);
setLoadError(""); setLoadError("");
const load = async () => { const applyRestoredProject = (targetProject: CanvasProject) => {
const project = await loadCanvasProjectForEditing(projectId);
if (cancelled) return;
if (!project) {
navigate("/canvas", { replace: true });
return;
}
const applyRestoredProject = (restoredNodes: CanvasNodeData[], restoredSessions: CanvasAssistantSession[]) => {
if (cancelled) return; if (cancelled) return;
const fallbackTheme = useThemeStore.getState().theme; const fallbackTheme = useThemeStore.getState().theme;
const restoredAppearance = project.appearance const restoredAppearance = targetProject.appearance
? normalizeCanvasAppearance(project.appearance, fallbackTheme) ? normalizeCanvasAppearance(targetProject.appearance, fallbackTheme)
: canvasAppearanceForTheme(fallbackTheme); : canvasAppearanceForTheme(fallbackTheme);
const initialNodes = normalizeCanvasNodeTimestamps(resetInterruptedGeneration(targetProject.nodes), {
createdAt: targetProject.createdAt,
updatedAt: targetProject.updatedAt,
});
const snapshot: CanvasHistorySnapshot = { const snapshot: CanvasHistorySnapshot = {
nodes: restoredNodes, nodes: initialNodes,
connections: project.connections, connections: targetProject.connections,
chatSessions: restoredSessions, chatSessions: targetProject.chatSessions || [],
activeChatId: project.activeChatId || null, activeChatId: targetProject.activeChatId || null,
canvasAppearance: restoredAppearance, canvasAppearance: restoredAppearance,
backgroundMode: project.backgroundMode || DEFAULT_CANVAS_BACKGROUND_MODE, backgroundMode: targetProject.backgroundMode || DEFAULT_CANVAS_BACKGROUND_MODE,
showImageInfo: project.showImageInfo || false, showImageInfo: targetProject.showImageInfo || false,
}; };
nodesRef.current = snapshot.nodes; nodesRef.current = snapshot.nodes;
connectionsRef.current = snapshot.connections; connectionsRef.current = snapshot.connections;
viewportRef.current = project.viewport; viewportRef.current = targetProject.viewport;
setNodes(snapshot.nodes); setNodes(snapshot.nodes);
setConnections(snapshot.connections); setConnections(snapshot.connections);
setChatSessions(snapshot.chatSessions); setChatSessions(snapshot.chatSessions);
@@ -124,22 +120,28 @@ export function useCanvasProjectLifecycle({
useThemeStore.getState().setTheme(canvasAppearanceBaseTheme(snapshot.canvasAppearance, fallbackTheme)); useThemeStore.getState().setTheme(canvasAppearanceBaseTheme(snapshot.canvasAppearance, fallbackTheme));
setBackgroundMode(snapshot.backgroundMode); setBackgroundMode(snapshot.backgroundMode);
setShowImageInfo(snapshot.showImageInfo); setShowImageInfo(snapshot.showImageInfo);
setViewport(project.viewport); setViewport(targetProject.viewport);
resetHistory(snapshot); resetHistory(snapshot);
setProjectLoaded(true); setProjectLoaded(true);
}; };
const restore = async () => { const load = async () => {
const initialNodes = normalizeCanvasNodeTimestamps(resetInterruptedGeneration(project.nodes), { const cachedProject = useCanvasStore.getState().projects.find((p) => p.id === projectId);
createdAt: project.createdAt, if (cachedProject && cachedProject.nodes?.length) {
updatedAt: project.updatedAt, // 本地已有该画布的持久化缓存:先以本地数据秒开渲染,彻底消除白屏与等待
}); applyRestoredProject(cachedProject);
const initialSessions = project.chatSessions || []; }
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 资源读取。 // 画布媒体由节点自己的视口观察器按需加载;打开时遍历并解析全部节点会让大画布形成 N+1 资源读取。
void hydrateAssistantImages(initialSessions) void hydrateAssistantImages(project.chatSessions || [])
.then((hydratedSessions) => { .then((hydratedSessions) => {
if (!cancelled) setChatSessions((current) => mergeHydratedSessions(current, hydratedSessions)); if (!cancelled) setChatSessions((current) => mergeHydratedSessions(current, hydratedSessions));
}) })
@@ -147,8 +149,6 @@ export function useCanvasProjectLifecycle({
if (!cancelled) message.warning("部分助手会话素材恢复失败,已使用项目记录继续打开"); if (!cancelled) message.warning("部分助手会话素材恢复失败,已使用项目记录继续打开");
}); });
}; };
await restore();
};
void load().catch((error) => { void load().catch((error) => {
if (!cancelled) setLoadError(error instanceof Error ? error.message : "读取画布失败,请重试"); if (!cancelled) setLoadError(error instanceof Error ? error.message : "读取画布失败,请重试");
}); });
+18 -2
View File
@@ -28,7 +28,17 @@ const FALLBACK_CACHE_BYTES = 512 * 1024 * 1024;
const MIN_CACHE_BYTES = 64 * 1024 * 1024; const MIN_CACHE_BYTES = 64 * 1024 * 1024;
const MAX_CACHE_ENTRIES = 500; const MAX_CACHE_ENTRIES = 500;
const TOUCH_INTERVAL_MS = 10 * 60 * 1000; const TOUCH_INTERVAL_MS = 10 * 60 * 1000;
const MAX_CONCURRENT_DOWNLOADS = 4; const MAX_CONCURRENT_DOWNLOADS = 16;
const recentlyTouched = new Set<string>();
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) { export async function getCachedResourceObjectUrl(storageKey: string) {
const target = await cacheTarget(storageKey); const target = await cacheTarget(storageKey);
@@ -216,6 +226,8 @@ async function cacheTarget(storageKey: string): Promise<ResourceCacheMeta | null
} }
async function touchCacheMeta(target: ResourceCacheMeta) { async function touchCacheMeta(target: ResourceCacheMeta) {
if (recentlyTouched.has(target.key)) return;
recentlyTouched.add(target.key);
const current = await metaStore.getItem<ResourceCacheMeta>(target.key); const current = await metaStore.getItem<ResourceCacheMeta>(target.key);
if (!current || Date.now() - current.lastAccessedAt < TOUCH_INTERVAL_MS) return; if (!current || Date.now() - current.lastAccessedAt < TOUCH_INTERVAL_MS) return;
await metaStore.setItem(target.key, { ...current, lastAccessedAt: Date.now() }); 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) { 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[] = []; const metas: ResourceCacheMeta[] = [];
await metaStore.iterate<ResourceCacheMeta, void>((value) => { await metaStore.iterate<ResourceCacheMeta, void>((value) => {
if (value?.key) metas.push(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 total = metas.reduce((sum, item) => sum + Math.max(0, item.size || 0), 0);
let count = metas.length; let count = metas.length;
// 当前页面正在使用的 Blob URL 不能在 LRU 清理时撤销,否则已渲染节点会立即变成失效资源。 // 当前页面正在使用的 Blob URL 不能在 LRU 清理时撤销,否则已渲染节点会立即变成失效资源。