fabric.js 实践

最近在使用 fabric.js 做 canvas 相关的开发,搜集了蛮多资料,但是有点分散,所以想整合一下,方便参考,下面也只是列举一些常用的 api,更详细的内容还是要参考官方文档(ps: 官方文档 不太友好 emmm…)

简单介绍

Fabric.js 是一个强大而简单的 canvas 库,提供了交互式对象模型、多种易用的 api 和 SVG 解析器等。比较明显的缺点是不支持 webGL,所以不太适合渲染巨量图形的场景,可以另外尝试 pixi.js。结合 typescript 开发的话,需要下载声明文件 @types/fabric

常用对象

  • Point
  • 线条 Line、多段线 Polyline
  • 矩形 Rect、多边形 Polygon
  • 圆形 Circle
  • 三角形 Triangle
  • 文本 Text(IText)
  • 图像 Image
  • 分组 Group

对象常用属性汇总

  • left 横坐标;top 纵坐标
  • width 宽度;height 高度
  • originX 对象转换的水平原点;originY 对象转换的垂直原点
  • scaleX 水平方向缩放倍数;scaleY 垂直方向缩放倍数
  • angle 偏转角度;snapAngle 设置对象在旋转时锁定的角度
  • stroke 图形线条颜色;strokeWidth 线条宽度
  • fill 对象的填充色;backgroundColor 对象的背景色
  • selectable 对象是否可选中
  • hasControls 值为 false 时无法对对象进行旋转和拉伸
  • lockRotation 是否禁止旋转对象
  • lockMovementXlockMovementY 是否禁止移动对象
  • lockScaleXlockScaleY 是否禁止缩放对象
  • fontFamily 字体;fontSize 字号
  • type 标记对象类型

基础使用

import { fabric } from 'fabric';
import { Canvas } from 'fabric/fabric-impl';
fabric.Object.prototype.originX = 'center';
fabric.Object.prototype.originY = 'center';
const CANVAS_ID = 'map-canvas';

class MyCanvas {
    initialize () {
      let myCanvas = new fabric.Canvas(CANVAS_ID);
      myCanvas.selection = false;
      myCanvas.setWidth(canvasWidth);
      myCanvas.setHeight(canvasHeight);
      // 鼠标事件监听
      canvas.on('mouse:wheel', (evt) => {
        // actionManager 是一个事件管理器,统筹所有的用户操作和事件订阅
        // rafThrottle 是节流函数
        rafThrottle(() = actionManager.handleMouseWheel(evt));
      });
      canvas.on('mouse:move', (evt) => {
        rafThrottle(() => actionManager.handleMouseMove(evt));
      });
      // 对象选中监听
      canvas.on('selection:created', (e: any) => {
        actionManager.handleObjectSelect(e);
      });
      canvas.on('selection:updated', (e: any) => {
        actionManager.handleObjectSelect(e);
      });
      canvas.on('selection:cleared', (e: any) => {
        actionManager.handleObjectUnSelect();
      });
      canvas.on('object:moving', (e: any) => {
        actionManager.handleObjectMove(e);
      });
      // 对象变动监听,包括位置、大小、角度等变化的监听
      canvas.on('object:modified', (e: any) => {
        actionManager.handleObjectModified(e);
      });
    }
}
// ...

入口组件初始化 MyCanvas

useEffect(() => {
  new MyCanvas().initialize()
}, [])
// ...
<canvas id={CANVAS_ID}>Map canvas</canvas>

常用方法

画布

import { fabric } from "fabric";
import { Canvas } from "fabric/fabric-impl";

//创建画布
const myCanvas = new fabric.Canvas("myCanvas");
// myCanvas.setBackgroundColor();
// myCanvas.setWidth();
// myCanvas.setHeight();
//标识画布中元素选中时,是否还按原有的层级位置展示
myCanvas.preserveObjectStacking = true;
// 重新渲染一遍画布,当画布中的对象有变更,在最后显示的时候,需要执行一次该操作
myCanvas.renderAll();
// 清除画布中所有对象:
myCanvas.clear();
// 只绘制视图区域的元素
myCanvas.skipOffscreen = true;

/**
 * 设置对象位置的基准参考位置为自身中心点
 */
fabric.Object.prototype.originX = "center";
fabric.Object.prototype.originY = "center";

