Fabric.js 专栏 | 实战经验分享

1,656 阅读14分钟

阅前说明,本文所有分享仅能保证在fabricjs v5版本中生效。

Fabricjs进阶实战经验分享,如果你对实现“竖向文本”、”彩虹文本“、“保留切边”等特殊需求感兴趣,不妨进来瞧瞧,宝贵的实战经验,仅此一家,错过下次可能找不到了~

基础使用

1. fabric.Image 拓展,实现fabricjs画布中嵌套fabricjs画布

我们知道可以通过传入HTMLImageElement实例创建fabric.Image对象:

const source = new Image();
const image = new fabric.Image(source, { ... });

实际上,new fabric.Image(IMAGE_SOURCE)的第一个参数不仅可以是图像元素(HTMLImageElement),还可以是画布元素(HTMLCanvasElement),从这一点去拓展,我们可以实现一个更为有趣的效果——画布嵌套画布,代码如下:

 // 外面的fabric画布
const outerCanvas = canvas.getFabricCanvas()!;
// 里面的fabric画布
const innerCanvas = new fabric.StaticCanvas(null, {
    width: 100,
    height: 100
});
// 里面的画布添加红色矩形
const rect = new fabric.Rect({ width: 100, height: 100, fill: 'red' });
innerCanvas.add(rect);
innerCanvas.renderAll();
// 将里面的fabric画布转为fabric.Image对象加入外面的画布
const image = new fabric.Image(innerCanvas.getElement());
outerCanvas.add(image);
outerCanvas.renderAll();
// 3秒后将内部画布的矩形改为蓝色同步更新内外部画布
setTimeout(() => {
    rect.set({ fill: 'blue' });
    innerCanvas.renderAll();
    outerCanvas.renderAll();
}, 3000);

2. 自行实现快捷键事件时请别忽略fabricjs本身的快捷键设置,必要情况下请设置为none避免相互冲突

new fabric.Canvas(element, {
    uniScaleKey: 'none',
    altActionKey: 'none',
    selectionKey: 'none',
    altSelectionKey: 'none',
    centeredKey: 'none'
});

3. 如何正确让一个画布元素变静态?selectableeventable区别在哪?

对于一个元素,如果你不允许它被选中,你可能会为它设置selectable: false,但是此时如果你画布中点击它,你会发现触发的鼠标事件(mouse:down)中仍然会监听到event.target指向该元素对象,但是在某些复杂的场景下,你需要在触发的事件中执行逻辑同时不希望静态的元素也参与导致影响到其他元素正常交互,这时请再为它设置evented:false,这时它才是一张“真正”的静态图:

object.set({
    selectable: false, // 禁用选中
    evented: false,    // 禁用事件
});

4. backgroundImagebackgroundColor的区别?

  1. 可以同时设置,也会同时渲染,但backgroundImage渲染层级更高。

  2. backgroundColor不仅可以修改单一背景色,由于它可以配置Pattern图案参数,也可以实现背景图填充,比如通过backgroundColor配置灰白相间的透明格子背景:

image.png

// 创建透明格子背景
const createTransparentBackground = (size = 10) => {
    const halfSize = size / 2;
    const bgCanvas = document.createElement('canvas');
    bgCanvas.width = size;
    bgCanvas.height = size;
    const bgCanvasCtx = bgCanvas.getContext('2d');
    bgCanvasCtx.fillStyle = '#fff';
    bgCanvasCtx.fillRect(0, 0, bgCanvas.width, bgCanvas.height);
    bgCanvasCtx.fillStyle = '#eee';
    bgCanvasCtx.fillRect(0, 0, halfSize, halfSize);
    bgCanvasCtx.fillRect(halfSize, halfSize, halfSize, halfSize);
    return {
        source: bgCanvas.toDataURL(), // 当然此处也可以直接用图像链接
        repeat: 'repeat',
    };
};
await new Promise((resolve) => {
    fabricCanvas.setBackgroundColor(createTransparentBackground(), resolve);
});
fabricCanvas.requestRenderAll();

5. 如何克隆画布对象?Object.clonefabric.util.object.clone的区别?

如果代码上下文支持异步,使用Object.clone 可以很好实现元素克隆效果,可以克隆一个完全一样且独立的元素;如果代码上下文不支持异步,还想实现克隆,可以借助fabric.util.object.clone方法,但该方法只是元素的浅拷贝,如果元素过于复杂就不建议使用了,比如配置clipPath、成组元素等等,如果真的要使用,要进一步考虑内部引用类型值的克隆。

