perf(canvas): 画布渲染 - 降低大画布节点开销

This commit is contained in:
ddCat
2026-09-24 00:05:32 +08:00
parent 8822cb137c
commit 8a35a0ea98
17 changed files with 734 additions and 352 deletions

View File

@@ -15,8 +15,30 @@ export function sameCanvasContent(left: CanvasProject | undefined, right: Canvas
return [...new Set([...Object.keys(a), ...Object.keys(b)])].every((key) => a[key] === b[key] || canonicalize(a[key]) === canonicalize(b[key]));
}
export async function canvasContentHash(project: CanvasProject) {
const serialized = canonicalize(canvasContentSnapshot(project));
type ContentSnapshot = ReturnType<typeof canvasContentSnapshot>;
const contentHashes = new WeakMap<CanvasProject["nodes"], { content: ContentSnapshot; hash: Promise<string> }>();
// Store updates are immutable. Reuse a hash across viewport/revision-only copies;
// any content field/reference change invalidates it. Weak keys bound its lifetime.
export function canvasContentHash(project: CanvasProject): Promise<string> {
const content = canvasContentSnapshot(project);
const cached = contentHashes.get(project.nodes);
if (cached && sameContentReferences(cached.content, content)) return cached.hash;
const hash = hashContent(content);
contentHashes.set(project.nodes, { content, hash });
void hash.catch(() => {
if (contentHashes.get(project.nodes)?.hash === hash) contentHashes.delete(project.nodes);
});
return hash;
}
function sameContentReferences(a: ContentSnapshot, b: ContentSnapshot) {
const keys = Object.keys(a) as (keyof ContentSnapshot)[];
return keys.length === Object.keys(b).length && keys.every((key) => a[key] === b[key]);
}
async function hashContent(content: ContentSnapshot) {
const serialized = canonicalize(content);
// LAN HTTP deployments may lack Web Crypto. Keep an exact baseline there;
// a lossy checksum could incorrectly discard an unsaved draft during login.
if (!globalThis.crypto?.subtle) return `json:${serialized}`;

View File

@@ -0,0 +1,12 @@
export type CanvasNodeRenderLOD = "shell" | "preview" | "full";
// Hysteresis prevents mounting/unmounting editors around the overview boundary.
export function canvasOverviewMode(scale: number, previous: boolean) {
return previous ? scale < 0.27 : scale < 0.23;
}
export function resolveCanvasNodeLOD(overview: boolean, inView: boolean, inEntry: boolean, editing: boolean): CanvasNodeRenderLOD {
if (editing) return "full";
if (inView) return overview ? "preview" : "full";
return inEntry ? "preview" : "shell";
}

View File

@@ -1,7 +1,7 @@
import { canvasNodeBounds, type CanvasSpatialBounds, type CanvasSpatialIndex } from "@/lib/canvas/canvas-spatial-index";
import type { CanvasNodeData } from "@/types/canvas";
const intersects = (a: CanvasSpatialBounds, b: CanvasSpatialBounds) => a.right > b.left && a.left < b.right && a.bottom > b.top && a.top < b.bottom;
export const intersectsCanvasBounds = (a: CanvasSpatialBounds, b: CanvasSpatialBounds) => a.right > b.left && a.left < b.right && a.bottom > b.top && a.top < b.bottom;
/** 预算只能裁减屏外预加载,不能裁掉屏内节点或正在交互的节点。 */
export function selectCanvasVisibleNodes({
@@ -25,8 +25,8 @@ export function selectCanvasVisibleNodes({
const node = nodeById.get(id);
if (!node || hiddenIds.has(id)) continue;
const bounds = canvasNodeBounds(node);
if (forcedIds.has(id) || intersects(bounds, view)) visible.push(node);
else if (retainedIds.has(id) || intersects(bounds, enter)) overscan.push(node);
if (forcedIds.has(id) || intersectsCanvasBounds(bounds, view)) visible.push(node);
else if (retainedIds.has(id) || intersectsCanvasBounds(bounds, enter)) overscan.push(node);
}
return [...visible, ...overscan.slice(0, Math.max(0, budget - visible.length))];
}

View File

@@ -0,0 +1,37 @@
// One clock per canvas runtime, not one timer per loading node. No work while
// hidden or when the final loading node unmounts.
const listeners = new Set<() => void>();
let now = Date.now();
let timer: ReturnType<typeof setInterval> | undefined;
export const taskTickerSnapshot = () => now;
export const idleTaskTickerSubscribe = () => () => {};
function tick() {
now = Date.now();
for (const listener of listeners) listener();
}
function reconcileTimer() {
if (timer !== undefined) clearInterval(timer);
timer = undefined;
if (listeners.size && (typeof document === "undefined" || !document.hidden)) {
tick();
timer = setInterval(tick, 1000);
}
}
export function subscribeTaskTicker(listener: () => void) {
listeners.add(listener);
if (listeners.size === 1) {
if (typeof document !== "undefined") document.addEventListener("visibilitychange", reconcileTimer);
reconcileTimer();
}
return () => {
listeners.delete(listener);
if (!listeners.size) {
if (typeof document !== "undefined") document.removeEventListener("visibilitychange", reconcileTimer);
reconcileTimer();
}
};
}