阅读视图

发现新文章,点击刷新页面。

2025 年最新 Fabric.js 实战:一个完整可上线的图片选区标注组件(含全部源码).

# 从 0 到 1 用最新 Fabric.js 实现:背景图 + 手动画矩形 + 坐标 + 删除 + 高清截图导出(2025 最新版)

最近项目要做一个「图片自动化清洗工具」,核心需求如下(产品甩给我的原话):

  1. 支持上传任意尺寸的广告图
  2. 运营要在图上框出需要保留的区域(可画多个矩形)
  3. 框出来的区域右上角必须有 × 可以删除
  4. 实时显示鼠标在原图上的精确坐标
  5. 最后要能把每个选区裁成独立图片 + 导出坐标数据给后端
  6. 必须有一键清空功能

我调研了 Konva、PixiJS、ZRender,最终还是选择了最成熟、最好用的 **Fabric.js**,并且使用最新版 v6.7.1+ 完美实现!

本文所有代码基于 Fabric.js 6.7+,完全适配 Vue3/React/原生JS,已在生产环境稳定运行。

一、Fabric.js 官网 & 安装方式(2025 最新)

二、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();
},

一、体验丝滑的绘制能力。

核心交互流程严格遵循经典三阶段模型:

  1. mouse:down → 开启绘制模式

    1. 调用 canvas.getPointer(e) 获取相对于画布的精准坐标(自动补偿缩放、平移、视口滚动)
    2. 初始化 isDrawing = true 状态标志
    3. 创建临时 fabric.Rect 实例(初始宽高为 0)并立即加入画布,确保后续 move 事件可实时更新
  2. mouse:move → 实时更新矩形尺寸与位置

    1. 动态计算宽度/高度取绝对值,支持四个方向拖拽
    2. 动态调整 left/top 为较小值,保证矩形左上角始终为起始点
    3. 每帧调用 canvas.requestRenderAll() 实现流畅预览
  3. mouse:up → 绘制完成 & 防御性收尾

    1. 自动过滤误触(宽或高 < 10px 的矩形直接丢弃)
    2. 为有效矩形添加自定义删除控件(controls.deleteBtn)
    3. 将矩形实例推入管理数组,便于后续批量操作与数据同步
    4. 触发外部回调 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();
  }
}

❌