# 大文档

> fabricjs 性能优化：大文档分批加载，页面不卡顿；可以显示加载进度、取消加载，图片加载失败也会说明原因。

Source: https://fabricjs-document-engine.jscrate.dev/zh/docs/guides/large-documents
Last updated: 2026-10-01

`loadFromJSON` 一次创建所有对象，所以一张包含几千个对象的户型图或长设计稿，会把标签页卡住直到加载完成。做 fabricjs 性能优化时，大文档的加载最值得关注，因为用户什么都看不到，也没法取消。引擎会分批加载、报告进度，并在需要时停下。

```tsx
"use client";

import {
  type FabricDocument,
  isDocumentEngineError,
  type LoadProgress,
  type SerializedFabricObject,
} from "fabricjs-document-engine";
import { useDocumentEngine } from "fabricjs-document-engine/react";
import { CircleStopIcon, FolderOpenIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { useRef, useState } from "react";

import {
  COLORS,
  DemoCanvas,
  DemoFrame,
  Status,
  Toolbar,
  ToolButton,
} from "./demo-ui";
import { PAGE, useFabricCanvas } from "./use-fabric-canvas";

const COUNT = 20_000;

/** A document with thousands of small shapes, like a busy floor plan. */
function bigDocument(): FabricDocument {
  const objects: SerializedFabricObject[] = Array.from(
    { length: COUNT },
    (_, index) => ({
      type: index % 3 === 0 ? "Circle" : "Rect",
      originX: "left",
      originY: "top",
      left: Math.random() * (PAGE.width - 8),
      top: Math.random() * (PAGE.height - 8),
      width: 6,
      height: 6,
      radius: 3,
      fill: COLORS[index % COLORS.length],
      objectCaching: false,
    })
  );
  return {
    schemaVersion: 1,
    id: `floor-plan-${Date.now()}`,
    createdAt: new Date().toISOString(),
    updatedAt: new Date().toISOString(),
    canvas: { width: PAGE.width, height: PAGE.height, background: "#ffffff" },
    objects,
    metadata: {},
  };
}

export default function LargeDocumentDemo() {
  const t = useTranslations("demos");
  const { elementRef, canvas } = useFabricCanvas();
  const engine = useDocumentEngine(canvas);
  const controller = useRef<AbortController | null>(null);
  const [progress, setProgress] = useState<LoadProgress | null>(null);
  const [message, setMessage] = useState("");
  const loading =
    controller.current !== null &&
    progress !== null &&
    progress.stage !== "done";

  async function load() {
    if (!engine) return;
    const current = new AbortController();
    controller.current = current;
    const started = performance.now();
    setMessage("");
    try {
      await engine.loadDocument(bigDocument(), {
        signal: current.signal,
        onProgress: setProgress,
        discardUnsavedChanges: true,
      });
      setMessage(
        t("large.done", {
          count: COUNT,
          ms: Math.round(performance.now() - started),
        })
      );
    } catch (error) {
      setMessage(
        isDocumentEngineError(error) && error.code === "LOAD_ABORTED"
          ? t("large.cancelled")
          : t("large.failed", { message: String(error) })
      );
    } finally {
      controller.current = null;
      setProgress(null);
    }
  }

  const share =
    progress && progress.total > 0 ? progress.done / progress.total : 0;

  return (
    <DemoFrame>
      <DemoCanvas elementRef={elementRef} label={t("canvas")} />

      <div
        role="progressbar"
        aria-label={t("large.progress")}
        aria-valuemin={0}
        aria-valuemax={100}
        aria-valuenow={Math.round(share * 100)}
        className="h-1.5 overflow-hidden rounded-full bg-muted"
      >
        <div
          className="h-full bg-foreground transition-[width]"
          style={{ width: `${share * 100}%` }}
        />
      </div>

      <Toolbar>
        <ToolButton
          icon={FolderOpenIcon}
          label={t("large.load", { count: COUNT.toLocaleString() })}
          primary
          disabled={!engine || loading}
          onClick={() => void load()}
        />
        <ToolButton
          icon={CircleStopIcon}
          label={t("large.cancel")}
          disabled={!loading}
          onClick={() => controller.current?.abort()}
        />
      </Toolbar>

      <Status tone={loading ? "busy" : message ? "ok" : "idle"}>
        {progress
          ? t("large.stage", {
              stage: t(`large.stages.${progress.stage}`),
              done: progress.done,
              total: progress.total,
            })
          : message || t("large.hint")}
      </Status>
    </DemoFrame>
  );
}
```

