Fabric.js Document Engine

搜索文档

查找页面或章节

EN

几千个对象、进度条、取消按钮和清楚的图片错误。

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

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

large-document-demo.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>
  );
}

显示加载进度并取消

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 停止,现在每一项都会说明原因:

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。

调整图片加载

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

网速慢、大图片很多时,同时加载的图片少一些反而更快。更多 fabricjs 性能优化 的数据见兼容性,图片缺失的处理见图片和字体。如果要渲染很多份文档,而不是打开一份大文档,请看批量渲染。