/**
 * 设置元素选中框的样式
 */
// 边角节点大小
fabric.Object.prototype.cornerSize = 6;
// 边角节点背景透明 false
// fabric.Object.prototype.transparentCorners = false;
// 边框颜色
// fabric.Object.prototype.borderColor = '#ccc';
// 角节点内部颜色
// fabric.Object.prototype.cornerColor = '#fff';
// 角节点边框颜色
// fabric.Object.prototype.cornerStrokeColor = '#ccc';

对象

// 获得画布上的所有对象:
const items = myCanvas.getObjects();

// 设置画布中的对象的某个属性值,比如第 0 个对象的 id
const items = myCanvas.getObjects();
tems[0].id ="items_id0" 或 items[0].set("id","items_id0")

// 获得画布中对象的某个属性,比如 第0 个对象的 id
const items = myCanvas.getObjects();
items[0].id;
// or items[0].get("id");

// 对象按指定位置放置
const t = myCanvas.getActiveObject();
t.center();
t.centerH(); // 水平居中
t.centerV(); // 垂直居中

// 设置对象属性,如 rect.set({top: 50,left:100})
object.set()
// 缩放对象
object.scale()
// 旋转对象
object.rotate()
// 返回对象的坐标和边界信息
object.getBoundingRect()
// 当对象修改了坐标、长宽、缩放、角度、倾斜程度等可能改变对象位置的属性时需要通过该方法重新计算位置
object.setCoords()
// 检查对象是否与另一个对象相交
object.intersectsWithObject(other)

// 加载图片时图片缩放到指定的大小
fabric.Image.fromURL(image_src, function(img) {
    img.set({
        left:tmp_left,
        top:tmp_top,
        centeredScaling:true,
        cornerSize: 7,
        cornerColor: "#9cb8ee",
        transparentCorners: false,
    });
    img.scaleToWidth(image_width);
    img.scaleToHeight(image_height);
    myCanvas.add(img).setActiveObject(img);
});

活动对象

// 设置画布上的某个对象为活动对象
const items = myCanvas.getObjects();
myCanvas.setActiveObject(items[i]);

// 获得画布上的活动对象
myCanvas.getActiveObject();

// 取消画布中的所有对象的选中状态
myCanvas.discardActiveObject(); // 如果这样不生效,可以使用 myCanvas.discardActiveObject().renderAll();

// 清除画布中的活动对象:
const t = canvas.getActiveObject();
myCanvas.remove(t);

// 设置活动对象在画布中的层级
const t = canvas.getActiveObject();
myCanvas.sendBackwards(t); // 向下跳一层
myCanvas.sendToBack(t); // 向下跳底层:
myCanvas.bringForward(t); // 向上跳一层:
myCanvas.bringToFront(t); // 向上跳顶层:
// or
t.sendBackwards();
t.sendToBack();
t.bringForward();
t.bringToFront();

常用事件

// 选中事件
myCanvas.on("selection:created", function (options) {});
myCanvas.on("selection:updated", function (options) {});
// 取消选中
myCanvas.on("selection:cleared", function (options) {});

// 对象移动事件
myCanvas.on("object:moving", function (options) {});
// 对象旋转事件
myCanvas.on("object:rotating", function (options) {});
// 对象缩放事件
myCanvas.on("object:scaling", function (options) {});
// 对象变动监听,包括位置、大小、角度等变化的监听
myCanvas.on("object:modified", function (e) {});
// 点击事件监听
myCanvas.on("mouse:down", function (e) {});
myCanvas.on("mouse:up", function (e) {});
// 文本编辑事件
myCanvas.on("text:changed", function (options) {});

场景

总结一些常用场景的实现方法

移动画布

// ...
this.canvas.on("mouse:move", this.handleMouseMove.bind(this))
handleMouseMove(event) {
  const e = event.e as MouseEvent;
  // ...
  const delta = new fabric.Point(e.movementX, e.movementY);
  canvas.relativePan(delta);
}

缩放画布

/**
 * @description 从鼠标处缩放地图
 * @param {Canvas} canvas
 * @param {WheelEvent} e
 */
