Fabric.js Document Engine

搜索文档

查找页面或章节

EN

批量渲染

查看 Markdown

在一个标签页里为几百份文档生成缩略图和导出文件,内存保持平稳。

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

正在加载在线示例,代码在下方。

batch-render-demo.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>
  );
}

渲染缩略图

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 可以是任意可迭代对象,也可以是异步可迭代对象,例如数据库查询的分页结果,这样所有文档不会同时留在内存里:

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 导出。这个函数列在辅助函数里。