# 渲染大量对象

> 解决 fabricjs 卡顿：脏区域渲染只重绘变化的部分，配合帧耗时统计和批量更新，几千个对象也能流畅拖动。

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

每次 renderAll，Fabric 都会清空整个画布，再把每个对象画一遍。在 5,000 个图形里拖动一个，每次鼠标移动都要画 5,000 个图形，这就是 fabricjs 卡顿 的主要原因（[fabric.js #9847](https://github.com/fabricjs/fabric.js/issues/9847)）。Fabric 自带的开关只能解决一部分：对象缓存减少每个对象的绘制时间，`skipOffscreen` 跳过视野外的对象，但屏幕上的一切仍然每帧都要重画。

## 试一试

```tsx
"use client";

import { Rect } from "fabric";
import {
  createPerformanceMonitor,
  type DirtyRegionRenderer,
  enableDirtyRegionRendering,
} from "fabricjs-document-engine/performance";
import { GaugeIcon, MoveIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { useEffect, useRef, useState } from "react";

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

const COUNT = 3000;

export default function DirtyRegionsDemo() {
  const t = useTranslations("demos");
  const { elementRef, canvas } = useFabricCanvas();
  const moverRef = useRef<Rect | null>(null);
  const [dirty, setDirty] = useState(true);
  const [moving, setMoving] = useState(false);
  const [stats, setStats] = useState({ ms: 0, drawn: 0 });

  useEffect(() => {
    if (!canvas) return;
    canvas.renderOnAddRemove = false;
    const shapes = Array.from(
      { length: COUNT },
      (_, index) =>
        new Rect({
          left: (index * 97) % (PAGE.width - 10),
          top: (index * 61) % (PAGE.height - 8),
          width: 9,
          height: 7,
          fill: COLORS[index % COLORS.length],
          selectable: false,
          evented: false,
          objectCaching: false,
        })
    );
    const mover = new Rect({
      left: 40,
      top: 120,
      width: 70,
      height: 50,
      rx: 8,
      ry: 8,
      fill: "#0d0d0d",
    });
    moverRef.current = mover;
    canvas.add(...shapes, mover);
    canvas.requestRenderAll();
    return () => {
      canvas.remove(...shapes, mover);
    };
  }, [canvas]);

  useEffect(() => {
    if (!canvas) return;
    const renderer: DirtyRegionRenderer | null = dirty
      ? enableDirtyRegionRendering(canvas)
      : null;
    const monitor = createPerformanceMonitor(canvas, { samples: 60 });
    const timer = setInterval(() => {
      const frame = monitor.stats();
      setStats({
        ms: Math.round(frame.p50Ms * 10) / 10,
        drawn: renderer
          ? renderer.stats().lastObjectsDrawn
          : frame.lastObjectsDrawn,
      });
    }, 400);
    canvas.requestRenderAll();
    return () => {
      clearInterval(timer);
      monitor.stop();
      renderer?.disable();
    };
  }, [canvas, dirty]);

  function move() {
    const mover = moverRef.current;
    if (!canvas || !mover || moving) return;
    setMoving(true);
    const start = performance.now();
    const step = (now: number) => {
      const progress = (now - start) / 2500;
      mover.set({
        left: 40 + Math.sin(progress * Math.PI * 2) * 160 + 160,
        top: 120 + Math.cos(progress * Math.PI * 4) * 60,
      });
      mover.setCoords();
      canvas.renderAll();
      if (progress < 1) requestAnimationFrame(step);
      else setMoving(false);
    };
    requestAnimationFrame(step);
  }

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

      <Toolbar>
        <ToolButton
          icon={MoveIcon}
          label={t("dirty.move")}
          primary
          disabled={!canvas || moving}
          onClick={move}
        />
        <ToolButton
          icon={GaugeIcon}
          label={dirty ? t("dirty.on") : t("dirty.off")}
          disabled={moving}
          onClick={() => setDirty((value) => !value)}
        />
      </Toolbar>

      <Status tone={stats.drawn > 0 ? "ok" : "idle"}>
        {t("dirty.status", {
          ms: stats.ms,
          drawn: stats.drawn,
          count: COUNT + 1,
        })}
      </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
import { enableDirtyRegionRendering } from "fabricjs-document-engine/performance";

const renderer = enableDirtyRegionRendering(canvas);

renderer.disable();
```

代码的其他部分不需要改变：renderAll、`requestRenderAll`、事件和导出都和以前一样。这就是 canvas 局部渲染 的做法。

## 它怎样决定画什么

1. 每一帧，用一次遍历把每个对象的指纹和上一帧比较：变换、大小、层级位置、阴影、描边、`dirty` 标记以及子对象的这些信息。用 `set()` 修改而没有触发事件的变化也能发现。
2. 变化的对象标记它的旧边界和新边界，并为阴影、描边拐角、文字溢出和抗锯齿留出余量。选中对象的控制点也会标记自己的区域。
3. 标记的区域最多合并成四个矩形，也就是 canvas 脏矩形。
4. 网格索引找出碰到这些矩形的对象，再由 Fabric 自己的渲染器按层级顺序只画这些对象，并裁剪到矩形内。`before:render` 和 `after:render` 仍然只触发一次。

视图或尺寸改变、背景或叠加层改变，或者变化的区域超过画布的 40% 时，这一帧会完整绘制。没有任何变化的帧什么也不画。

## 实测

在 1,200 × 800 的画布上拖动一个图形时，每帧耗时的中位数（无头浏览器）：

| 对象数 | Chromium：Fabric / 脏区域 | Firefox：Fabric / 脏区域 |
| ------ | ------------------------- | ------------------------ |
| 1,000  | 178 ms / 0.4 ms           | 151 ms / 1 ms            |
| 5,000  | 987 ms / 1.7 ms           | 814 ms / 2 ms            |

## 其他辅助函数

- `createPerformanceMonitor(canvas)` 记录每帧耗时（`p50Ms`、`p95Ms`、`worstMs`）和绘制的对象数，只保存最近若干帧。
- `batchCanvasUpdates(canvas, work)` 在添加或修改大量对象时关闭 `renderOnAddRemove`，最后只渲染一次，减少 fabricjs 卡顿。
- `createSpatialIndex()` 就是那个网格索引，可以用来做你自己的命中检测。

## 自定义对象

如果自定义对象在没有改变任何属性的情况下改变了绘制内容，在改变之后调用 `renderer.invalidate()`，下一帧就会完整绘制。

## 相关内容

选项列在 [性能 API](https://fabricjs-document-engine.jscrate.dev/zh/docs/api/performance-api) 中。加载大量对象见 [大文档](https://fabricjs-document-engine.jscrate.dev/zh/docs/guides/large-documents)，图片滤镜慢的问题见 [图片滤镜](https://fabricjs-document-engine.jscrate.dev/zh/docs/guides/image-filters)。