function zoomMapCenter(canvas: Canvas, e: WheelEvent) {
  let zoom = 1;
  // 控制缩放范围
  zoom *= 0.999 ** e.deltaY;
  if (zoom > 20) zoom = 20;
  if (zoom < 0.01) zoom = 0.01;

  canvas.zoomToPoint(
    {
      x: e.offsetX,
      y: e.offsetY,
    },
    canvas.getZoom() * zoom
  );
  // updateMapOffset 更新地图偏移值
  // adaptStrokeWidth 保持线条宽度
}

/**
 * @description 从中心点放大地图
 * @param {Canvas} canvas
 * @param {number} [zoom=1.1]
 */
function zoomInMap(canvas: Canvas, zoom: number = 1.1) {
  if (zoom > 20) zoom = 20;
  canvas.zoomToPoint(
    { x: (canvas.width ?? 0) / 2, y: (canvas.height ?? 0) / 2 },
    canvas.getZoom() * zoom
  );
  // updateMapOffset 更新地图偏移值
  // adaptStrokeWidth 保持线条宽度
}

/**
 * @description 从中心点缩小地图
 * @param {Canvas} canvas
 * @param {number} [zoom=0.9]
 */
function zoomOutMap(canvas: Canvas, zoom: number = 0.9) {
  if (zoom < 0.01) zoom = 0.01;
  canvas.zoomToPoint(
    { x: (canvas.width ?? 0) / 2, y: (canvas.height ?? 0) / 2 },
    canvas.getZoom() * zoom
  );
  // updateMapOffset 更新地图偏移值
  // adaptStrokeWidth 保持线条宽度
}

缩放时保持线条宽度

function adaptStrokeWidth(canvas: Canvas, width: number = 2) {
  let strokeWidth = width / canvas.getZoom();
  for (let object of canvas.getObjects()) {
    if (object.strokeWidth) {
      object.set("strokeWidth", strokeWidth);
      if (object.strokeDashArray && object.strokeDashArray.length > 0) {
        // 有虚线的话,还要做下调整
        object.set("strokeDashArray", [strokeWidth * 5, strokeWidth * 2]);
      }
    }
  }
}

操作后的鼠标定位

在对画布进行移动、缩放操作时,重新添加图形,会发现位置不准确的问题,其实就是偏移量的问题,因为画布尺寸并没有发生改变

1.首先要设置偏移量对象,记录最后一次的偏移量和缩放比例

const mapOffset = { x: 0, y: 0, zoom: 1 };

2.移动画布时累加偏移量

const mouseMoveEvent = (e) => {
  // ...
  mapOffset.x += e.e.movementX / mapOffset.zoom;
  mapOffset.y += e.e.movementY / mapOffset.zoom;
};

3.缩放画布时计算偏移量和保存最新的缩放系数

const mouseWheelEvent = (e) => {
  // ...
  if (zoom === mapOffset.zoom) return;
  const [x, y] = [e.offsetX, e.offsetY];
  // 计算缩放时产生的偏移量
  mapOffset.x += x / zoom - x / mapOffset.zoom;
  mapOffset.y += y / zoom - y / mapOffset.zoom;
  // 缩放操作
  const zoomPoint = new fabric.Point(x, y);
  myCanvas.zoomToPoint(zoomPoint, zoom);
  mapOffset.zoom = zoom;
};

接下来新增图形时,通过以下代码可以正确获取到当前节点在画布中的真实位置

// ...
const mouseClickEvent = (e) => {
// ...
  const [left, top] = [
    e.pointer.x / mapOffset.zoom - mapOffset.x
    e.pointer.y / mapOffset.zoom - mapOffset.y
  ];
}

划线

两点划线

这个两点划线的意思是先点击固定起点,另一边伺机而定。这个思路其实很简单,需要两个辅助变量 mouseFrommouseTo,和一个锁 isDrawingLine

  1. 触发第一次点击事件,赋值 mouseFrom,添加 Line
  2. 触发鼠标移动事件,赋值 mouseTo,然后修改 Line 的属性重绘画布,开锁
  3. 触发第二次点击事件,赋值 mouseTo,上锁

连续划线

其实思路和两点划线类似,只是将 Line 换成 Polyline,然后在鼠标移动过程中不断的重绘最后一段线,参考下面的代码:

