perf: lazy load canvas 3d renderers

This commit is contained in:
134 2026-07-10 18:33:49 +08:00
parent b50d3eb295
commit 78f14edbfc
2 changed files with 113 additions and 2 deletions

View File

@ -1,18 +1,17 @@
"use client";
import React, { useRef, useState, useCallback, useMemo } from "react";
import dynamic from "next/dynamic";
import { Rnd } from "react-rnd";
import { motion } from "framer-motion";
import { useCanvasStore, canvasSelectors } from "@/store/canvas";
import type { AgentSlotNodeData, CanvasEdge, CanvasNode, Position } from "@/types/canvas";
import { ImageNode } from "./nodes/ImageNode";
import { AgentSlotNode } from "./nodes/AgentSlotNode";
import { Model3DNode } from "./nodes/Model3DNode";
import { TextNode } from "./nodes/TextNode";
import { UiScreenNode } from "./nodes/UiScreenNode";
import { VideoNode } from "./nodes/VideoNode";
import { WorkflowNode } from "./nodes/WorkflowNode";
import { World3DNode } from "./nodes/World3DNode";
import { CanvasToolbarIcon } from "./CanvasToolbarIcon";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
@ -68,6 +67,50 @@ import {
} from "@/lib/canvas/agent-pending-requests";
import { inferOriginalImageUrl } from "@/lib/image-url";
type Model3DNodeModule = typeof import("./nodes/Model3DNode");
type World3DNodeModule = typeof import("./nodes/World3DNode");
let model3DNodeModulePromise: Promise<Model3DNodeModule> | null = null;
let world3DNodeModulePromise: Promise<World3DNodeModule> | null = null;
function loadModel3DNodeModule() {
model3DNodeModulePromise ??= import("./nodes/Model3DNode");
return model3DNodeModulePromise;
}
function loadWorld3DNodeModule() {
world3DNodeModulePromise ??= import("./nodes/World3DNode");
return world3DNodeModulePromise;
}
function preloadCanvas3DNodeRenderers() {
void loadModel3DNodeModule();
void loadWorld3DNodeModule();
}
function Canvas3DNodeLoading() {
return (
<div className="flex h-full w-full items-center justify-center rounded-[12px] border border-dashed border-[#d8dee8] bg-[#f6f7f9] text-[12px] text-[#7a8494] dark:border-white/12 dark:bg-white/5 dark:text-white/45">
正在加载 3D 预览
</div>
);
}
const Model3DNode = dynamic(
() => loadModel3DNodeModule().then((module) => module.Model3DNode),
{
loading: () => <Canvas3DNodeLoading />,
ssr: false,
}
);
const World3DNode = dynamic(
() => loadWorld3DNodeModule().then((module) => module.World3DNode),
{
loading: () => <Canvas3DNodeLoading />,
ssr: false,
}
);
const CANVAS_NO_DRAG_SELECTOR =
'[data-canvas-no-drag="true"], [data-canvas-no-drag="true"] *';
const CANVAS_DRAG_CANCEL_SELECTOR = [
@ -352,6 +395,10 @@ export function CanvasWorkspace({
const tool = useCanvasStore(canvasSelectors.tool);
const lastAddedNodeId = useCanvasStore((state) => state.lastAddedNodeId);
const setViewport = useCanvasStore((state) => state.setViewport);
const has3DNodes = useMemo(
() => nodes.some((node) => node.type === "model3d" || node.type === "world3d"),
[nodes]
);
const viewportRef = useRef(viewport);
const pendingViewportRef = useRef<typeof viewport | null>(null);
const pendingViewportRafRef = useRef<number | null>(null);
@ -370,6 +417,49 @@ export function CanvasWorkspace({
viewportRef.current = viewport;
}, [viewport]);
React.useEffect(() => {
if (has3DNodes) {
preloadCanvas3DNodeRenderers();
return;
}
if (typeof window === "undefined") return;
const connection = (
navigator as Navigator & {
connection?: { effectiveType?: string; saveData?: boolean };
}
).connection;
if (connection?.saveData || /(^|-)2g$/i.test(connection?.effectiveType || "")) {
return;
}
const idleWindow = window as typeof window & {
cancelIdleCallback?: (handle: number) => void;
requestIdleCallback?: (
callback: IdleRequestCallback,
options?: IdleRequestOptions
) => number;
};
let idleId: number | null = null;
const warmupTimer = window.setTimeout(() => {
if (document.visibilityState !== "visible") return;
if (typeof idleWindow.requestIdleCallback === "function") {
idleId = idleWindow.requestIdleCallback(preloadCanvas3DNodeRenderers, {
timeout: 15000,
});
return;
}
preloadCanvas3DNodeRenderers();
}, 12000);
return () => {
window.clearTimeout(warmupTimer);
if (idleId !== null) {
idleWindow.cancelIdleCallback?.(idleId);
}
};
}, [has3DNodes]);
React.useEffect(() => {
return () => {
if (pendingViewportRafRef.current !== null) {

View File

@ -85,4 +85,25 @@ test.describe("home to canvas entry performance", () => {
'canvasChatPanelModulePromise ??= import("./CanvasChatPanel")'
);
});
test("keeps heavy 3d renderers out of the normal canvas workspace chunk", () => {
const workspaceSource = readSource("components/canvas/CanvasWorkspace.tsx");
expect(workspaceSource).not.toContain(
'import { Model3DNode } from "./nodes/Model3DNode"'
);
expect(workspaceSource).not.toContain(
'import { World3DNode } from "./nodes/World3DNode"'
);
expect(workspaceSource).toContain(
'model3DNodeModulePromise ??= import("./nodes/Model3DNode")'
);
expect(workspaceSource).toContain(
'world3DNodeModulePromise ??= import("./nodes/World3DNode")'
);
expect(workspaceSource).toContain("window.setTimeout(() => {");
expect(workspaceSource).toContain('document.visibilityState !== "visible"');
expect(workspaceSource).toContain("timeout: 15000");
expect(workspaceSource).toContain("}, 12000)");
});
});