FabricImage.applyFilters() 是同步的。它要把每个滤镜作用到每个像素之后,页面才能重绘或响应点击,所以对大照片做图片模糊会让编辑器卡住(fabric.js #9532)。Fabric 的 WebGL 后端对部分滤镜更快,但工作仍然在主线程上进行,GPU 较慢或 WebGL 上下文丢失时还会退回 2D 方式。createFilterWorker 会把 fabricjs 滤镜放到 Web Worker 中运行。
试一试
正在加载在线示例,代码在下方。
"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>
);
}分别在 Worker 中和用 Fabric 运行同样的模糊,注意角落里的旋转图标。页面卡住时它就会停下。状态栏会显示最长一次卡顿的毫秒数。
在 Worker 中运行滤镜
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。
进度和最新一次优先
只改变单个像素的滤镜,例如亮度、对比度和颜色矩阵,会按行分块一起运行,每块完成后报告一次进度。模糊、卷积和像素化则作为一步作用于整张图片。
slider.addEventListener("input", () => {
void filterWorker.apply(
image,
[new filters.Blur({ blur: Number(slider.value) })],
{
onProgress: (done) => (progress.value = done),
}
);
});拖动滑块时,同一张图片的每次新运行都会取消上一次,旧的 promise 返回 false。只有最后一个值会被绘制。
取消
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。
const filterWorker = createFilterWorker({ worker: false });所有配置项都在滤镜 API里。对象太多导致绘制变慢,请看渲染大量对象。fabricjs 滤镜保留在 filters 数组中,保存的文档重新打开后效果不变。