// ...
object.set({
  points: [
    ...object.points.slice(0, -1),
    { x, y },
  ],
});
canvas.requestRenderAll();

Polyline 会有 bug,在缩放或平移时,如果 Polyline 对象超出可视范围时会导致路线消失。解决办法有两个

  • 【推荐】改用多段 Line 的连续划线思路。其实就是用一个二维数组表示路径,每个元素是一段 Line,再增加或修改路径中某段路线的时候就很方便,不需要全量重绘
  • 缩放或平移的时候去除 offscreen 限制(设置 screen.skipOffscreen = false),但是会导致缩放或平移卡顿

绘制带洞多边形

带洞多边形其实就是在 Polygon 的基础上支持在内部绘制多个 Polygon,可以扩展 Polygon 来实现一个基础对象

import { fabric } from 'fabric';
type TPos = { x: number; y: number; z?: number };
export class FreespacePolygon extends fabric.Polygon {
  fillRule: 'evenodd' | 'nonzero' = 'evenodd';
  // NOTE: 注意这里是点集的二维数组,第一个为外边框,其余都是内洞
  holes: TPos[][] = [];
  constructor(paths: TPos[][], options?: fabric.IPolylineOptions) {
    if (!paths) {
      return;
    }
    const pathPoints = paths.map((points) => {
      return points.map((point) => new fabric.Point(point.x, point.y));
    });
    const [outer, ...holes] = pathPoints;
    super(outer, options);
    this.holes = holes;
  }
  holesRender(ctx: CanvasRenderingContext2D) {
    const { x, y } = this.pathOffset;
    this.holes.forEach((hole) => {
      const len = hole.length;
      ctx.moveTo(hole[0].x - x, hole[0].y - y);
      for (let i = 0; i < len; i += 1) {
        const point = hole[i];
        ctx.lineTo(point.x - x, point.y - y);
      }
      ctx.closePath();
    });
  }
  _render(ctx: CanvasRenderingContext2D) {
    if (!(this as any).commonRender(ctx)) {
      return;
    }
    ctx.closePath();
    this.holesRender(ctx);
    this._renderPaintInOrder(ctx);
  }
}

除了传参不同,其余使用方式同 Polygon

绘制路线曲线

可以通过两种方式绘制曲线用以平滑路径

  • 贝塞尔曲线
  • Catmull-Rom
type TPos = { x: number, y: number, z?: number };
type TGeometry = {
  alt?: number,
  lat: number,
  lng: number,
};
class Vector {
  x = 0;
  y = 0;
  constructor(x: number, y: number) {
    this.x = x;
    this.y = y;
  }
}
/**
 * 计算曲线路径
 */
class CatmullRom {
  pointsNum = 15;
  interpolatedPosition(P0: TPos, P1: TPos, P2: TPos, P3: TPos, u: number) {
    const u3 = u * u * u;
    const u2 = u * u;
    const f1 = -0.5 * u3 + u2 - 0.5 * u;
    const f2 = 1.5 * u3 - 2.5 * u2 + 1.0;
    const f3 = -1.5 * u3 + 2.0 * u2 + 0.5 * u;
    const f4 = 0.5 * u3 - 0.5 * u2;
    const x = P0.x * f1 + P1.x * f2 + P2.x * f3 + P3.x * f4;
    const y = P0.y * f1 + P1.y * f2 + P2.y * f3 + P3.y * f4;
    return new Vector(x, y);
  }
  getPoints(controlPoints: TGeometry[]) {
    const points = [];
    const len = controlPoints.length;
    const head = {
      lng: 2 * controlPoints[0].lng - controlPoints[1].lng,
      lat: 2 * controlPoints[0].lat - controlPoints[1].lat,
    };
    const tail = {
      lng: 2 * controlPoints[len - 1].lng - controlPoints[len - 2].lng,
      lat: 2 * controlPoints[len - 1].lat - controlPoints[len - 2].lat,
    };
    let newControlPoints = controlPoints.slice(0);
    newControlPoints.splice(0, 0, head);
    newControlPoints.push(tail);
    for (let i = 0; i < newControlPoints.length - 3; i++) {
      const p0 = new Vector(newControlPoints[i].lng, newControlPoints[i].lat);
      const p1 = new Vector(
        newControlPoints[i + 1].lng,
        newControlPoints[i + 1].lat
      );
      const p2 = new Vector(
        newControlPoints[i + 2].lng,
        newControlPoints[i + 2].lat
      );
      const p3 = new Vector(
        newControlPoints[i + 3].lng,
        newControlPoints[i + 3].lat
      );
      const max = this.pointsNum;
      for (let j = 0; j <= max; j++) {
        const p = this.interpolatedPosition(p0, p1, p2, p3, j / max);
        points.push({ lng: p.x, lat: p.y });
      }
    }
    return points;
  }
}
const CATMULL = new CatmullRom();
export default CATMULL;

