2025 年最新 Fabric.js 实战:一个完整可上线的图片选区标注组件(含全部源码).
2025年11月28日 10:34
![]()
# 从 0 到 1 用最新 Fabric.js 实现:背景图 + 手动画矩形 + 坐标 + 删除 + 高清截图导出(2025 最新版)
最近项目要做一个「图片自动化清洗工具」,核心需求如下(产品甩给我的原话):
- 支持上传任意尺寸的广告图
- 运营要在图上框出需要保留的区域(可画多个矩形)
- 框出来的区域右上角必须有 × 可以删除
- 实时显示鼠标在原图上的精确坐标
- 最后要能把每个选区裁成独立图片 + 导出坐标数据给后端
- 必须有一键清空功能
我调研了 Konva、PixiJS、ZRender,最终还是选择了最成熟、最好用的 **Fabric.js**,并且使用最新版 v6.7.1+ 完美实现!
本文所有代码基于 Fabric.js 6.7+,完全适配 Vue3/React/原生JS,已在生产环境稳定运行。
一、Fabric.js 官网 & 安装方式(2025 最新)
-
官网:fabricjs.com
-
GitHub:github.com/fabricjs/fa…
-
当前最新版本:6.7.x(2025年11月)
二、bash
npm install fabric@latest
![]()
三、创建画布 & 初始化 CanvasManager 类
<template>
<canvas ref="canvasEl" id="canvas"></canvas>
</template>
// CanvasManager.js
import { Canvas, FabricImage, Rect, Control } from "fabric";
export class CanvasManager {
constructor(canvasEl, options = {}) {
this.canvas = new Canvas(canvasEl, {
width: canvasEl.clientWidth || 1000,
height: canvasEl.clientHeight || || 700,
backgroundColor: "#f5f5f5",
selection: false, // 全局禁止多选框(我们自己控制选中态)
preserveObjectStacking: true,
});
// 回调函数,用于通知外部(如 Pinia/Vuex)矩形增删改
this.onRectangleAdded = options.onRectangleAdded;
this.onRectangleUpdated = options.onRectangleUpdated;
this.onRectangleDeleted = options.onRectangleDeleted;
this.isDrawing = false;
this.startX = this.startY = 0;
this.currentRect = null;
this.rectangles = []; // 所有已完成的矩形
this.originalImageWidth = this.originalImageHeight = 0;
this.initEvents();
}
initEvents() {
this.canvas.on("mouse:down", this.handleMouseDown.bind(this));
this.canvas.on("mouse:move", this.handleMouseMove.bind(this));
this.canvas.on("mouse:up", this.handleMouseUp.bind(this));
}
}
四、上传背景图 + 完美适配画布(支持 5000×5000 大图不卡)
FabricImage 设置canvas画布背景
<!-- 上传按钮(Ant Design Vue) -->
<input
type="file"
ref="fileInput"
@change="handleFileUpload"
accept="image/*"
style="display: none"
/>
<a-button type="primary" @click="$refs.fileInput.click()">
上传图片
</a-button>
// 设置背景图(核心方法)
async setBackground(source) {
try {
const img = await FabricImage.fromURL(source, { crossOrigin: "anonymous" });
// 保存原始尺寸(后面导出坐标要用)
this.originalImageWidth = img.width;
this.originalImageHeight = img.height;
// 等比缩放至画布宽度(也可改成 scaleToHeight)
img.scaleToWidth(this.canvas.getWidth());
this.canvas.setBackgroundImage(img, () => {
this.canvas.requestRenderAll();
});
return {
originalSize: { width: img.width, height: img.height },
scaledSize: { width: img.getScaledWidth(), height: img.getScaledHeight() },
scaleX: img.scaleX,
scaleY: img.scaleY,
};
} catch (err) {
console.error("背景图加载失败", err);
throw err;
}
}
五、手动画矩形 + 右上角删除按钮(最丝滑写法)
在图片标注类工具中,“按住鼠标拖拽绘制矩形选区 + 右上角一键删除”是核心交互体验。本方案基于 Fabric.js v6.7+ 官方推荐的事件机制与自定义 Control 体系,实现了一套高可维护性、视觉统
绘制矩形区域
// 开始绘制矩形选区(鼠标按下时触发)
startDrawing(opt) {
// 获取当前鼠标在画布上的精确坐标(已自动处理缩放、平移、滚动偏移)
// getPointer 是 Fabric.js 官方推荐方法,比 e.layerX/e.offsetX 更准确
const pointer = this.canvas.getPointer(opt.e);
this.startX = pointer.x;// 记录矩形起始点的 X 坐标(左上角)
this.startY = pointer.y;// 记录矩形起始点的 Y 坐标(左上角)
// 标记当前处于“正在绘制”状态,后续 mouse:move 和 mouse:up 会用到
this.isDrawing = true;
// 创建一个新的 Fabric Rect 实例,作为当前正在绘制的矩形
this.currentRect = new Rect({
left: this.startX,// 矩形左上角 X 坐标(起始点)
top: this.startY,// 矩形左上角 Y 坐标(起始点)
width: 0, // 初始宽高为 0,后续拖拽时动态更新
height: 0,
fill: "rgba(255,0,0,0.3)", // 半透明红色填充,便于区分选区
stroke: "red",// 边框颜色
strokeWidth: 2,// 边框粗细
selectable: true, // 允许被选中和拖拽移动
hasControls: true,// 显示八个控制点(调整大小用)
hasRotatingPoint: false, // 禁止旋转手柄(我们不需要旋转)
cornerColor: "red", // 控制点角(调整大小时的八个小方块)颜色设为红色,与主题保持一致
transparentCorners: false, // 控制点不透明(默认是半透明的,这里改成实心)
cornerStyle: "circle",// 控制点形状为圆形(比默认矩形更好看)
cornerSize: 12,// 控制点大小(像素)
strokeDashArray: [5, 5],// 边框虚线样式 [实线长度, 间隔长度]
});
// 创建右上角的“× 删除”自定义控制点(自定义控制点是 Fabric.js 高阶用法)
const deleteControl = this.createDeleteControl();
// 把自定义删除按钮挂载到当前矩形的 controls 上,键名可以自定义
// 之后可以通过 rect.setControlVisible('deleteBtn', false) 控制显隐
this.currentRect.controls.deleteBtn = deleteControl;
// 隐藏默认的旋转控制点(mtr = middle top rotate)
// 我们已经禁用了旋转,这里再保险隐藏一下
this.currentRect.setControlVisible("mtr", false);
// 立即把矩形添加到画布(此时宽高为0,看不到,但必须先加进去才能实时更新)
this.canvas.add(this.currentRect);
// 触发重绘,确保即使宽高为0也能看到光标变化
this.canvas.requestRenderAll();
},
一、体验丝滑的绘制能力。
核心交互流程严格遵循经典三阶段模型:
-
mouse:down → 开启绘制模式
- 调用 canvas.getPointer(e) 获取相对于画布的精准坐标(自动补偿缩放、平移、视口滚动)
- 初始化 isDrawing = true 状态标志
- 创建临时 fabric.Rect 实例(初始宽高为 0)并立即加入画布,确保后续 move 事件可实时更新
-
mouse:move → 实时更新矩形尺寸与位置
- 动态计算宽度/高度取绝对值,支持四个方向拖拽
- 动态调整 left/top 为较小值,保证矩形左上角始终为起始点
- 每帧调用 canvas.requestRenderAll() 实现流畅预览
-
mouse:up → 绘制完成 & 防御性收尾
- 自动过滤误触(宽或高 < 10px 的矩形直接丢弃)
- 为有效矩形添加自定义删除控件(controls.deleteBtn)
- 将矩形实例推入管理数组,便于后续批量操作与数据同步
- 触发外部回调 onRectangleAdded,实现与 Pinia/Vuex/React 状态的完美解耦
鼠标事件
// 鼠标按下事件
handleMouseDown(opt) {
if (opt.target) {
return; // 点击了已有对象,进入编辑模式
}
if (this.isDrawing) return;
this.startDrawing(opt);
}
// 鼠标移动事件
handleMouseMove(opt) {
if (!this.isDrawing || !this.currentRect) return;
const pointer = this.canvas.getPointer(opt.e);
const w = Math.abs(pointer.x - this.startX);
const h = Math.abs(pointer.y - this.startY);
this.currentRect.set({
width: w,
height: h,
left: Math.min(pointer.x, this.startX),
top: Math.min(pointer.y, this.startY),
});
this.canvas.requestRenderAll();
}
// 鼠标松开事件
handleMouseUp() {
if (!this.isDrawing || !this.currentRect) return;
this.isDrawing = false;
// 检查矩形尺寸,太小则删除
if (this.currentRect.width < 5 || this.currentRect.height < 5) {
this.canvas.remove(this.currentRect);
this.currentRect = null;
this.canvas.requestRenderAll();
return;
}
this.finalizeRectangle();
}
自定义删除控件(Custom Control)实现亮点
- 位置锚点:x: 0.5, y: -0.5 + offsetX/Y 微调,精准定位在矩形右上角
- 视觉风格:红色圆形底 + 白色粗体 ×,与 Ant Design/ProComponents 设计语言完全一致
- 交互体验:cornerSize: 28 扩大点击区域,老年模式也能轻松点中
- 性能优化:仅使用 Canvas 2D 绘制,无额外 DOM 元素,无内存泄漏
- 事件隔离:mouseUpHandler 内部直接 canvas.remove(target) 并通知外部删除回调
Control就是可以设置图形的点。
createDeleteControl() {
/**
* 创建 Fabric.js 自定义删除控件
* 该控件会出现在选中对象的右上角(可通过 x/y 调整位置)
*/
return new Control({
x: 0.5, // 水平锚点:0.5 表示对象右边缘
y: -0.5, // 垂直锚点:-0.5 表示对象上边缘
offsetX: 10, // 水平偏移量(向右偏移 10px)
offsetY: -10, // 垂直偏移量(向上偏移 10px)
cursorStyle: "pointer", // 鼠标悬停时显示手型光标
cornerSize: 24, // 可点击区域大小(24×24px)
// 自定义绘制删除图标(× 或垃圾桶图标)
render: this.renderDeleteIcon.bind(this),
// 点击删除按钮后执行的回调
mouseUpHandler: this.deleteHandler.bind(this),
});
}
简单的删除操作就是在画布中remove(对应的矩形) requestRenderAll重新渲染
六、清空所有选取
清空操作就是获取所有的矩形实例然后给remove ,重新渲染requestRenderAll。
七、完整代码
import { Canvas, FabricImage, Rect, Control } from "fabric";
export class CanvasManager {
// 创建初始画布
constructor(canvasEl, options = {}) {
// 保存回调函数
this.onRectangleAdded = options.onRectangleAdded;
this.onRectangleUpdated = options.onRectangleUpdated;
this.onRectangleDeleted = options.onRectangleDeleted;
this.canvas = new Canvas(canvasEl, {
width: canvasEl.clientWidth || 800,
height: canvasEl.clientHeight || 600,
backgroundColor: "#f0f0f0",
selection: false,
});
this.isDrawing = false;
this.startX = 0;
this.startY = 0;
this.currentRect = null;
this.deleteIconImg = null;
this.rectangles = [];
this.initEvents();
}
// 设置背景图片
async setBackground(imageUrl) {
try {
// 保存图片URL/路径
this.imageUrl =
typeof imageUrl === "string"
? imageUrl
: imageUrl?.default || imageUrl?.src || imageUrl;
const img = await FabricImage.fromURL(this.imageUrl);
// 保存原始图片尺寸
this.originalImageWidth = img.width;
this.originalImageHeight = img.height;
// 缩放图片以适应画布宽度
img.scaleToWidth(this.canvas.getWidth());
// 保存缩放后的尺寸(实际显示尺寸)
this.scaledImageWidth = img.width * (img.scaleX || 1);
this.scaledImageHeight = img.height * (img.scaleY || 1);
this.canvas.backgroundImage = img;
this.canvas.requestRenderAll();
return {
image: img,
imageUrl: this.imageUrl, // 返回图片路径
originalSize: {
width: this.originalImageWidth,
height: this.originalImageHeight,
},
scaledSize: {
width: this.scaledImageWidth,
height: this.scaledImageHeight,
},
scaleX: img.scaleX,
scaleY: img.scaleY,
};
} catch (error) {
console.error("背景加载失败:", error);
throw error;
}
}
// 获取图片URL/路径
getImageUrl() {
return this.imageUrl || null;
}
// 获取图片尺寸信息
getImageSize() {
if (!this.canvas.backgroundImage) {
return null;
}
return {
original: {
width: this.originalImageWidth || 0,
height: this.originalImageHeight || 0,
},
scaled: {
width: this.scaledImageWidth || 0,
height: this.scaledImageHeight || 0,
},
scaleX: this.canvas.backgroundImage.scaleX || 1,
scaleY: this.canvas.backgroundImage.scaleY || 1,
};
}
// 加载删除图标
async loadDeleteIcon(iconUrl) {
return new Promise((resolve) => {
const img = new Image();
img.src = iconUrl;
img.onload = () => {
this.deleteIconImg = img;
resolve(img);
};
img.onerror = () => {
console.warn("删除图标加载失败,使用默认样式");
resolve(null);
};
});
}
// 初始化事件
initEvents() {
this.canvas.on("mouse:down", this.handleMouseDown.bind(this));
this.canvas.on("mouse:move", this.handleMouseMove.bind(this));
this.canvas.on("mouse:up", this.handleMouseUp.bind(this));
this.canvas.on("selection:created", this.handleSelectionCreated.bind(this));
}
// 鼠标按下事件
handleMouseDown(opt) {
if (opt.target) {
return; // 点击了已有对象,进入编辑模式
}
if (this.isDrawing) return;
this.startDrawing(opt);
}
// 开始绘制
startDrawing(opt) {
const pointer = this.canvas.getPointer(opt.e);
this.startX = pointer.x;
this.startY = pointer.y;
this.isDrawing = true;
this.currentRect = new Rect({
left: this.startX,
top: this.startY,
width: 0,
height: 0,
fill: "rgba(255,0,0,0.3)",
stroke: "red",
strokeWidth: 2,
selectable: true,
hasControls: true,
hasRotatingPoint: false,
cornerColor: "red",
transparentCorners: false,
cornerStyle: "circle",
cornerSize: 12,
strokeDashArray: [5, 5],
});
// 添加删除控制点
const deleteControl = this.createDeleteControl();
this.currentRect.controls.deleteBtn = deleteControl;
this.currentRect.setControlVisible("mtr", false);
this.canvas.add(this.currentRect);
this.canvas.requestRenderAll();
}
// 创建删除控制点
createDeleteControl() {
return new Control({
x: 0.5,
y: -0.5,
offsetX: 10,
offsetY: -10,
cursorStyle: "pointer",
cornerSize: 24,
render: this.renderDeleteIcon.bind(this),
mouseUpHandler: this.deleteHandler.bind(this),
});
}
// 鼠标移动事件
handleMouseMove(opt) {
if (!this.isDrawing || !this.currentRect) return;
const pointer = this.canvas.getPointer(opt.e);
const w = Math.abs(pointer.x - this.startX);
const h = Math.abs(pointer.y - this.startY);
this.currentRect.set({
width: w,
height: h,
left: Math.min(pointer.x, this.startX),
top: Math.min(pointer.y, this.startY),
});
this.canvas.requestRenderAll();
}
// 鼠标松开事件
handleMouseUp() {
if (!this.isDrawing || !this.currentRect) return;
this.isDrawing = false;
// 检查矩形尺寸,太小则删除
if (this.currentRect.width < 5 || this.currentRect.height < 5) {
this.canvas.remove(this.currentRect);
this.currentRect = null;
this.canvas.requestRenderAll();
return;
}
this.finalizeRectangle();
}
// CanvasManager.js
finalizeRectangle() {
const id = `rect_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const coords = this.currentRect.getCoords();
const rectData = {
id,
fabricObject: this.currentRect,
coords: {
tl: coords[0],
tr: coords[1],
br: coords[2],
bl: coords[3],
},
left: this.currentRect.left,
top: this.currentRect.top,
width: this.currentRect.width,
height: this.currentRect.height,
angle: this.currentRect.angle,
};
// 保存到 rectangles 数组
this.rectangles.push(rectData);
// 直接调用回调函数添加到 store
if (this.onRectangleAdded) {
this.onRectangleAdded(rectData);
}
this.canvas.setActiveObject(this.currentRect);
// 绑定实时更新事件
this.currentRect.on("moving", () => this.updateRectCoords(rectData));
this.currentRect.on("scaling", () => this.updateRectCoords(rectData));
this.currentRect.on("rotating", () => this.updateRectCoords(rectData));
this.currentRect = null;
this.canvas.requestRenderAll();
return rectData;
}
// 更新矩形坐标
updateRectCoords(rectData) {
const obj = rectData.fabricObject;
const coords = obj.getCoords();
rectData.coords = {
tl: {
x: Number(coords[0].x.toFixed(2)),
y: Number(coords[0].y.toFixed(2)),
},
tr: {
x: Number(coords[1].x.toFixed(2)),
y: Number(coords[1].y.toFixed(2)),
},
br: {
x: Number(coords[2].x.toFixed(2)),
y: Number(coords[2].y.toFixed(2)),
},
bl: {
x: Number(coords[3].x.toFixed(2)),
y: Number(coords[3].y.toFixed(2)),
},
};
rectData.left = Number(obj.left.toFixed(2));
rectData.top = Number(obj.top.toFixed(2));
rectData.width = Number(obj.width.toFixed(2));
rectData.height = Number(obj.height.toFixed(2));
rectData.angle = Number(obj.angle.toFixed(2));
// 通知更新
if (this.onRectangleUpdated) {
this.onRectangleUpdated(rectData);
}
}
// 选中事件
handleSelectionCreated(opt) {
const active = opt.target;
const data = this.rectangles.find((r) => r.fabricObject === active);
if (data) this.updateRectCoords(data);
}
// 删除处理
deleteHandler(eventData, transform) {
const target = transform.target;
if (!target) return false;
// 找到要删除的矩形数据
const rectData = this.rectangles.find((r) => r.fabricObject === target);
// 从 rectangles 中移除
this.rectangles = this.rectangles.filter((r) => r.fabricObject !== target);
// 调用删除回调
if (rectData && this.onRectangleDeleted) {
this.onRectangleDeleted(rectData.id);
}
this.canvas.remove(target);
this.canvas.requestRenderAll();
return true;
}
// 渲染删除图标
renderDeleteIcon(ctx, left, top) {
if (!this.deleteIconImg) {
// 降级样式
ctx.save();
ctx.fillStyle = "red";
ctx.beginPath();
ctx.arc(left, top, 12, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = "white";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(left - 6, top - 6);
ctx.lineTo(left + 6, top + 6);
ctx.moveTo(left + 6, top - 6);
ctx.lineTo(left - 6, top + 6);
ctx.stroke();
ctx.restore();
return;
}
const size = 20;
ctx.drawImage(
this.deleteIconImg,
left - size / 2,
top - size / 2,
size,
size
);
}
// 获取所有矩形数据
getRectangles() {
return this.rectangles.map((rect) => ({
id: rect.id,
coords: rect.coords,
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
angle: rect.angle,
}));
}
// 清空画布(保留背景图片)
clear() {
// 获取所有对象(不包括背景图片)
const objects = this.canvas.getObjects();
// 移除所有对象
objects.forEach((obj) => {
this.canvas.remove(obj);
});
// 清空矩形数组
this.rectangles = [];
// 重新渲染
this.canvas.requestRenderAll();
}
// 清空所有(包括背景图片)
clearAll() {
// 清空所有对象
this.clear();
// 清空背景图片
this.canvas.backgroundImage = null;
// 重新渲染
this.canvas.requestRenderAll();
}
// 调整画布大小
resize(width, height) {
this.canvas.setDimensions({ width, height });
if (this.canvas.backgroundImage) {
this.canvas.backgroundImage.scaleToWidth(width);
}
this.canvas.requestRenderAll();
}
// 销毁
destroy() {
this.canvas.off("mouse:down");
this.canvas.off("mouse:move");
this.canvas.off("mouse:up");
this.canvas.off("selection:created");
this.canvas.dispose();
}
}