# 图片滤镜

> 在 Web Worker 中运行 fabricjs 滤镜：模糊和颜色滤镜支持进度和取消，像素和 Fabric 相同，页面不会卡住。

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

`FabricImage.applyFilters()` 是同步的。它要把每个滤镜作用到每个像素之后，页面才能重绘或响应点击，所以对大照片做图片模糊会让编辑器卡住（[fabric.js #9532](https://github.com/fabricjs/fabric.js/issues/9532)）。Fabric 的 WebGL 后端对部分滤镜更快，但工作仍然在主线程上进行，GPU 较慢或 WebGL 上下文丢失时还会退回 2D 方式。`createFilterWorker` 会把 fabricjs 滤镜放到 Web Worker 中运行。

## 试一试

```tsx
"use client";

import { FabricImage, filters } from "fabric";
import {
  createFilterWorker,
  type FilterWorker,
  type ImageFilter,
} from "fabricjs-document-engine/filters";
import { CpuIcon, RotateCcwIcon, SnailIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { useEffect, useRef, useState } from "react";

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

const SOURCE = { width: 2400, height: 1500 };

function photo() {
  const element = document.createElement("canvas");
  element.width = SOURCE.width;
  element.height = SOURCE.height;
  const ctx = element.getContext("2d")!;
  const sky = ctx.createLinearGradient(0, 0, 0, SOURCE.height);
  sky.addColorStop(0, "#7ad9ec");
  sky.addColorStop(0.6, "#ffd666");
  sky.addColorStop(1, "#f2836f");
  ctx.fillStyle = sky;
  ctx.fillRect(0, 0, SOURCE.width, SOURCE.height);
  for (let index = 0; index < 600; index += 1) {
    ctx.fillStyle = `hsl(${(index * 47) % 360} 70% ${40 + (index % 30)}%)`;
    ctx.beginPath();
    ctx.arc(
      (index * 389) % SOURCE.width,
      (index * 251) % SOURCE.height,
      10 + (index % 50),
      0,
      Math.PI * 2
    );
    ctx.fill();
  }
  return element.toDataURL("image/jpeg", 0.9);
}

const heavy = (): ImageFilter[] => [
  new filters.Blur({ blur: 0.25 }),
  new filters.Convolute({ matrix: [0, -1, 0, -1, 5, -1, 0, -1, 0] }),
  new filters.Saturation({ saturation: 0.4 }),
];

function measureFreeze(work: () => Promise<unknown>) {
  const channel = new MessageChannel();
  let last = performance.now();
  let longest = 0;
  let running = true;
  channel.port1.onmessage = () => {
    const now = performance.now();
    longest = Math.max(longest, now - last);
    last = now;
    if (running) channel.port2.postMessage(null);
  };
  channel.port2.postMessage(null);
  return work().then(() => {
    running = false;
    channel.port1.close();
    return Math.round(Math.max(longest, performance.now() - last));
  });
}

export default function FilterWorkerDemo() {
  const t = useTranslations("demos");
  const { elementRef, canvas } = useFabricCanvas();
  const imageRef = useRef<FabricImage | null>(null);
  const workerRef = useRef<FilterWorker | null>(null);
  const spinnerRef = useRef<HTMLSpanElement>(null);
  const [busy, setBusy] = useState(false);
  const [progress, setProgress] = useState(0);
  const [message, setMessage] = useState("");

  useEffect(() => {
    if (!canvas) return;
    let cancelled = false;
    const worker = createFilterWorker();
    workerRef.current = worker;
    void FabricImage.fromURL(photo()).then((image) => {
      if (cancelled) return;
      image.scaleToWidth(PAGE.width);
      image.set({
        left: 0,
        top: 0,
        originX: "left",
        originY: "top",
        selectable: false,
      });
      imageRef.current = image;
      canvas.add(image);
      canvas.requestRenderAll();
    });
    let frame = 0;
    let angle = 0;
    const spin = () => {
      angle = (angle + 6) % 360;
      if (spinnerRef.current)
        spinnerRef.current.style.transform = `rotate(${angle}deg)`;
      frame = requestAnimationFrame(spin);
    };
    frame = requestAnimationFrame(spin);
    return () => {
      cancelled = true;
      cancelAnimationFrame(frame);
      worker.terminate();
      if (imageRef.current) canvas.remove(imageRef.current);
      imageRef.current = null;
    };
  }, [canvas]);

  async function run(inWorker: boolean) {
    const image = imageRef.current;
    const worker = workerRef.current;
    if (!canvas || !image || !worker || busy) return;
    setBusy(true);
    setProgress(0);
    setMessage(t("filters.running"));
    await new Promise((resolve) => setTimeout(resolve, 50));
    const freeze = await measureFreeze(async () => {
      if (inWorker) {
        await worker.apply(image, heavy(), { onProgress: setProgress });
      } else {
        image.filters = heavy() as never;
        image.applyFilters();
        setProgress(1);
      }
      canvas.requestRenderAll();
    });
    setMessage(
      inWorker
        ? t("filters.workerResult", { freeze, mode: worker.mode })
        : t("filters.fabricResult", { freeze })
    );
    setBusy(false);
  }

  async function reset() {
    const image = imageRef.current;
    if (!canvas || !image || !workerRef.current) return;
    await workerRef.current.apply(image, []);
    setProgress(0);
    setMessage("");
  }

  return (
    <DemoFrame>
      <DemoCanvas elementRef={elementRef} label={t("canvas")}>
        <span
          ref={spinnerRef}
          aria-hidden
          className="absolute top-2 right-2 size-5 rounded-full border-2 border-[#6f00ff] border-t-transparent"
        />
      </DemoCanvas>

      <div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
        <div
          className="h-full bg-[#6f00ff] transition-[width]"
          style={{ width: `${Math.round(progress * 100)}%` }}
        />
      </div>

      <Toolbar>
        <ToolButton
          icon={CpuIcon}
          label={t("filters.worker")}
          primary
          disabled={!canvas || busy}
          onClick={() => void run(true)}
        />
        <ToolButton
          icon={SnailIcon}
          label={t("filters.fabric")}
          disabled={!canvas || busy}
          onClick={() => void run(false)}
        />
        <ToolButton
          icon={RotateCcwIcon}
          label={t("filters.reset")}
          iconOnly
          disabled={!canvas || busy}
          onClick={() => void reset()}
        />
      </Toolbar>

      <Status tone={busy ? "busy" : message ? "ok" : "idle"}>
        {message || t("filters.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>
  );
}
```

分别在 Worker 中和用 Fabric 运行同样的模糊，注意角落里的旋转图标。页面卡住时它就会停下。状态栏会显示最长一次卡顿的毫秒数。

## 在 Worker 中运行滤镜

```ts
import { filters } from "fabric";
import { createFilterWorker } from "fabricjs-document-engine/filters";

const filterWorker = createFilterWorker({ engine });

await filterWorker.apply(image, [
  new filters.Blur({ blur: 0.2 }),
  new filters.Brightness({ brightness: 0.1 }),
]);
```

- 图片以 `ImageBitmap` 的形式传给 Worker，结果也以同样的方式传回。两者都是转移，而不是复制。
- Worker 在 OffscreenCanvas 上绘制，并使用 Fabric 自己的滤镜类，所以结果和 Fabric 的 2D canvas滤镜像素相同。
- 结果准备好之后图片才会改变。传入 `engine` 时，这次修改算一步撤销，并随文档保存。
- 图片更新后，`apply` 返回 `true`。

## 进度和最新一次优先

只改变单个像素的滤镜，例如亮度、对比度和颜色矩阵，会按行分块一起运行，每块完成后报告一次进度。模糊、卷积和像素化则作为一步作用于整张图片。

```ts
slider.addEventListener("input", () => {
  void filterWorker.apply(
    image,
    [new filters.Blur({ blur: Number(slider.value) })],
    {
      onProgress: (done) => (progress.value = done),
    }
  );
});
```

拖动滑块时，同一张图片的每次新运行都会取消上一次，旧的 promise 返回 `false`。只有最后一个值会被绘制。

## 取消

```ts
const controller = new AbortController();
const run = filterWorker.apply(image, heavyFilters, {
  signal: controller.signal,
});
controller.abort();
await run.catch(() => undefined);
```

被取消的运行会以 signal 的 reason 拒绝，图片保持原样。

## 在哪里运行

| 情况                                                                               | 滤镜在哪里运行                           |
| ---------------------------------------------------------------------------------- | ---------------------------------------- |
| 支持模块 Worker 和 `OffscreenCanvas`（Chrome 80、Firefox 114、Safari 16.4 及以上） | 在 web worker 中                         |
| 较旧的浏览器，或 CommonJS 构建                                                     | 在主线程上分成小步运行，每步之后暂停一下 |
| BlendImage、Resize 或你自己的滤镜类                                                | 使用 Fabric 的 `applyFilters`            |

`filterWorker.mode` 会告诉你使用的是哪一种。Vite、webpack 和 Next.js 会自动处理 Worker 文件。如果想有意在主线程上运行，例如在测试中，请传入 `worker: false`；在其他环境中，可以用 `createWorker` 自己创建 Worker。

```ts
const filterWorker = createFilterWorker({ worker: false });
```

所有配置项都在[滤镜 API](https://fabricjs-document-engine.jscrate.dev/zh/docs/api/filters-api)里。对象太多导致绘制变慢，请看[渲染大量对象](https://fabricjs-document-engine.jscrate.dev/zh/docs/guides/rendering-performance)。fabricjs 滤镜保留在 `filters` 数组中，保存的文档重新打开后效果不变。