使用的话很方便,调用 getPoints,把路径点集传入,就可以得到转换后的曲线点集

const curvePoints = CATMULL.getPoints(points);
// 之后将 curvePoints 作为参数重新绘制路线即可

对象层叠

有时候会遇到多个对象层叠的情况,这个时候需要将点到的对象放到最上层便于操作

性能优化

内置优化

其实 Fabric.js 在 canvas 基础上已做了很多优化,比如 requestRenderAll、只渲染视图内的对象(skipOffscreen)、skipTargetFind、分层(上层负责响应交互事件,下层负责绘制画布)、批量绘制等,后续有时间可以研究研究源码,看看具体是怎么优化的。当绘制元素多了之后,就还需要考虑其他优化方向:

本地缓存

空间换时间,我实际遇到的项目是可以用 indexDB 缓存地图数据(较大的 json 对象),通过地图数据中的 md5 值来检查缓存的有效性。我也封装了一个工具 Hook,代码如下:

import { useRef, useState } from 'react';
import { ISOTimeStamp, TPlainObject } from '@/types';
import { reject } from 'lodash';
type TObjectStore = any;
const SIM_EDITOR_DB_NAME = 'editor_default_db';
const SIM_EDITOR_STORE_NAME = 'default';
/**
 * @description 创建数据库
 */
