# 批量渲染

> 批量 canvas生成图片：把很多份已保存的设计渲染成缩略图、SVG 或 PDF，内存不会一直上涨。

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

为几百份已保存的设计生成缩略图，通常要创建 Fabric.js 多个画布，每份设计一个。还没处理完，标签页的内存就耗尽了，因为浏览器归还画布内存很慢，而 Fabric.js 的 `dispose` 根本不会释放它。`renderDocuments` 只用几个复用的画布来做 canvas生成图片，每份文档完成后释放所有资源。

```tsx
"use client";

import { type FabricDocument, renderDocuments } from "fabricjs-document-engine";
import { CircleStopIcon, ImagesIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { useEffect, useRef, useState } from "react";

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

const COUNT = 24;

/** A saved design, as a database would return it. */
function savedDesign(index: number): FabricDocument {
  const color = (offset: number) => COLORS[(index + offset) % COLORS.length];
  return {
    schemaVersion: 1,
    id: `design-${index + 1}`,
    createdAt: "",
    updatedAt: "",
    canvas: { width: PAGE.width, height: PAGE.height, background: "#ffffff" },
    objects: [
      {
        type: "Rect",
        originX: "left",
        originY: "top",
        left: 40,
        top: 40,
        width: 400,
        height: 220,
        rx: 24,
        ry: 24,
        fill: color(0),
      },
      {
        type: "Circle",
        originX: "left",
        originY: "top",
        left: 70 + (index % 5) * 50,
        top: 80,
        radius: 60,
        fill: color(2),
      },
      {
        type: "Textbox",
        originX: "left",
        originY: "top",
        left: 250,
        top: 120,
        width: 170,
        text: `#${index + 1}`,
        fontSize: 64,
        fontFamily: "sans-serif",
        fill: "#0d0d0d",
      },
    ],
    metadata: {},
  };
}

export default function BatchRenderDemo() {
  const t = useTranslations("demos");
  const [thumbnails, setThumbnails] = useState<
    Array<{ id: string; url: string }>
  >([]);
  const [done, setDone] = useState(0);
  const [running, setRunning] = useState(false);
  const [message, setMessage] = useState("");
  const controller = useRef<AbortController | null>(null);
  const urls = useRef<string[]>([]);

  // Thumbnails are object URLs; free them when the demo goes away.
  useEffect(
    () => () => urls.current.forEach((url) => URL.revokeObjectURL(url)),
    []
  );

  async function render() {
    urls.current.forEach((url) => URL.revokeObjectURL(url));
    urls.current = [];
    setThumbnails([]);
    setDone(0);
    setRunning(true);
    const current = new AbortController();
    controller.current = current;
    const started = performance.now();
    const designs = Array.from({ length: COUNT }, (_, index) =>
      savedDesign(index)
    );
    try {
      for await (const { documentId, result } of renderDocuments(designs, {
        format: "png",
        scale: 0.25,
        concurrency: 2,
        signal: current.signal,
        onProgress: ({ done: finished }) => setDone(finished),
      })) {
        if (!result) continue;
        const url = URL.createObjectURL(result.blob);
        urls.current.push(url);
        setThumbnails((previous) => [
          ...previous,
          { id: documentId ?? url, url },
        ]);
      }
      setMessage(
        t("batch.done", {
          count: COUNT,
          ms: Math.round(performance.now() - started),
        })
      );
    } catch {
      setMessage(t("batch.cancelled"));
    } finally {
      controller.current = null;
      setRunning(false);
    }
  }

  return (
    <DemoFrame>
      <ul
        aria-label={t("batch.list")}
        className="grid aspect-480/300 grid-cols-6 content-start gap-1.5 overflow-hidden rounded-lg border bg-muted/40 p-2"
      >
        {thumbnails.map(({ id, url }) => (
          <li key={id} className="overflow-hidden rounded-sm border bg-white">
            {/* eslint-disable-next-line @next/next/no-img-element -- a rendered PNG from the batch */}
            <img src={url} alt={id} className="block aspect-480/300 w-full" />
          </li>
        ))}
      </ul>

      <Toolbar>
        <ToolButton
          icon={ImagesIcon}
          label={t("batch.render", { count: COUNT })}
          primary
          disabled={running}
          onClick={() => void render()}
        />
        <ToolButton
          icon={CircleStopIcon}
          label={t("batch.cancel")}
          disabled={!running}
          onClick={() => controller.current?.abort()}
        />
      </Toolbar>

      <Status tone={running ? "busy" : message ? "ok" : "idle"}>
        {running
          ? t("batch.progress", { done, total: COUNT })
          : message || t("batch.hint")}
      </Status>
    </DemoFrame>
  );
}
```

```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>
  );
}
```

```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 };
}
```

## 渲染缩略图

```ts
import { renderDocuments } from "fabricjs-document-engine";

for await (const { documentId, result, error } of renderDocuments(savedDocuments, {
  format: "png",
  scale: 0.25,
  concurrency: 2,
})) {
  if (result) await uploadThumbnail(documentId, result.blob);
  else console.warn(documentId, error?.message);
}
```

- 每份文档完成后就会返回结果，可以逐个上传后丢弃，不必全部留在内存里。
- 损坏的文档会报告自己的 `error`，其余文档继续渲染。
- `ExportOptions` 的所有字段都可以用，同一个循环可以做 canvas生成图片（PNG、JPEG），也可以导出 SVG 或 JSON。

## 来自数据库的文档

`documents` 可以是任意可迭代对象，也可以是异步可迭代对象，例如数据库查询的分页结果，这样所有文档不会同时留在内存里：

```ts
async function* allDesigns() {
  for (let page = 0; ; page += 1) {
    const rows = await db.designs.findMany({ skip: page * 50, take: 50 });
    if (rows.length === 0) return;
    yield* rows.map((row) => row.document);
  }
}

for await (const rendered of renderDocuments(allDesigns(), { format: "png", scale: 0.5 })) {
  // store rendered.result
}
```

## 内存、取消和进度

| 配置项         | 作用                                                  | 默认值  |
| -------------- | ----------------------------------------------------- | ------- |
| `concurrency`  | 同时渲染几份文档，每份用自己的画布                    | `2`     |
| `onProgress`   | 每份文档完成后以 `{ done, total }` 调用               | 无      |
| `signal`       | 以 `EXPORT_ABORTED` 停止整个批次                      | 无      |
| `customObjects`, `assets`, `limits` | 和 `createDocumentEngine` 相同   | 无      |

- 不再需要 Fabric.js 多个画布：无论有多少份文档，只存在 `concurrency` 个画布。
- 每份文档完成后，它的对象、对象的缓存画布和导出用的画布都会立即释放。
- 用 `break` 或 `signal` 提前停止时，所有画布也会被释放。

渲染需要浏览器。要生成每份文档一页的 PDF 文件，把文档传给 [PDF 导出](https://fabricjs-document-engine.jscrate.dev/zh/docs/guides/pdf-export)。这个函数列在[辅助函数](https://fabricjs-document-engine.jscrate.dev/zh/docs/api/helpers)里。
