Fabric.js 学习
入门指南
安装
根据项目需求,可通过以下几种方式引入 Fabric.js:
通过 CDN 使用 script 标签引入
在 HTML 页面中添加 script 标签,可从提供该服务的 CDN 获取,如 cdnjs、jsdelivr、unpkg 。
<script src="https://cdn.jsdelivr.net/npm/fabric@latest/dist/index.min.js"></script>
从 npm 安装
终端窗口
npm i --save fabric
浏览器支持情况
以下表格展示了哪些浏览器受支持。一般来说,你总能通过重新转译来支持旧版浏览器,但有些画布功能无法转译或使用垫片。
上下文 | 支持版本 | 备注 |
---|---|---|
Firefox | ✔️ | ≥ 58 |
Safari | ✔️ | ≥ 11 |
Opera | ✔️ | 基于 Chromium |
Chrome | ✔️ | ≥ 64 |
Edge | ✔️ | 基于 Chromium |
Edge 旧版 | ❌ | |
IE11 | ❌ | |
Node.js | ✔️ | ≥ 18 |
你的第一个应用程序
你的第一个基础应用程序
让我们看看你能编写的最简单的应用程序,即在浏览器中实现 “Hello world!” 的示例。
你至少需要导入 StaticCanvas
和 FabricText
类。
import { StaticCanvas, FabricText } from 'fabric'
然后实例化它们两个并将它们组合起来
const canvas = new StaticCanvas();
const helloWorld = new FabricText('Hello world!');
canvas.add(helloWorld);
canvas.centerObject(helloWorld);
然后你想用它下载你的 PNG 文件
const imageSrc = canvas.toDataURL();
// some download code down here
const a = document.createElement('a');
a.href = imageSrc;
a.download = 'image.png';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
核心概念
在使用 Fabric.js 时,有许多类和概念需要学习了解。以下是对 Fabric.js 主要组成部分的概述。
画布
Fabric.js 的主要容器是 StaticCavas
或简单称为 Canvas
的交互式版本。这是一个类,为你提供绘图的表面,还提供以下工具:
- 处理选择和对象交互
- 对对象堆栈重新排序
- 强制渲染
- 序列化和反序列化状态
- 将图形状态导出为 JSON、SVG 或图像
- 处理当前应用程序的视口
对象
对象是我们添加到 Canvas
或 StaticCanvas
上的项目。预构建的对象提供了一些基本形状和文本。这些对象中的每一个都代表一种视觉上不同的形状,可以添加到画布上并自由变换或编辑。
Path
Polyline
,Polygon
Rect
Circle
,Ellipse
FabricImage
FabricText
,IText
,Textbox
图案、渐变和阴影
除了用于表示形状/对象的类之外,还有一些较小的类,用于绘制对象的填充或描边。你不能将 Gradient
(渐变)或 Shadow
(阴影)添加到画布上,而是将它们设置为对象的属性以获得特定效果。
图像滤镜
FabricImage
类表示画布上的光栅图像。FabricImage
可以使用一个或多个滤镜进行过滤。滤镜是用 WEBGL 编写的小程序(可选 JavaScript 回退),它们通过改变图像像素值来获得特定效果。Fabric.js 支持许多预构建的滤镜,用于常见操作,还支持一个堆栈,可组合多个滤镜以创建特定效果。
交互
Fabric.js 提供了画布上对象之间的一些预构建交互。
- 选择
- 拖动
- 通过可定制的控件进行缩放、旋转、倾斜
- 擦除
选择
Fabric 开箱即支持以下选择模式:
- 单个对象选择
- 区域选择
- 多选
控件
对对象执行状态更改是通过其控件完成的。Fabric 提供了以下控件:
- 缩放
- 旋转
- 调整大小
- 扭曲
Control
类和 API 专门设计用于创建自定义控件以及在外观或功能上自定义现有控件。有关 Controls
的更多信息,请访问 此处 。
绘图与画笔
画布提供了一种嵌入式绘图模式,在这种模式下,鼠标移动事件会被转发到画笔类,该类负责创建一个表示画笔笔触的对象。绘图基于 Path
对象,或者基于一组圆形/矩形来表示喷雾效果。
可用的画笔:
CircleBrush
和SprayBrush
(喷雾示例)PencilBrush
(经典铅笔)PatternBrush
(填充有图案的铅笔刷)
事件
应用程序用户与开发者编写的代码之间的一些交互是通过事件来处理的。每当最终用户通过 Fabric.js 的嵌入式功能与画布进行交互时,你都会收到一个事件,用于处理诸如以下的低级事件:
- 鼠标按下/松开/移动
- 鼠标滚轮
- 鼠标进入/离开
- 拖放
虽然你也会得到一些高级事件,这些事件是基于标准鼠标事件构建的嵌入式用户体验的最终结果。
- 对象选择创建/销毁/更改
- 对象添加到画布/组中
- 对象从画布/组中移除
- 通过绘制创建对象
动画
Fabric.js 还支持一些基本的动画实用工具。你可以使用支持对象的动画库与 FabricJS 配合使用。你可以对对象位置、变换属性(如缩放、颜色或矩阵)进行动画处理。只要你能在一段时间内将状态从一个值更改为另一个值,就可以创建动画。
如果对于某些特定的动画效果有特定需求,Fabric.js 的动画实用工具较为基础,你最好搜索特定的库来实现。
导出
Fabric 支持导出为 JSON
和 SVG
格式。
JSON
JSON 导出用于保存和恢复画布上的视觉状态。每个 FabricJS 对象都配备了自己的 toObject
方法,该方法将输出一个简单的 JS 对象,可将其存储起来,并与 fromObject
配合使用,以获取相同类型的实例。此状态旨在恢复画布的视觉状态,而非诸如控件之类的功能。Fabric.js 假定自定义控件和自定义处理程序作为应用程序的一部分在代码库中设置,而不属于状态的一部分。
SVG
SVG 导出用于将视觉画布输出为矢量格式,可导入到其他软件中或进行打印。SVG 和 Canvas 有很多相似之处,但并不完全相同。因此,SVG 导入和 SVG 导出并非一一对应。例如,某些功能在 SVG 导出中受支持,但在 SVG 导入中不受支持,如 TSPAN 或图案。
配置默认属性
为对象配置默认控件
如果您正在寻找配置默认控件的方法,请查看此另一页面 配置控件。
Fabric.js 中的每个对象(矩形、路径、圆形……)都带有一系列状态属性,这些属性决定了对象的外观以及一些默认的交互行为。
您可以在实例化对象时配置这些值,或者您可能希望在项目级别设置某些属性,然后就不用再管它们了。您可能希望全局进行此操作,原因有以下几种:
- 您决定不使用缓存
- 您不喜欢对象周围 1 像素透明描边
- 您希望所有文本对象都以自定义字体开始
- 您可能希望按照建议将项目设置为
originX
和originY
均设为“center”来工作
大多数默认值都存储在一个名为 ownDefaults
的静态属性中。当构造实例时,这组属性会通过 Object.assign
分配给该实例。
例如,如果您想更改 FabricText
类的默认字体,可以这样做:
import { FabricText } from 'fabric';
FabricText.ownDefaults.fontFamily = 'Lobster';
这将使每个 FabricText
及其所有子类的默认字体系列都为 Lobster
,除非你在构造函数中另行指定。
new FabricText('Hello World!') // 将使用 Lobster 字体
new FabricText('Hello World!', { fontFamily: 'Arial' }) // 将使用 Arial 字体
请注意,在更改 FontFamily
之前,你需要先弄清楚它在哪里定义。在 FabricObject
上更改 FontFamily
不会有任何效果,因为 FabricText
有自己的默认值,而在 IText
或 Textbox
上设置它,会使 FabricText
保持原始默认值。
一般来说,你应该一次性更改自己的默认值,以此来配置 Fabric.js 以适应你的应用程序需求。配置默认值而不是在每个对象实例上设置每个选项,这将减少代码中的混乱,但从性能角度来看,并不会更快或更好。
实例上有一个名为 getDefaults()
的方法。该方法将返回一个对象,该对象会显示该实例的默认值是什么。
此方法在 toObject
导出中使用,可选择性地避免导出默认值,以减小输出的大小。
关于原型的说明
与 Fabric.js 5 及更早版本不同,这不会影响已经构造的实例。
Javascript 类语法不支持 Fabric.js 以前使用对象时所依赖的原型上的属性。
如果你真的喜欢旧的行为,仍然可以通过编写一些代码来切换回旧行为。
以下是 FabricObject
的一个示例:
import { FabricObject } from 'fabric';
Object.assign(FabricObject.prototype, FabricObject.ownDefaults);
FabricObject.ownDefaults = {};
请注意,你确实需要有充分的理由才这么做。有一些优点,例如,作为对象或数组的属性不会在每个实例中重复,而是在所有实例之间共享。另一方面,这会产生意外的变异副作用,而你可能不希望在应用程序中出现这种情况。FabricJS 的默认设置更安全,同时也考虑到了可覆盖性。
配置控件
配置控制手柄
Fabric.js 对象的控件有一个默认配置,该配置由控制类和对象的默认值定义。这里列出的属性 对象属性 以及这里的 边框属性 会影响控件:
/**
* 对象控制角的大小(以像素为单位)
* @类型 Number
* @默认值 13
*/
cornerSize: number;
/**
* 检测到触摸交互时对象控制角的大小
* @类型 Number
* @默认值 24
*/
touchCornerSize: number;
/**
* 为 true 时,对象的控制角内部渲染为透明(即描边而非填充)
* @类型 Boolean
* @默认值 true
*/
transparentCorners: boolean;
/**
* 对象控制角的颜色(处于活动状态时)
* @类型 String
* @默认值 rgb(178,204,255)
*/
cornerColor: string;
/**
* 对象控制角的颜色(处于活动状态且 transparentCorners 为 false 时)
* @自 1.6.2 起
* @类型 String
* @默认值 ''
*/
cornerStrokeColor: string;
/**
* 指定控制样式,'rect' 或 'circle'
* 此属性已弃用。将来会有标准的控制渲染
* 并且你可以使用控制 API 提供的替代方案之一进行替换
* @自 1.6.2 起
* @类型'rect' | 'circle'
* @默认值'rect'
* @已弃用
*/
cornerStyle:'rect' | 'circle';
/**
* 指定对象控制的虚线模式的数组(hasBorder 必须为 true)
* @自 1.6.2 起
* @类型 Array | null
* @默认值 null
*/
cornerDashArray: number[] | null;
/**
* 对象与其控制边框之间的间距(以像素为单位)
* @类型 Number
* @默认值 0
*/
padding: number;
/**
* 对象控制边框的颜色(处于活动状态时)
* @类型 String
* @默认值 rgb(178,204,255)
*/
borderColor: string;
/**
* 指定对象边框的虚线模式的数组(hasBorder 必须为 true)
* @自 1.6.2 起
* @类型 Array | null
* 默认值 null;
*/
borderDashArray: number[] | null;
/**
* 设置为 `false` 时,不渲染对象的控制边框
* @类型 Boolean
* @默认值
*/
hasBorders: boolean;
/**
* 对象处于活动状态并移动时,其控制边框的不透明度
* @类型 Number
* @默认值 0.4
*/
borderOpacityWhenMoving: number;
/**
* 对象控制边框的缩放因子
* 数字越大,边框越粗
* 边框默认为 1,所以这基本上就是边框厚度
* 因为无法直接更改边框本身。
* @类型 Number
* @默认值 1
*/
borderScaleFactor: number;
这些属性具有默认值,使得控件看起来像这样:
现在让我们更改其中一些基本属性。在下面的示例中注释并更改属性,以查看效果:
const canvas = new fabric.Canvas(canvasEl);
const text = new fabric.FabricText('Fabric.JS', {
cornerStyle: 'round',
cornerStrokeColor: 'blue',
cornerColor: 'lightblue',
cornerStyle: 'circle',
padding: 10,
transparentCorners: false,
cornerDashArray: [2, 2],
borderColor: 'orange',
borderDashArray: [3, 1, 3],
borderScaleFactor: 2,
});
canvas.add(text);
canvas.centerObject(text);
canvas.setActiveObject(text);
为每个对象配置控制默认值
现在,为每个对象都这样做,需要在创建每个对象时都传递这些选项,你可以创建一个函数来实现,或者更改 Fabric.js 的默认值。
const canvas = new fabric.Canvas(canvasEl);
fabric.InteractiveFabricObject.ownDefaults = {
...fabric.InteractiveFabricObject.ownDefaults,
cornerStyle: 'round',
cornerStrokeColor: 'blue',
cornerColor: 'lightblue',
cornerStyle: 'circle',
padding: 10,
transparentCorners: false,
cornerDashArray: [2, 2],
borderColor: 'orange',
borderDashArray: [3, 1, 3],
borderScaleFactor: 2,
}
const text = new fabric.FabricText('Fabric.JS');
const rect = new fabric.Rect({ width: 100, height: 100, fill: 'green' });
canvas.add(text, rect);
canvas.centerObject(text);
canvas.setActiveObject(text);
为不同对象配置默认控件集
现在,如果你必须为对象添加额外或不同的控件,你知道可以将一个控件附加到 controls 对象上。对象上的 controls 对象由构造函数创建,并且每个实例都不同,这避免了意外的变异副作用。
const canvas = new fabric.Canvas(canvasEl);
const text = new fabric.FabricText('Fabric.JS', { controls: {
...fabric.FabricText.createControls().controls,
mySpecialControl: new fabric.Control({
x: -0.5,
y: 0.25,
}),
} });
const rect = new fabric.Rect({ width: 100, height: 100, fill: 'green', controls: {
...fabric.FabricText.createControls().controls,
mySpecialControl: new fabric.Control({
x: 0.5,
y: -0.25,
}),
} });
canvas.add(text, rect);
canvas.centerObject(text);
canvas.setActiveObject(text);
如果这不合你心意,你也可以直接更改 createControls
静态函数的输出:
const canvas = new fabric.Canvas(canvasEl);
fabric.Textbox.createControls = () => {
const controls = fabric.controlsUtils.createTextboxDefaultControls();
delete controls.mtr;
return {
controls: {
...controls,
mySpecialControl: new fabric.Control({
x: -0.5,
y: 0.25,
}),
}
}
}
const text = new fabric.Textbox('Fabric.JS');
const text2 = new fabric.Textbox('Fabric.JS');
canvas.add(text, text2);
canvas.centerObject(text);
canvas.setActiveObject(text);
这种设置仍然为每个对象提供单独的控件对象,以避免变异副作用。如果你正在寻找一种设置后就不用管的设置方式,上述示例可能是最佳方法。
如果你有需要,可以在共享和副作用方面进行更深入的探索。如果你希望在实例之间共享控件,就必须再次对默认值进行操作。这将使你能够为所有类类型一次性配置控件,并在运行时全局添加控件。
每种设置都有其优缺点,这取决于你的个人偏好和项目的功能。对于此代码片段,你必须按下 “runMe” 按钮。一旦运行,上述代码片段也会受到影响。
const canvas = new fabric.Canvas(canvasEl);
// 停用构造函数控制赋值
fabric.InteractiveFabricObject.createControls = () => {
return {};
}
const controls = fabric.controlsUtils.createObjectDefaultControls();
delete controls.mtr;
fabric.InteractiveFabricObject.ownDefaults.controls = {
...controls,
mySpecialControl: new fabric.Control({
x: -0.5,
y: 0.25,
}),
}
const rect = new fabric.Rect({ width: 100, height: 100, fill: 'green' });
const rect2 = new fabric.Rect({ width: 100, height: 100, fill: 'orange', top: 100, left: 200 });
// 在实例创建后根据默认值动态添加控件,这将对每个对象产生影响
fabric.InteractiveFabricObject.ownDefaults.controls.myOtherControls = new fabric.Control({
x: 0.5,
y: -0.25,
});
canvas.add(rect, rect2);
canvas.centerObject(rect);
canvas.setActiveObject(rect);
你可以在所有对象上全局添加和删除控件,这意味着每个对象共享相同的控件集。目前如果不完全更换控件集,就无法单独编辑某个对象。你可以准备好预制的控件集,根据需要进行切换。
你也可以创建完全自定义的控件,更多信息请查看此处的示例:
处理事件
为什么要使用事件
Fabric.js 提供了硬编码的交互,这些交互由用户与画布的交互触发。在这些操作过程中,开发人员可能希望做出反应或运行额外的代码,甚至只是更新应用程序的状态。
简单的例子有:
- 当一个对象被选中时
- 当一个对象被悬停时
- 当一个变换完成时
早在引入自定义控件 API 和选择回调之前,事件就一直是 Fabric.js 的一部分。因此,事件也被用于修改 Fabric.js 的标准行为,从而创建复杂的代码流程。
何时使用事件
尽量不要过度依赖事件,除了鼠标和选择相关操作外,这并非一种很好的模式。我加入项目时,事件机制就已存在,随着时间推移不断有新增内容,但从未有过清理。不妨这样想:在 Fabric.sj 中,事件旨在提醒你发生了一些因非你编写的代码而无法触及的事情。事件并非用于激活或连接应用程序功能的消息系统。
我在决定是否使用某个事件时,总会想到一个例子,即 object:added
事件。当一个对象被添加到画布或添加到一个组中时,object:added
事件就会触发。例如,假设当一个新对象被添加到画布上时,你希望它居中并被选中。
canvas.on('object:added', function(e) {
canvas.center(e.target);
canvas.setActiveObject(e.target);
});
现在,在应用程序中任何添加对象的部分,事件都会触发,这段代码也会运行。这看起来没什么问题,但另一方面,如果你认为为了添加一个对象,你需要编写相应代码才能实现,那么你也可以创建一个函数来添加对象、使其居中并选中它,然后调用这个函数,而不是调用 canvas.add
。
一般来说,如果你需要为某件事编写代码才能使其发生,那么来自这件事的事件就是不必要的。
自定义控件 API 也使得大多数变换事件变得不必要。如果你必须限制缩放或旋转,在控件操作中进行限制会比在事件中事后纠正行为更容易且性能更好。变换事件是在变换已经发生之后才运行的。
围绕选择操作的事件有助于通知你的 UI 框架有事情发生,但例如,如果你想基于某些条件避免选择某个对象,最好使用对象上的 onSelect
和 onDeselect
回调函数来影响操作。
事件如何工作
BaseFabricObject
类和 StaticCanvas
类都继承自 Observable
类。这个类公开了 4 个方法:on
、off
、once
和 fire
。
on
、off
、once
用于注册和注销事件监听器:
const handler = function() {
console.log('circle has been clicked');
}
// to register an event listener
const disposer = myCircle.on('mousedown', handler);
// to unregister an event listener call off
myCircle.off('mousedown', handler);
// or use the return value of on
dispose();
// 感觉应该是 disposer.dispose();
注意:off
方法可以只传入事件名称来调用,在这种情况下,该实例上该事件的所有监听器都将被移除。这很不好,因为 fabric.js 没有受保护的事件,并且它将事件用于一些功能。例如,从文本实例中移除所有 mousedown
事件会破坏文本编辑功能。这种模式以及更极端的 myObject.off()
模式已被弃用,应避免使用。
释放器是一个函数,调用它时会移除事件监听器,与保留对 on
中使用的处理函数的引用相比,这样使用可能更方便。
处理函数在实例上被绑定调用,因此在处理函数中,this
要么是 fabric 对象,要么是 fabric 画布。箭头函数不能绑定到除创建它们的上下文之外的任何其他内容,所以如果你不希望出现意外的副作用,请使用函数作为事件处理函数。
Fabric.js 调用 fire
来触发事件监听器,然后按注册顺序在实例上绑定调用托管的监听器,并将一个数据对象作为第一个且唯一的参数。
事件会被触发两次,一次在涉及的对象上,一次在画布上,这在编写事件监听器时提供了一些灵活性。触发顺序取决于特定事件的实现,并非需要考虑的规范。虽然事件触发两次时是相同的,使用相同的数据对象。但画布事件会增加一个额外的属性,该属性引用事件的 target
。
当事件由鼠标操作触发时,鼠标或指针事件会与通常对使用者有用的通用属性一起随数据转发。例如,mouse:down
事件将包含:
e
:原始鼠标事件scenePoint
:事件在画布坐标系中发生的点viewportPoint
:事件在视口坐标系中发生的点target
:被事件命中的对象,可能为未定义subTargets
:被事件命中的对象数组,如果目标对象有子对象,该数组可能为空transform
:当前正在进行的变换操作(如果有,例如缩放)
事件列表
目前没有一份经过人工精心整理的事件列表,其中包含描述以及通过参数传递的数据。了解这些事件的一种方法是查看此处的事件演示:事件检查器,或者使用支持类型提示的集成开发环境(IDE),查看 on
方法的自动补全内容。
自定义事件
你可以通过使用 fire
方法并传入事件名称以及所需的数据(或者完全不传入数据)来触发自定义事件。
为了使你的代码具有类型提示,你还可以扩展事件列表:
// declare the events for typescript
declare module "fabric" {
interface CanvasEvents {
'custom:event': Partial<TEvent> & {
target: FabricObject;
anydata: string;
};
}
}
对象与自定义属性
自定义属性
在构建应用程序时,你可能需要为对象附加一些自定义属性。一个非常常见的需求是为对象添加 id
或 name
。
如果你使用的是 TypeScript,或者希望 IDE 提供自动补全功能,那么你需要明确声明这些属性。
除此之外,还有序列化问题,这就要求你在 toObject
函数的参数中传递这些属性。
// 无正确类型标注的示例:
(myRect as any).name = 'rectangle';
myRect.toObject(['name', 'id']);
为了使代码更美观,你必须使用 TypeScript 的接口特性以及对象类中的自定义属性钩子。
import { FabricObject } from 'fabric';
declare module "fabric" {
// 使这些属性在实例和构造函数中都能被识别
interface FabricObject {
id?: string;
name?: string;
}
// 使导出对象中输入的属性具有类型
interface SerializedObjectProps {
id?: string;
name?: string;
}
}
// 实际上要将属性添加到序列化对象中
FabricObject.customProperties = ['name', 'id'];
This change will make the types work correcty:
自定义方法
一般来说,如果你能坚持使用外部实用函数,事情会变得更简单,但在你想将特定方法附加到不同类的原型上的情况下,你必须再次修改接口:
// 为 TypeScript 声明方法
declare module "fabric" {
// 使属性在实例和构造函数中都能被识别
interface Rect {
getText: () => string;
}
// 使属性在导出的对象中具有类型
interface Text {
getText: () => string;
}
}
// 然后将方法添加到类中:
Rect.prototype.getText = function() { return 'none'; }
Text.prototype.getText = function() { return this.text; }
自定义事件
如果您想触发或了解自定义事件,请查看此处:事件。
继承
继承更容易,但并非总是可行。如果您想继承像 Rect
、Textbox
、IText
、Path
这样的叶节点,这是可行且容易的。
import { classRegistry, SerializedPathProps } from 'fabric';
interface UniquePathPlusProps {
id?: string;
name?: string;
}
export interface SerializedPathPlusProps
extends SerializedPathProps,
UniquePathPlusProps {}
export interface PathPlusProps extends SerializedPathProps, UniqueRectProps {}
export class PathPlus<
Props extends TOptions<PathPlusProps> = Partial<PathPlusProps>,
SProps extends SerializedPathPlusProps = SerializedPathPlusProps,
EventSpec extends ObjectEvents = ObjectEvents,
> extend Path<Props, SProps, EventSpec> {
static type: 'path' // if you want it to override Path completely
declare id?: string;
declare name?: string;
toObject<
T extends Omit<Props & TClassProperties<this>, keyof SProps>,
K extends keyof T = never,
>(propertiesToInclude: K[] = []): Pick<T, K> & SProps {
return super.toObject([...propertiesToInclude, 'id', 'name']);
}
}
// to make possible restoring from serialization
classRegistry.setClass(PathPlus, 'path');
// to make PathPlus connected to svg Path element
classRegistry.setSVGClass(PathPlus, 'path');
但你不能对 FabricObject
进行子类化,然后再将其添加回其他对象的原型链中。
警告
在渲染或事件处理过程中,当自定义属性能让你的工作更简便时,你才应该添加它们。一般来说,画布上的 Fabric.js 类/对象不应包含与其渲染需求或行为配置无关的数据,它们不应成为你应用程序的数据存储库。
对象、属性和行为的缓存
Fabric.js 中的缓存
当开启 fabric 对象缓存时,你在画布上绘制的对象实际上会预先绘制在另一个较小的画布上,该画布位于 DOM 之外,大小与对象本身的像素尺寸相同。在 render
方法执行期间,这个预先绘制的画布会通过 drawImage
操作复制到主画布上。
这意味着在 drag
(拖动)、rotate
(旋转)、skew
(倾斜)、scale
(缩放)操作期间,对象不会在画布上重新绘制,而只是将其复制的缓存图像绘制在画布上。
一般来说,fabric.canvas
上的每个顶级对象最终都会在其自己对应的图像上,该图像存储在一个 HTMLCanvas 上,其引用保存在属性 _cacheCanvas
中。每个渲染周期,代码都会检查这个对象是否已脏(即是否需要更新),如果是,则刷新副本,然后再次使用它进行绘制。
在 Fabric.js 中,缓存策略非常简单,对象上或顶级配置中有几个属性可以对其进行微调。但实际上,这个过程存在很多限制和性能陷阱。请务必阅读整个页面以了解基础知识。
调整与配置
对象配置
Fabric.js 对象有一些顶级属性来处理缓存。
myObject.objectCaching = true;
objectCaching
属性是每个对象缓存的主要开关。当该属性为 false
时,缓存将被跳过,除非由于其他限制而需要缓存(稍后会详细介绍)。
myObject.noScaleCache = true;
noScaleCache
是一个布尔值,它将在使用鼠标进行缩放操作时停止缓存失效。在缩放操作结束时,对象将被重新绘制,并为该缩放值创建一个新的缓存。
同时,请注意 noScaleCache
为 true
或 false
时缩放的差异。
在下面的画布中,左侧画布的 noScaleCache
为 true
,这意味着在缩放变换期间,对象不会重新生成。右侧画布的缩放会在每次缩放更改时使缓存失效。你还可以打开开发者工具,记录两种情况下的缩放性能并进行比较。请注意,下面的黄色小汽车是一个由 417 个内部对象和渐变组成的大型复杂对象。如果你将对象缩放超过原始大小的 3 倍,你会注意到模糊现象,一旦你松开鼠标,新的缓存副本就会修复该问题。你可以自己尝试在两个画布上缩放黄色小汽车:
myObject.dirty = true;
此布尔值将缓存状态标记为陈旧/无效/脏,并将导致在下一个渲染周期进行重新渲染。截至目前,还没有一种方法可以强制命令式地刷新缓存。
Fabric.js 会在可能导致缓存失效的操作(例如文本输入)期间设置脏标志。
类配置
除了那些每个实例的属性外,还有一些是类级别的属性。每个类都有一个静态数组 cacheProperties
,如下所示:
static cacheProperties = [
'fill',
'stroke',
'strokeWidth',
'strokeDashArray',
'width',
'height',
'paintFirst',
'strokeUniform',
'strokeLineCap',
'strokeDashOffset',
'strokeLineJoin',
'strokeMiterLimit',
'backgroundColor',
'clipPath',
];
作为静态成员,你会在类定义中找到这个数组,如下所示:
import { Path } from 'fabric';
console.log(Path.cacheProperties);
// outputs
['fill', 'stroke', 'strokeWidth', ...]
这个数组用于 set
方法,以检查缓存失效情况。当你调用:
myObject.set({ fill: 'red', name: 'John' });
键 fill
和 name
都会在数组中进行检查,如果找到其中一个,对象的状态就会被设置为“脏”。建议始终使用 set
方法,无论是使用对象签名,还是在想要更改对象属性时使用更简单的形式,以避免缓存过期。
如果你正在创建一个自定义类,并且有一些会影响渲染的属性,你应该在新类的 cacheProperties
中添加基类的所有属性,并加上你自己的属性。
当在组内的对象中设置缓存属性时,该对象的所有父级对象也会失效。
Fabric.js 配置
为了在大多数时候都能提供一定程度的便利,缓存存在一系列权衡。例如,缓存的画布不能太大,否则刷新速度会过慢;也不能太小,否则无法触发浏览器中的某些 GPU 优化路径(随着浏览器的变化,此信息可能已过时)。
在 Fabric.js 配置对象中,有三个与缓存相关的值:
/**
* 缓存画布的像素限制。100 万像素、400 万像素应该都没问题。
* @since 1.7.14
* @type Number
* @default
*/
perfLimitSizeTotal = 2097152;
/**
* 缓存画布宽度或高度的像素限制。历史原因是 IE11,所以这个数字低于 5000。
* @since 1.7.14
* @type Number
* @default
*/
maxCacheSideLimit = 4096;
/**
* 缓存画布的最低像素限制,设置为 256 像素。
* @since 1.7.14
* @type Number
* @default
*/
minCacheSideLimit = 256;
minCacheSideLimit
将决定缓存一边的最小尺寸。无论对象大小如何,缓存画布至少为 256×256 像素。如果你要缓存一个 1×1 的矩形,你将得到一个 256×156 的画布,其中只有一个像素被填充。
maxCacheSizeLimit
和 perfLimitSizeTotal
决定了缓存画布的最大边长和最大面积。因此,边长设置为 4000 像素,perfLimitSizeTotal
约为 200 万像素,如果你的对象是 5000×10000,它将被缓存为 4096×(2097152÷4096) 的大小。最大边将减少到 4096,另一边将在 200 万像素允许的范围内适配。
你可以通过以下方式更改这些值,以试验哪种质量和速度最适合你的应用程序:
import { config } from 'fabric';
config.perfLimitSizeTotal = 4096 * 1024;
config.maxCacheSideLimit = 8192;
请继续阅读有关各种注意事项,如移动设备性能、导出时的输出质量等内容。
缓存与渲染循环
这一小节将尝试阐释 Fabric.js 中处理缓存的当前逻辑。
当执行 Canvas.renderAll
时,该库会遍历画布上的每个顶层对象,即所有通过 canvas.add
添加到画布上的对象。对于每个对象,代码会检查该对象是否应该缓存。这个 shouldCache()
检查并不像我们期望的那样直接明了:
/**
* 决定对象是否应该缓存。创建其自身的缓存级别
* objectCaching 是一个全局标志,优先级高于其他所有条件
* 当对象的绘制方法需要一个缓存步骤时,应使用 needsItsOwnCache。Fabric 的类都不需要它。
* 一般来说,在组中的对象不进行缓存,因为组外部会被缓存。
* 解读为:如果有需要,或者该功能已启用但我们尚未进行缓存,则进行缓存。
* @return {Boolean}
*/
shouldCache() {
this.ownCaching = (this.objectCaching && (!this.parent ||!this.parent.isOnACache())) ||
this.needsItsOwnCache();
return this.ownCaching;
}
/**
* 当返回`true`时,即使对象在组内,也强制其拥有自身的缓存
* 当你的对象在缓存方面有特殊行为,并且始终需要其自身独立的画布才能正确渲染时,可能需要这样做。
* 创建此方法是为了可被重写
* 自 1.7.12 起
* @returns Boolean
*/
needsItsOwnCache() {
if (
this.paintFirst === STROKE &&
this.hasFill() &&
this.hasStroke() &&
!!this.shadow
) {
return true;
}
if (this.clipPath) {
return true;
}
return false;
}
/**
* 检查此对象是否会以偏移量投射阴影。
* Group.shouldCache 使用此方法递归地判断子对象是否有阴影
* @return {Boolean}
* @已弃用
*/
willDrawShadow() {
return (
!!this.shadow && (this.shadow.offsetX!== 0 || this.shadow.offsetY!== 0)
);
}
/**
* 递归向上检查实例或其组是否正在缓存
* @return {Boolean}
*/
isOnACache(): boolean {
return this.ownCaching || (!!this.parent && this.parent.isOnACache());
}
我们这样理解:如果 objectCaching
为真,我们就会进行缓存,除非我们有一个已经在缓存中的父级。无论是否需要缓存,反正我们都会进行缓存。那我们什么时候需要缓存呢?当存在 clipPath
时,或者如果我们有阴影并且我们先绘制描边时。
所以对于组外的普通对象,归结起来就是启用缓存。如果 paintFirst
设置为 stroke
(默认是 fill
),我们就需要缓存,以避免填充的绘制在描边上产生阴影。
当一个对象被缓存时,绘制的副本是不带有不透明度或阴影的。然后,生成的画布会与正确的不透明度进行混合,并在每次绘制时实时投射阴影。这样做的原因是,画布中的阴影无论旋转如何都会保持方向。一个向东南方向投射阴影的对象,在旋转后仍会向该方向投射阴影。如果我们想实现这个效果,就不能缓存阴影。关于不透明度,原因在于半透明的缓存仍然需要与背景混合,所以缓存或不缓存不透明度并不会带来任何速度优势,另一方面,不缓存不透明度可以让我们在用户更改对象的不透明度时重用缓存。
现在,当缓存一个组时,情况就变得更加复杂了。
/**
* 决定该组是否应该缓存。创建其自身的缓存级别
* 当对象绘制方法需要缓存步骤时,应使用 needsItsOwnCache。
* 一般来说,在组中不缓存对象,因为组已经被缓存。
* @return {Boolean}
*/
shouldCache() {
const ownCache = FabricObject.prototype.shouldCache.call(this);
if (ownCache) {
for (let i = 0; i < this._objects.length; i++) {
if (this._objects[i].willDrawShadow()) {
this.ownCaching = false;
return false;
}
}
}
return ownCache;
}
/**
* 检查此对象或子对象是否会投射阴影
* @return {Boolean}
*/
willDrawShadow() {
if (super.willDrawShadow()) {
return true;
}
for (let i = 0; i < this._objects.length; i++) {
if (this._objects[i].willDrawShadow()) {
return true;
}
}
return false;
}
缓存组时,我们从相同的 shouldCache
检查开始。如果返回 true
,我们将递归检查每个子对象是否有带偏移的阴影。如果找到带偏移的阴影,则无法缓存。不带偏移的阴影仍可缓存,因为旋转不会影响投射的阴影。
运行 shouldCache
将标记组属性 ownCache
,这将告诉我们该组是否正在缓存。稍后,当我们检查每个对象自身的缓存策略时,确定它们是否已在组中被缓存将非常有用。
使用当前代码缓存一个组时,该组首先必须递归向下检查它是否可以缓存,并且每个对象无论如何都要递归向上检查它是否已被缓存。
上述逻辑仅处理缓存决策。
然后,我们要么在缓存上绘制一个对象,然后仅在画布上渲染缓存。
Fabric.js 首先必须了解缓存是否已脏。当缓存已脏、不存在或大小发生变化时,缓存即为已脏。
如果需要创建或绘制缓存,则需要计算尺寸。缓存可能会在具有视网膜缩放的缩放画布上渲染,所有这些都需要考虑在内。
所有这些参数都在 _getCacheCanvasDimensions
中进行计算,并限制在缓存大小配置范围内。
_getCacheCanvasDimensions(): TCacheCanvasDimensions {
const objectScale = this.getTotalObjectScaling(),
// 计算无倾斜时的尺寸
dim = this._getTransformedDimensions({ skewX: 0, skewY: 0 }),
neededX = (dim.x * objectScale.x) / this.scaleX,
neededY = (dim.y * objectScale.y) / this.scaleY;
return {
// 可以肯定的是,这个 ALIASING_LIMIT 在缓存画布达到上限的情况下
// 确实会稍微产生一些问题
// 而且 objectScale 已经包含了 scaleX 和 scaleY
width: Math.ceil(neededX + ALIASING_LIMIT),
height: Math.ceil(neededY + ALIASING_LIMIT),
zoomX: objectScale.x,
zoomY: objectScale.y,
x: neededX,
y: neededY,
};
}
如果画布的尺寸与上一个渲染周期完全相同,画布将被清理并重新绘制。如果对象变大或变小,画布将被调整大小并重新绘制。
一旦绘制完成并进行了最佳居中处理,缓存画布就构建完成,然后在主画布的正确位置进行绘制。
性能陷阱与问题
性能提升并非总是存在,偶尔也会出现模糊问题。
缓存是否有益取决于项目的具体情况。如果你只是绘制一堆圆形、矩形或简单多边形,缓存并不是什么大问题。调用 ctx.fillRect
或 ctx.drawImage
大致相同,即便不完全一样,你也能节省缓存画布的创建、尺寸比较以及大量内存。
另一方面,如果你要导入并显示大型复杂的 SVG 或群组,那么在这种情况下缓存会很有帮助。
你必须始终牢记,如果你处理的是真正静态的对象群组,你始终可以选择将它们导出到画布,然后使用这些画布实例化一些 FabricImage,使其保持快速且真正静态。还要考虑到,复杂的 SVG 也可以不进行解析,而是作为普通图像使用。
缓存对于大型文本对象或带样式的文本对象始终非常有帮助。
视口缩放
在所有缓存场景中常见的第一个性能陷阱是视口缩放。缩放会立即导致所有画布对象的大小发生变化,进而导致所有对象的重新调整大小和重新绘制。
下面的火焰图来自一个包含 900 个简单矩形的画布(并非实际用例),我们正在测量缩放级别变化期间所花费的时间:
矩形大小为 50px:
矩形大小为 130px:
矩形大小为 130px 且未缓存:
如你所见,在现代笔记本电脑上绘制 900 个简单矩形大约需要 4 - 5 毫秒。这与渲染缓存副本所需的时间差不多。在此合成测试中禁用缓存,我们可以跳过检查缓存大小以及实际调整缓存画布大小的所有开销。
你可以看到,50 像素比 130 像素快得多,这是因为 50 像素在视网膜缩放和 1.2 倍缩放的情况下仍低于 256 像素的最小缓存大小,所以缓存会被清理但不会调整大小。130 像素的矩形在视网膜缩放时达到 260 像素,每一次后续缩放都需要调整画布大小,导致上下文丢失和新的内存分配。所需的 70 多毫秒中,大约 30 毫秒用于垃圾回收。而当画布不调整大小时,不会发生重大的垃圾回收事件。
一般来说,视口缩放对于有许多对象和缓存的情况是性能杀手。
对象模糊和抗锯齿
画布矢量绘图是抗锯齿的。这意味着,如果你在位置 X 绘制一条黑色的单像素线,你会得到横跨像素 X 和 X - 1 的线。这条跨像素的线意味着两个像素都会变成灰色而不是黑色,这样你就得到了一条模糊的线。在缓存画布中尽可能清晰地绘制内容非常重要,以避免在模糊的基础上再增加模糊。为了生成下面的屏幕截图,我创建了两个缓存矩形,一个宽 51 像素,另一个宽 51.5 像素,两者都有 1 像素的描边。
在 51 像素的矩形的情况下,Fabric.js 能够通过将矩形在 256 像素的表面上进行像素对齐,使两边都清晰,因为要明白我们需要尽可能在整数像素上进行绘制。对于 51.5 像素的矩形,这是不可能的,会有一边模糊。即使不进行缓存,同样的问题也会发生,但是当我们平移具有小数像素的矩形时,画布会重新优化抗锯齿像素,试图始终生成清晰的线条。对于缓存副本来说,这是不可能的,因为缓存副本在像素对齐时清晰度最佳,而在未对齐时效果欠佳。
如果我们看一下三角形的缓存,就更容易理解这一点:
当三角形彼此相邻放置时,缓存的三角形和未缓存的三角形之间的差异基本上为零。如果考虑旋转,情况就会发生很大变化。当未缓存的三角形旋转 26.57 度时,其中一条边会完全与像素网格对齐,变成一条完美的黑线。而缓存的抗锯齿线会垂直对齐并再次进行抗锯齿处理,从而产生模糊的效果。这是无法修复的,事实就是如此。
为确保在缓存画布上绘制的缓存图像具有最清晰的像素,从而降低双重抗锯齿的影响,已经做出了不错的努力,但有些问题源于画布本身,无法解决。
清晰的输出和打印
由于上述原因,一般来说,在启用缓存的情况下导出 PNG 或 JPEG 格式文件并不是一个好主意。如果你致力于生成最佳的打印文件,应该在导出前禁用缓存,导出后再重新启用。对于需要启用缓存的形状,目前没有内置解决方案,但已有相关计划。
缓存的实际应用
缓存画布大于绘制对象的示例(默认最小尺寸为 256x256):
默认值下最大尺寸缓存画布的示例(200 万像素)。在白色区域内滚动以 100% 缩放查看图像:
默认的缓存设置并不小,足以在标准屏幕上呈现清晰的图像。
下面你可以看到两个 fabric 画布。左边的是默认启用缓存的,而右边的是在禁用缓存的情况下绘制的。
这些画布加载了大量的路径组,比如雪人,我找到的最重的路径组有三个副本,这使得渲染速度大幅下降。试着在左边或右边的画布上拖动其中一个形状,注意速度差异。在现代机器上,即使不启用缓存,仍然可以使用。在我第一次撰写本文时(大约 2017 年),这种差异可谓天壤之别。
作为参考,在我的机器上,一台 2021 年的 M1 Pro 芯片电脑,启用缓存的画布渲染需要 0.8 毫秒,而未启用缓存的则需要 25 毫秒。
Fabric.js 简介
对 Fabric.js 的简要介绍,特别是用户可以从本指南中获得什么
先决条件
如果你想编写比“Hello World”更复杂的程序,具备使用 JavaScript 的经验会很有帮助 😇
变换
数学入门
变换由矩阵描述:
const matrix = [a, b, c, d, e, f];
const vectorX = [a, b];
const vectorY = [c, d];
const translation = [e, f];
vectorX
和 vectorY
分别描述应用于单位向量 [1, 0]
和 [0, 1]
的变换。我们使用从应用于单位向量的变换中派生的分解值(angle
、scaleX
、scaleY
、skewX
、skewY
),详见 qrDecompose
。
translation
描述从父平面的 (0, 0)
到该平面中心的偏移量,分解为 translateX
和 translateY
。画布的 (0, 0)
是画布的左上角,组的 (0, 0)
是组的中心点。
变换应用顺序:
- 平移
- 旋转
- 缩放
- 水平倾斜
- 垂直倾斜
工作原理
每个对象都有自己的变换,该变换定义了一个 平面。一个对象可以存在于另一个对象定义的 平面 中(例如,嵌套在组或裁剪路径下的对象)。这意味着该对象会受到该 平面 的影响。
变换应用顺序:
- 视口
- 父组
- 自身
// 自身变换
object.calcOwnTransform();
// 包含父组的对象变换
object.calcTransformMatrix();
// 对象所在的平面
multiplyTransformMatrixArray([canvas.viewportTransform, object.group?.calcTransformMatrix()]);
使用变换可能会很棘手。有时我们需要使用相对父平面(例如在渲染期间),而有时我们需要使用画布平面或视口平面(例如对象相交、鼠标交互)。
Fabric 针对此类情况提供了以下工具:
sendPointToPlane
sendVectorToPlane
sendObjectToPlane
原点
可以从中心点或其他任何点定位对象
// 在画布平面中设置中心点
object.setCenterPoint(point);
// 在父平面中设置中心点
object.setRelativeCenterPoint(point);
// 在画布平面中设置左上角点
object.setXY(point, 'left', 'top');
// 在父平面中设置右下角点
object.setRelativeXY(point, 'bottom', 'right');
Fabric.js
什么是 Fabric.js
Fabric.js 是 Canvas API 之上的一个抽象层。它提供了基于对象的渲染 API,以及额外的交互和事件层、序列化机制、SVG 导出、动画工具和其他实用程序。
为什么使用 Fabric.js
如果你想在 Canvas 之上构建一个交互式应用程序,并且不想为诸如对象检测和渲染堆栈处理等基本功能编写代码,那么你可能会想要使用 Fabric.js。