6. 如何获取元素的包围框位置和大小?

getBoundingRect可以用于获取元素的包围框位置和大小,但需要注意的是,组内元素的计算结果是相对于其组对象的。

7. 如何获取元素某个顶点的位置?比如左上顶点。

可以通过getPointByOrigin获取元素特定位置点的坐标,比如当你需要计算元素左上角位置时:

const coord = object.getPointByOrigin('left', 'top');

8. 如何将元素的某个顶点移动到特定位置?

可以通过setPositionByOrigin将元素的某个特定位置点移动到特定位置,比如当你需要将元素的左上角移动到坐标(100, 100):

object.setPositionByOrigin({ X: 100, Y: 100 }, 'left', 'top');

9. setCoords究竟是干嘛的?为何突然元素交互区域与实际显示的位置和尺寸对不上了?

在动态画布中,当你脚本修改元素的宽高、偏移、旋转等属性时,对象的控制点不会及时更新,如下面例子:

const canvas = document.getElementById("canvas");
const fabricCanvas = new fabric.Canvas(canvas);

// 初次创建的矩形没有设置宽高
const rect = new fabric.Rect({ fill: 'red' });
fabricCanvas.add(rect);

// 添加进画布后才记得补充宽高或者修改宽高 
rect.set({ width: 100, height: 100 });
fabricCanvas.renderAll();

上面例子创建后你会发现元素无法选中,造成这个问题的原因就是修改了元素属性后没有调用setCoords,导致元素交互区域与实际显示的位置和尺寸对不上,setCoords方法会更新对象的边界框坐标以及重新计算对象的控制点位置,确保对象的交互区域与实际显示位置保持一致。

至于该方法的调用时机,你只要记得当你发现你所修改的元素属性会导致元素的交互区域发生变化,调用它准没错,当然如果是静态画布,它没有控制器,也就没有调用该方法的必要了。

10. (性能优化)使用requestRenderAll替换renderAll,减少不必要的重新渲染

requestRenderAll基于window.requestAnimationFrame实现,在下一次浏览器渲染的时候执行画布渲染操作,而不是renderAll那样只要调用就立即执行,可以减少不必要的画布渲染,目前使用下来,交互画布的大部分场景都可以用requestRenderAll替代renderAll

canvas.renderAll();
// 下面性能更好,避免多余重复的渲染导致页面阻塞
canvas.renderOnAddRemove();

11. (性能优化)配置renderOnAddRemove,自行控制渲染时刻

fabricjsadd、remove、insertAt方法默认会触发requestRenderAll重新渲染画布,如果当前操作非常复杂,需要处理大批量的元素增删,你可能就需要自行控制重新渲染的时刻,这时可以做以下操作:

// 配置renderOnAddRemove为false,阻止add、remove、insertAt默认触发requestRenderAll
canvas.renderOnAddRemove = false;
canvas.add(A);
canvas.remove(A);
canvas.insertAt(A, 0);
...;
// 恢复renderOnAddRemove并手动触发渲染
canvas.renderOnAddRemove = true
canvas.requestRenderAll();

进阶使用

1. 如何通过脚本模拟鼠标的画布事件(点击/取消点击)?

有些交互画布的交互场景下,可能会出现一种特殊的需求,比如我正在拖动元素A,但这时候需要将拖动的目标突变为元素B,现实中你可能需要松开鼠标点击元素B,但有些场景你必须要保持连贯性,这时候你可能需要用脚本模拟鼠标点击交互控制点:

  1. 手动模拟触发fabric Canvas的鼠标mouse:down事件
import { fabric } from 'fabric';

/**
 * 手动模拟触发fabric Canvas的鼠标mouse:down事件
 *
 * @remarks 注意: 会触发画布mouse:down相关画布监听事件
 */
const fireFabricMouseDown = (canvas: fabric.Canvas, offset: { x: number; y: number }) => {
    const canvasElement = canvas.getSelectionElement();
    if (!canvasElement) return;

    const canvasElementBoundRect = canvasElement.getBoundingClientRect();
    const event = new MouseEvent('mousedown', {
        clientX: canvasElementBoundRect.left + offset.x,
        clientY: canvasElementBoundRect.top + offset.y,
    });

    canvasElement.dispatchEvent(event);
};
  1. 手动模拟触发fabric Canvas的鼠标mouse:up事件
import { fabric } from 'fabric';

/**
 * 手动模拟触发fabric Canvas的鼠标mouse:up事件
 *
 * @remarks 注意: 会触发画布mouse:up相关画布监听事件
 */