function initIndexDB(name: string = SIM_EDITOR_DB_NAME, versionNum = 1) {
  const request = window.indexedDB.open(name, versionNum);
  return request;
}
export interface IData {
  mapKey: string;
  md5Sum: string;
  data: TPlainObject | string;
  createTime: ISOTimeStamp;
}
export function useIndexDB({
  dbname = SIM_EDITOR_DB_NAME,
  storeName = SIM_EDITOR_STORE_NAME,
  index = 'key',
}) {
  const objectStoreRef = useRef<IDBObjectStore>();
  const request = initIndexDB(dbname);
  const [isUpdate, setIsUpdate] = useState<number>(0);
  request.onupgradeneeded = () => {
    // NOTE 首次创建和db version变化才会执行这个函数;这个场景暂时没必要维护版本
    const db = request.result;
    // 创建存储空间
    const objectStore = db.createObjectStore(storeName, {
      keyPath: 'key',
      autoIncrement: true,
    });
    // 创建索引
    objectStore.createIndex(index, index, { unique: true });
  };
  request.onerror = () => {
    console.error('indexDB init error');
  };
  // 重新获取数据
  function refresh() {
    setIsUpdate(isUpdate + 1);
  }
  /**
   * @description 查询全部数据
   */
  function findAll(): Promise<IData[] | null> {
    console.log('===查询全部数据');
    console.time('findAll');
    return new Promise((resolve, reject) => {
      initIndexDBStore()
        .then((objectStore) => {
          const getRequest = objectStore.getAll();
          getRequest.onsuccess = () => {
            if (getRequest.result) {
              console.timeEnd('findAll');
              resolve(getRequest.result);
            } else {
              console.log('not found');
              resolve(null);
            }
          };
          getRequest.onerror = (err: Error) => {
            console.error('getRequest error');
            reject(err);
          };
        })
        .catch((err) => {
          console.error('indexDB getAll error', err);
          reject('indexDB error');
        });
    });
  }
  /**
   * @description 创建事务,要通过事务操作数据库
   */
  function initIndexDBStore(): Promise<TObjectStore> {
    return new Promise((resolve, reject) => {
      const openRequest = window.indexedDB.open(dbname);
      openRequest.onsuccess = () => {
        const db = openRequest.result;
        const transaction = db.transaction(
          [SIM_EDITOR_STORE_NAME],
          'readwrite',
        );
        objectStoreRef.current = transaction.objectStore(SIM_EDITOR_STORE_NAME);
        // const index = objectStoreRef.current.index('key');
        resolve(objectStoreRef.current);
      };
      openRequest.onerror = (err) => {
        console.error('openRequest error');
        reject(err);
      };
    });
  }
  /**
   * @description 查询数据
   */
  function find(key: string): Promise<IData | null> {
    console.log('===find, key: ', key);
    console.time('find');
    return new Promise((resolve, reject) => {
      initIndexDBStore()
        .then((objectStore) => {
          const getRequest = objectStore.get(key);
          getRequest.onsuccess = () => {
            // console.log("===find getRequest: ", getRequest);
            if (getRequest.result) {
              console.timeEnd('find');
              resolve(getRequest.result);
            } else {
              console.log('not found');
              resolve(null);
            }
          };
          getRequest.onerror = (err: Error) => {
            console.error('getRequest error');
            reject(err);
          };
        })
        .catch((err) => {
          console.error('indexDB get error', err);
          reject('indexDB error');
        });
    });
  }
  /**
   * @description 新增数据
   */
  function add(
    key: string,
    md5Sum: string,
    data: TPlainObject | string,
    createTime: number,
  ) {
    return new Promise((resolve, reject) => {
      initIndexDBStore().then((objectStore) => {
        const addRequest = objectStore.add({ key, data, createTime, md5Sum });
        addRequest.onsuccess = (res: any) => {
          console.log('add success');
          refresh();
          resolve(res.result);
        };
        addRequest.onerror = (err: Error) => {
          console.error('addRequest error');
          reject(err);
        };
      });
    }).catch((err) => {
      console.error('indexDB add error', err);
      reject('indexDB error');
    });
  }
  /**
   * @description 删除数据
   */
  function remove(key: string, isRefresh = true) {
    return new Promise((resolve, reject) => {
      initIndexDBStore().then((objectStore) => {
        const deleteRequest = objectStore.delete(key);
        deleteRequest.onsuccess = (res: any) => {
          console.log('delete success');
          isRefresh && refresh();
          resolve(res.result);
        };
        deleteRequest.onerror = (err: Error) => {
          console.error('deleteRequest error');
          reject(err);
        };
      });
    }).catch((err) => {
      console.error('indexDB delete error', err);
      reject('indexDB error');
    });
  }
  /**
   * @description 删除全部数据
   */
  function removeAll() {
    return new Promise((resolve, reject) => {
      initIndexDBStore().then((objectStore) => {
        const clearRequest = objectStore.clear();
        clearRequest.onsuccess = (res: any) => {
          console.log(`clear success`);
          resolve(res.result); // should be undefined
        };
        clearRequest.onerror = (err: Error) => {
          console.error('clearRequest error');
          reject(err);
        };
      });
    }).catch((err) => {
      console.error('indexDB clear error', err);
      reject('indexDB error');
    });
  }
  /**
   * @description 清理过期数据
   */
  function clearExpirationJsons(jsons: any[]) {
    return Promise.all(jsons.map((json) => remove(json.key, false)));
  }
  return {
    indexdb: {
      findAll,
      find,
      remove,
      removeAll,
      add,
    },
  };
}

注意:IndexDB 在查询的时候会去读取整个对象,有一个解析耗时,比如如果只是检查是否失效,建议可以自己设计个关联对象,只保存 key 和 md5 来判定

高频操作优化

主要是做下函数节流

const lock: Record<string, boolean> = {};
export function rafThrottle(callback = (time: number) => {}, key = "default") {
  if (lock[key]) {
    return false;
  }
  lock[key] = true;
  window.requestAnimationFrame((time) => {
    lock[key] = false;
    callback(time);
  });
  return true;
}

其他优化

  • 异步绘制。分量绘制
  • 分片渲染。每次渲染间隔几毫秒,会增加总的渲染时长,但可以降低页面的卡顿感
  • 考虑临时分层。当我们在复杂的图层中拖动物体时,为了避免重绘该图层,可以将拖动的物体单独移到另外一层中渲染,当拖拽结束时再将物体移动回原来的图层
  • WebGL 实现 2D 渲染,可以考虑使用 pixijs

最后

持续更新 ing…

参考