```tsx
import { Canvas } from "fabric";
import { useLayoutEffect, useRef, useState } from "react";

/**
 * The page every demo draws on. The document keeps this size; CSS scales the
 * canvas down to fit its card, so saved documents and exports never depend on
 * the screen.
 */
export const PAGE = { width: 480, height: 300 };

// Selection handles in the site's style. Fabric does not save these.
const SELECTION = {
  borderColor: "#6f00ff",
  borderScaleFactor: 1.5,
  borderOpacityWhenMoving: 0.5,
  cornerStyle: "circle",
  cornerSize: 11,
  cornerColor: "#ffffff",
  cornerStrokeColor: "#6f00ff",
  transparentCorners: false,
} as const;

export function styleSelection(canvas: Canvas) {
  canvas.set({
    selectionColor: "rgba(111, 0, 255, 0.06)",
    selectionBorderColor: "#6f00ff",
    selectionLineWidth: 1,
  });
  const style = () => canvas.getActiveObject()?.set(SELECTION);
  canvas.on("selection:created", style);
  canvas.on("selection:updated", style);
}

/**
 * Creates a Fabric.js canvas once the `<canvas>` element exists, and disposes
 * it on unmount. The engine is created from the returned `canvas`.
 */
export function useFabricCanvas({ width, height } = PAGE) {
  const elementRef = useRef<HTMLCanvasElement>(null);
  const [canvas, setCanvas] = useState<Canvas | null>(null);

  // A layout effect creates the canvas before the first paint.
  useLayoutEffect(() => {
    const created = new Canvas(elementRef.current!, {
      width,
      height,
      backgroundColor: "#ffffff",
    });
    styleSelection(created);
    setCanvas(created);

    return () => {
      // Dispose after the engine's own cleanup, so its last recovery copy
      // still sees the objects.
      setTimeout(() => created.dispose().catch(() => undefined));
    };
  }, [width, height]);

  return { elementRef, canvas };
}
```

```tsx
import { Circle, type FabricObject, Rect, Triangle } from "fabric";
import type { LucideIcon } from "lucide-react";

import { cn } from "@/lib/utils";

import { PAGE } from "./use-fabric-canvas";

export const COLORS = ["#f2836f", "#5fe6c4", "#7ad9ec", "#6f00ff", "#ffd666"];

// Demo shapes are placed by their top-left corner, like a design tool.
const CORNER = { originX: "left", originY: "top" } as const;

/** A new shape at a random spot on the page. */
export function newShape(kind: "square" | "circle", fill?: string) {
  const left = 40 + Math.random() * (PAGE.width - 160);
  const top = 30 + Math.random() * (PAGE.height - 120);
  const color = fill ?? COLORS[Math.floor(Math.random() * COLORS.length)];
  return kind === "square"
    ? new Rect({
        ...CORNER,
        left,
        top,
        width: 88,
        height: 64,
        rx: 10,
        ry: 10,
        fill: color,
      })
    : new Circle({ ...CORNER, left, top, radius: 34, fill: color });
}

/** A small composed drawing, so no demo starts on an empty page. */
export function seedShapes(): FabricObject[] {
  return [
    new Rect({
      ...CORNER,
      left: 48,
      top: 56,
      width: 176,
      height: 124,
      rx: 16,
      ry: 16,
      fill: COLORS[4],
    }),
    new Circle({ ...CORNER, left: 268, top: 44, radius: 58, fill: COLORS[0] }),
    new Rect({
      ...CORNER,
      left: 256,
      top: 196,
      width: 176,
      height: 52,
      rx: 26,
      ry: 26,
      fill: COLORS[1],
    }),
    new Triangle({
      ...CORNER,
      left: 96,
      top: 196,
      width: 72,
      height: 60,
      fill: COLORS[3],
    }),
  ];
}

export function DemoFrame({
  className,
  ...props
}: React.ComponentProps<"div">) {
  return (
    <div
      className={cn(
        "flex w-full max-w-md flex-col gap-3 text-left outline-none",
        className
      )}
      {...props}
    />
  );
}

/**
 * Scales the fixed-size Fabric canvas to the frame's width. The rules are
 * `!important` because Fabric writes its sizes as inline styles.
 */
export function DemoCanvas({
  elementRef,
  label,
  page = PAGE,
  children,
}: {
  elementRef: React.Ref<HTMLCanvasElement>;
  label: string;
  page?: { width: number; height: number };
  children?: React.ReactNode;
}) {
  return (
    <div
      className="relative overflow-hidden rounded-lg border bg-white shadow-xs [&_.canvas-container]:aspect-(--page)! [&_.canvas-container]:h-auto! [&_.canvas-container]:w-full! [&_canvas]:h-full! [&_canvas]:w-full!"
      style={
        { "--page": `${page.width} / ${page.height}` } as React.CSSProperties
      }
    >
      <div>
        <canvas ref={elementRef} aria-label={label} />
      </div>
      {children}
    </div>
  );
}

export function Toolbar({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      className={cn("flex flex-wrap items-center gap-1.5", className)}
      {...props}
    />
  );
}

export function ToolButton({
  icon: Icon,
  label,
  iconOnly = false,
  primary = false,
  className,
  ...props
}: React.ComponentProps<"button"> & {
  icon: LucideIcon;
  label: string;
  iconOnly?: boolean;
  primary?: boolean;
}) {
  return (
    <button
      type="button"
      title={label}
      aria-label={iconOnly ? label : undefined}
      className={cn(
        "inline-flex h-8 min-w-0 items-center justify-center gap-1.5 rounded-md border px-2.5 text-[13px] font-medium whitespace-nowrap transition-colors disabled:pointer-events-none disabled:opacity-40 [&_svg]:size-3.5 [&_svg]:shrink-0",
        primary
          ? "border-transparent bg-foreground text-background hover:bg-foreground/85"
          : "bg-background hover:bg-muted",
        iconOnly && "w-8 px-0",
        className
      )}
      {...props}
    >
      <Icon aria-hidden />
      {!iconOnly && <span className="truncate">{label}</span>}
    </button>
  );
}

const TONES = {
  idle: "bg-zinc-400",
  busy: "bg-amber-400",
  ok: "bg-emerald-500",
  error: "bg-red-500",
};

/** One line of status text. Its height never changes, so the card never jumps. */
export function Status({
  tone = "idle",
  children,
}: {
  tone?: keyof typeof TONES;
  children: React.ReactNode;
}) {
  return (
    <p
      role="status"
      className="flex h-5 min-w-0 items-center gap-2 text-[13px] text-muted-foreground"
    >
      <span
        aria-hidden
        className={cn("size-1.5 shrink-0 rounded-full", TONES[tone])}
      />
      <span className="truncate">{children}</span>
    </p>
  );
}
```