const fireFabricMouseUp = (canvas: fabric.Canvas) => {
    const canvasElement = canvas.getSelectionElement();
    if (!canvasElement) return;

    const event = new MouseEvent('mouseup', {
        view: window,
        bubbles: true,
        cancelable: true,
    });

    canvasElement.dispatchEvent(event);
};
  1. 手动模拟触发fabric Canvas的鼠标选择事件,以实现在选中操作对象时更改操作对象
import { fabric } from 'fabric';
import fireFabricMouseDown from './fire-fabric-mouse-down';
import fireFabricMouseUp from './fire-fabric-mouse-up';

/**
 * 手动模拟触发fabric Canvas的鼠标选择事件,以实现在选中操作对象时更改操作对象
 *
 * @remarks 请注意这会触发画布mouse:up/mouse:down等画布事件
 */
const fireMouseUpAndSelect = (object: fabric.Object) => {
    const canvas = object.canvas;
    if (!canvas) return;

    // 如果object是成组元素,需要更多处理,这里仅对非成组有效
    const objectBoundRect = object.getBoundingRect();
    fireFabricMouseUp(canvas);
    fireFabricMouseDown(canvas, {
        x: objectBoundRect.left + objectBoundRect.width / 2,
        y: objectBoundRect.top + objectBoundRect.height / 2,
    });
};

2. 如何通过脚本模拟元素的框选?

某些场景下你可能会需要过滤框选的元素,比如用户框选了10个元素,但是其中两个元素是不允许通过多选选中的,这时候你就需要通过脚本过滤元素并模拟多元素框选了。

// 先取消当前的元素选中
canvas.discardActiveObject();
// 过滤实际要框选的元素列表
const filterObjects = objects.filter(() => true);
// 脚本模拟框选
if (filterObjects.length === 1) canvas.setActiveObject(filterObjects[0]);
else if (filterObjects.length > 1) {
    const activeSelection = new fabric.ActiveSelection(filterObjects, {
        canvas: canvas,
        originX: 'center',
        originY: 'center'
    });
    canvas.setActiveObject(activeSelection);
}

3. 坐标计算太复杂怎么办?计算并非一定要靠自己算,可以巧用临时状态

如果遇到复杂的元素坐标计算,不妨想想,能不能应用临时的状态来辅助我们计算,举个例子,当前我们有个图像元素,我们需要计算它旋转n角度后的左上角坐标位置,如果你要自己算那是非常困难的,那为何不直接先让它临时旋转n角度后计算出位置再调整回来,相当就是把计算操作都交给了fabricjs来算:

const image = new fabric.Image(...);
// 暂存角度
const cacheAngle = image.angle;
// 临时旋转
image.set({ angle: n });
// 计算旋转后的左上角坐标
const coord = image.getPointByOrigin('left', 'top');
// 恢复角度
image.set({ angle: cacheAngle });

除了应用临时状态,还可应用临时的空白对象,如上面的例子,我们可以创建一个基础配置(偏移、宽高、缩放、旋转、镜像等)和图像元素一样的空白对象(fabric.Object),然后将要处理的操作执行在这个临时的空白对象上,再算出需要的值也是一种取巧的方法。

4. 如何计算成组后子元素相对于画布的变形数据?

元素成组后,其内部的参数都会变成相对于组的,即使是序列化后依然是相对参数,有些场景下我们需要将目标相对于画布的参数(偏移、缩放、旋转等)计算出来,可以通过下面的方法简单计算出元素相对于画布的变形数据:

// skipGroup参数如果改为true,将计算相对于组元素的偏移
fabric.util.qrDecompose(object.calcTransformMatrix(/* skipGroup */false))

虽然可以计算得到,但上面的计算结果有以下问题:

  1. 缩放值可能是负数,这意味着元素发生过镜像操作
  2. 计算出的偏移量始终是元素中心相对于画布的偏移,如果元素的原点不是中心,我们计算出来的偏移就会有问题

根据上面的问题,我们可以做以下调整:

/**
 * 获取对象相对于画布的变形数据
 * @param target 目标元素
 * @returns
 */
const getRelativeTransformData = (target: fabric.Object) => {
  const { width = 0, height = 0, originX = 'center', originY = 'center' } = target;
  let { angle, scaleX, scaleY, left = 0, top = 0, flipX, flipY } = target;

  // 如果是组内元素,直接获取的参数相对于组进行计算,这里需要将其转换为相对于画布
  if (target.group) {
    const decomposeMatrix = fabric.util.qrDecompose(target.calcTransformMatrix());
    angle = decomposeMatrix.angle;
    // scaleX/scaleY如果是负数,意味着元素发生了镜像变换
    flipX = decomposeMatrix.scaleX < 0;
    flipY = decomposeMatrix.scaleY < 0;
    // 上面转换后将缩放值改为绝对值
    scaleX = Math.abs(decomposeMatrix.scaleX);
    scaleY = Math.abs(decomposeMatrix.scaleY);
    // 这里的偏移是元素中心相对于画布左上的偏移值
    left = decomposeMatrix.translateX;
    top = decomposeMatrix.translateY;

    // 当元素的原点不是中心时,上面的偏移值就是错误的,所以需要进一步计算
    if (originX !== 'center' || originY !== 'center') {
      // 这里通过模拟一个临时矩形用于计算当前元素的偏移
      const scaleWidth = width * scaleX;
      const scaleHeight = height * scaleY;
      const rect = new fabric.Object({
        width,
        height,
        scaleX,
        scaleY,
        angle,
        flipX,
        flipY,
        left,
        top,
        originX: 'center',
        originY: 'center',
      });
      // 计算实际原点相对于中心原点的偏移距离
      const offset = rect.getPointByOrigin(originX, originY);
      left = offset.x;
      top = offset.y;
    }
  }

  return { scaleX, scaleY, left, top, angle, flipX, flipY };
};

5. 如何处理画布内的相对位置和画布文档节点上的偏移点的换算?

  1. 已知画布文档节点上的偏移点,求在fabricjs画布上的相对位置
 /**
* 计算画布文档节点上的偏移点在fabric画布上的相对位置
*
* @param  canvas - fabric画布
* @param  offset - 画布文档节点上的偏移点
* @returns 返回该偏移点在画布内的相对坐标点
*/
export const calcFabricCanvasCoord = (
  canvas: fabric.Canvas | fabric.StaticCanvas,
  offset: Coord,
) => {
  const matrix = canvas.viewportTransform ?? [1, 0, 0, 1, 0, 0];
  return fabric.util.transformPoint(
    new fabric.Point(offset.x, offset.y),
    fabric.util.invertTransform(matrix),
  );
};
  1. 已知fabricjs画布上的相对位置,求画布文档节点上的偏移点
/**
* 根据fabric画布上的相对位置计算出实际画布DOM节点上的偏移位置
*
* @param  canvas - fabric画布
* @param  coord - 画布内相对的点位置
* @returns 返回该点在实际画布DOM节点上的偏移位置
*/
export const calcRealCanvasOffset = (
    canvas: fabric.Canvas | fabric.StaticCanvas,
    coord: Coord,
) => {
  const matrix = canvas.viewportTransform ?? [1, 0, 0, 1, 0, 0];
  return fabric.util.transformPoint(new fabric.Point(coord.x, coord.y), matrix);
};

6. 如何处理被边界框裁切的画布元素?

一般情况下,将objectCaching设置为false可以使元素不使用缓存画布,直接绘制在画布上,这时可以解决被裁切问题,但如果元素配置clipPath,元素仍然会缓存于独立画布。如果要解决元素被缓存画布裁切的问题,也许只能直接处理缓存画布的大小了,以下是取巧的方式,会影响性能,请酌情使用:

// 改写fabric.Object内设置缓存画布的方法,使部分超出框的素材能够正常区域显示
const fabricObjectPrototype = fabric.Object.prototype as any;
const originFunc = fabricObjectPrototype._limitCacheSize;
fabricObjectPrototype._limitCacheSize = function (...args: any[]) {
    const dims = originFunc.bind(this)(...args);

    // 一定要标识真正需要处理的元素,不区分元素会严重影响性能
    if (this.name === '标识的元素') {
        // 处理缓存画布的宽高
        const { width = 0, height = 0 } = this; // 元素标识的宽高
        const realWidth = 1000;  // 实际图像宽度(包含超出边界部分)
        const realHeight = 1000; // 实际图像高度(包含超出边界部分)
        dims.width /= width / realWidth;
        dims.height /= height / realHeight;
    }

    return dims;
};

7. 如何解决路径文本出现文字偏移问题?

不知道你是否有遇到过路径文本字符偏移的问题,如下:

image.png

const canvas = document.getElementById("canvas");
const fabricCanvas = new fabric.Canvas(canvas);