## 显示加载进度并取消

```ts
const controller = new AbortController();
cancelButton.onclick = () => controller.abort();

await engine.load("floor-plan", {
  signal: controller.signal,
  onProgress: ({ stage, done, total }) => {
    progressBar.value = total > 0 ? done / total : 0;
    progressLabel.textContent = stage;
  },
});
```

- `stage` 依次是 `prepare`（校验和迁移）、`images`（检查图片）、`objects`（创建对象）和 `done`。
- 对象每次创建 100 个，每批之间把控制权交还给页面，所以点击和滚动不会卡住。这也是常见的 canvas性能优化 思路。
- 取消 `AbortSignal` 会以 `LOAD_ABORTED` 拒绝，画布继续显示原来的内容。取消加载和加载失败都一样，因为所有对象都创建完成后才会清空画布。
- `load:progress` 事件也带有同样的值，方便调用方以外的代码使用。

`signal` 和 `onProgress` 可以用于 `load`、`loadDocument`、`importFabricJson` 和 `restoreRecovery`。

## 图片加载失败的原因

有图片缺失时，加载会以 `MISSING_ASSETS` 停止，现在每一项都会说明原因：

```ts
try {
  await engine.load("floor-plan");
} catch (error) {
  if (isDocumentEngineError(error) && error.code === "MISSING_ASSETS") {
    for (const { url, failure } of error.missingAssets) {
      console.warn(url, failure?.reason, failure?.status);
    }
  }
}
```

| `reason`     | 含义                                       |
| ------------ | ------------------------------------------ |
| `NOT_FOUND`  | 服务器返回 404 或 410                      |
| `HTTP_ERROR` | 服务器返回了其他错误状态                   |
| `CORS`       | 其他网站不允许这个页面读取图片             |
| `NETWORK`    | 地址无法访问                               |
| `TIMEOUT`    | 图片加载超过了 `assets.imageTimeout`       |
| `DECODE`     | 文件已经下载，但不是浏览器能读取的图片     |
| `ABORTED`    | 加载被取消                                 |

浏览器会有意隐藏一些细节。来自其他网站、没有 CORS 头的图片失败时，如果图片设置了 `crossOrigin`，原因是 `CORS`，否则是 `NETWORK`。

## 调整图片加载

```ts
createDocumentEngine({
  canvas,
  assets: {
    imageTimeout: 15_000,     // default 30 seconds
    maxConcurrentImages: 4,   // default 6
  },
});
```

网速慢、大图片很多时，同时加载的图片少一些反而更快。更多 fabricjs 性能优化 的数据见[兼容性](https://fabricjs-document-engine.jscrate.dev/zh/docs/production/compatibility)，图片缺失的处理见[图片和字体](https://fabricjs-document-engine.jscrate.dev/zh/docs/guides/assets-and-fonts)。如果要渲染很多份文档，而不是打开一份大文档，请看[批量渲染](https://fabricjs-document-engine.jscrate.dev/zh/docs/guides/batch-rendering)。