// 创建一个路径文本
const pathText = new fabric.Text('X X X X X X X X X X X', {
    left: 30,
    top: 30,
    fontSize: 35,
        // 圆路径
        path: new fabric.Path('M50,0 C50,27.614 27.614,50 0,50 C-27.614,50 -50,27.614 -50,0 C-50,-27.614 -27.614,-50 0,-50 C27.614,-50 50,-27.614 50,0 Z', {
        fill: 'transparent',
    })
});

fabricCanvas.add(pathText);
fabricCanvas.renderAll();

这个问题出现的原因是因为闭合路径闭合处的绘制区域不够导致字符的旋转角度计算出现了问题,我们需要改写fabric自带的方法:

// 修复路径文本闭合路径下出现的字符偏移问题
const fabricUtil = fabric.util as any;
const origingGetPointOnPath = fabricUtil.getPointOnPath;
fabricUtil.getPointOnPath = function getPointOnPath(path, distance, infos) {
    let d = distance;
    let i = 0;
    let lastMDistance = 0;
    while (d - infos[i].length > 0 && i < infos.length - 2) {
        d -= infos[i].length;
        i++;
        if (infos[i].command === 'M') lastMDistance = 0;
        else lastMDistance += infos[i].length;
    }
    
    // 闭合路径所在字符绘制到起始位
    if (infos[i].command.toUpperCase() === 'Z' && infos[i].length === 0) {
        distance -= lastMDistance;
    }
    
    return origingGetPointOnPath.bind(this)(path, distance, infos);
};

8. 如何给文本元素内每个字符施加变形(旋转、偏移、缩放等)?如何实现竖向文本?

首先能支持该方案的只有路径文本,也即有配置path字段的文本,下面例子将文本内的所有字符都旋转了n度。

const originMeasureLine = object.__proto__._measureLine;
(object as any)._measureLine = (lineIndex: number) => {
    const result = originMeasureLine.bind(object)(lineIndex);
    object.__charBounds = object.__charBounds?.map((i) => {
        // 每个字符都旋转n度
        return i.map((i) => ({ ...i, angle: (n) * (Math.PI / 180) }));
    });
    return result;
};

通过这个方法便可以实现竖向文本:

image.png

const canvas = document.getElementById("canvas");
const fabricCanvas = new fabric.Canvas(canvas);

const text = new fabric.Text('竖向文本', {
    left: 100,
    top: 50,
    fontSize: 20,
    path: new fabric.Path('M 0 0 V 100')
});

// 文本内每个字符都重写为旋转0度,最后整体效果就是垂直文本
const originMeasureLine = text.__proto__._measureLine;
text._measureLine = (lineIndex) => {
    const result = originMeasureLine.bind(text)(lineIndex);
    text.__charBounds = text.__charBounds?.map((i) => {
        return i.map((i) => ({ ...i, angle: 0 }));
    });
    return result;
};

fabricCanvas.add(text);
fabricCanvas.requestRenderAll();

9. 如何实现彩虹文本,即文本内每个字符的颜色都不一样?

image.png

可以通过配置文本的styles字段实现该效果,具体实现逻辑可参考下方代码:

const colors = ['red', 'orange', 'yellow', 'green', 'blue']; // 循环颜色请自行配置
let lineIdx = 0;
let charIdx = 0;
let index = 0;
text.set({
    /**
     * 其返回数据结构如下示例,标记第一段文字的第一个字符的填充色为红色。
     * { 0: { 0: { fill: 'red'} }}
     * 除了fill,你还可以设置如 fontSize 字体尺寸 等其他配置
     */
    styles: text._text.reduce((result, char) => {
      if (char === '\n') {
        index = 0;
        // 打开注释则手动换行时会重置循环
        // charIdx = 0;
        lineIdx += 1;
      } else if (/\s/.test(char)) {
        index++;
      } else {
        result[lineIdx] = result[lineIdx] ?? {};
        result[lineIdx][index] = {
          fill: colors[charIdx % colors.length]
        };
        index++;
        charIdx++;
      }
      return result;
    }, {})
});
text.canvas.renderAll();

最后

上面分享的实战经验如果能够帮助到你,不妨留下你的点赞,让更多有需要的JYM看到;如果你遇到了难点,或者有更好的实现方法或见解也可以分享到评论区,感谢你们的阅读。

后续我将持续分享fabricjsCanvas相关文章,如果恰好你也对这方面感兴趣,关注我或者订阅此专栏,我将倾囊相授。

猛男击拳🤜🤛

开源:Fabric.js 图像扭曲工具

结合fabricjs快速搭建图像扭曲工具已开源,如果你感兴趣,可点击跳转:Fabric-Warpvas