友情提示
1. 前言
上一章,我们完成了fabric对象的继承链上的ObjectGeometry类,这个类包含了对象的基础信息, 包括位置信息(left、top)、缩放信息(scale)、旋转信息(angle)等,但是这个类只是这条长长的继承链上的其中一环,fabric将对象的其他功能分散到了这条继承链上的其他类上,所以在这一章里,我们将继续完成这条继承链,并讲述fabric的内置对象是如何实现的。
2. 对象的继承链
从ObjectGeometry类出发,讲述fabric对象的继承链的实现
2.1 InnerObject
这个对象继承自ObjectGeometry,在fabric中,这个类叫做FabricObject,但是这个名字和另一个FabricObject重名了,所以这里给他改一下名字,叫做InnerObject。
ObjectGeometry类存放了对象的基本属性,它的作用是记录元素的位置信息(left、top)、缩放信息(scale)、旋转信息(angle)等,然后用特定的方式把他们组合起来,得到一个localTransform矩阵;而InnerObject则存放了对象的绘制属性,比如透明度(opacity),填充色(fill),描边色(stroke),render和_render函数等,它决定了这个对象将会如何被绘制出来。
2.1.1 constructor
首先设置对象的默认属性值,然后把参数中的属性值设置给对象。
代码:
ts
export class InnerObject extends ObjectGeometry {
// ...
constructor(options?: Props) {
super();
Object.assign(this, FabricObject.ownDefaults);
this.setOptions(options);
}
}
有一个点需要注意,fabric v6的默认锚点是LEFT、TOP,而v7的锚点默认是CENTER、CENTER。
2.1.2 _render
执行绘制逻辑,通过重写这个函数来实现自定义绘制逻辑,这个函数默认为空。
ts
export class InnerObject extends ObjectGeometry {
// ...
_render(_ctx: CanvasRenderingContext2D) {
// placeholder to be overridden
}
}
2.1.3 transform
计算出自身的localTransform
fabric的这个函数会先计算出自身的localTransform,然后调用ctx.transform把自身的localTransform叠加到当前的变换矩阵,实现了worldTransform的效果,但是,我们不必调用ctx.transform,因为pixi会帮我们计算出worldTransform。
fabric的代码:
ts
export class InnerObject extends ObjectGeometry {
// ...
transform(ctx: CanvasRenderingContext2D) {
const needFullTransform =
(this.group && !this.group._transformDone) ||
(this.group && this.canvas && ctx === (this.canvas as Canvas).contextTop);
const m = this.calcTransformMatrix(!needFullTransform);
ctx.transform(m[0], m[1], m[2], m[3], m[4], m[5]);
}
}
我们的实现:
ts
export class InnerObject extends ObjectGeometry {
// ...
transform(ctx: CanvasRenderingContext2D) {
this.calcOwnMatrix();
}
}
2.1.4 render
这个函数的内容包含了transform函数和_render函数,它的作用是:先判断该对象是否应该被渲染(是否visible),如果应该渲染,则计算该对象的localTransform,然后再渲染。
既然我们是在用pixijs实现fabricjs,那么这个函数也需要作出相应的改造:
- 判断对象是否应该渲染,我们依然沿用fabric的isNotVisible函数,如果不应该渲染,则将pixiContent的visible设置为false,达到不渲染的效果。
- 用pixi的alpha代替fabric的opacity
- fakeCtx模拟ctx
代码:
ts
export class InnerObject extends ObjectGeometry {
// ...
render(ctx: CanvasRenderingContext2D) {
this.pixiContent.visible = false;
if (this.isNotVisible()) return;
this.pixiContent.visible = true;
this.transform(ctx);
this.pixiContent.alpha = this.opacity ?? 1;
this.drawObject(ctx);
this.dirty = false;
}
drawObject(ctx: CanvasRenderingContext2D) {
if (!this.dirty) return;
const originalFill = this.fill;
const originalStroke = this.stroke;
(ctx as FakeCanvasRenderingContext2D).bindObjectGeometry(this);
this._renderBackground(ctx);
this._render(ctx);
this.fill = originalFill;
this.stroke = originalStroke;
}
}
2.1.5 InnerObject的其他函数
其他函数可以参考fabric,这块代码可以不用动。
2.2 InteractiveFabricObject
这个类继承自InnerObject,ObjectGeometry承载了元素的基本信息,InnerObject承载了对象的绘制信息,而InteractiveFabricObject则承载了元素的交互信息。
这个类承载的功能,只有在实现了Canvas类(fabric的可交互画布)后,才会用到,在实现Canvas类之前我们还需要先实现StaticCanvas类,所以短时间内我们还用不到InteractiveFabricObject这个类,所以等我们实现Canvas类的时候再来实现InteractiveFabricObject这个类。
2.3 FabricObject
这个类继承自InteractiveFabricObject,fabric仅对这个类扩展了一些svg相关的函数,抛开这一点,它和InteractiveFabricObject类的功能是一样的,仅仅是名字不同。
3. 内置对象的实现
fabric内置了很多简单对象,让我们不必所有对象都通过自定义对象的方式实现。接下来我们会实现这些对象。但是,需要注意的是:纵使是简单对象,fabric也给他们内置了一系列的函数或者属性,来丰富它的功能,让用户有更多操作空间,这里我们重点关注这些简单对象的核心功能,而不会去过多关注这些边缘功能。
3.1 Circle
画一个圆
核心代码是ctx.arc()
代码:
ts
class Circle extends FabricObject {
radius = 0;
startAngle = 0;
endAngle = 360;
counterClockwise = false;
_render(ctx: CanvasRenderingContext2D): void {
ctx.beginPath();
ctx.arc(
0,
0,
this.radius,
degreesToRadians(this.startAngle),
degreesToRadians(this.endAngle),
this.counterClockwise,
);
this._renderPaintInOrder(ctx);
}
}
3.2 Rect
画一个矩形
这里并不会用ctx.rect来绘制,因为要考虑x、y方向的圆角,所以采用了ctx.moveTo、ctx.lineTo来绘制直边,用ctx.bezierCurveTo来绘制圆角。
代码:
ts
class Rect extends FabricObject {
rx = 0;
ry = 0;
_render(ctx: CanvasRenderingContext2D) {
const { width: w, height: h } = this;
const x = -w / 2;
const y = -h / 2;
const rx = this.rx ? Math.min(this.rx, w / 2) : 0;
const ry = this.ry ? Math.min(this.ry, h / 2) : 0;
const isRounded = rx !== 0 || ry !== 0;
ctx.beginPath();
ctx.moveTo(x + rx, y);
ctx.lineTo(x + w - rx, y);
isRounded &&
ctx.bezierCurveTo(
x + w - kRect * rx,
y,
x + w,
y + kRect * ry,
x + w,
y + ry,
);
ctx.lineTo(x + w, y + h - ry);
isRounded &&
ctx.bezierCurveTo(
x + w,
y + h - kRect * ry,
x + w - kRect * rx,
y + h,
x + w - rx,
y + h,
);
ctx.lineTo(x + rx, y + h);
isRounded &&
ctx.bezierCurveTo(
x + kRect * rx,
y + h,
x,
y + h - kRect * ry,
x,
y + h - ry,
);
ctx.lineTo(x, y + ry);
isRounded &&
ctx.bezierCurveTo(x, y + kRect * ry, x + kRect * rx, y, x + rx, y);
ctx.closePath();
this._renderPaintInOrder(ctx);
}
}
3.3 Triangle
画一个三角形
用ctx.moveTo和ctx.lineTo绘制一个三角形路径然后fill或者stroke
代码:
ts
class Triangle extends FabricObject {
_render(ctx: CanvasRenderingContext2D) {
const widthBy2 = this.width / 2,
heightBy2 = this.height / 2;
ctx.beginPath();
ctx.moveTo(-widthBy2, heightBy2);
ctx.lineTo(0, -heightBy2);
ctx.lineTo(widthBy2, heightBy2);
ctx.closePath();
this._renderPaintInOrder(ctx);
}
}
3.4 Ellipse
画一个椭圆
这里并没有直接使用ctx.ellipse来绘制椭圆,而是用了ctx.arc函数,在使用ctx.arc函数之前,调用ctx.transform来叠加一个y方向的压缩矩阵,把自身'压扁',这样圆就变成椭圆了。
代码:
ts
class Ellipse extends FabricObject {
rx = 0;
ry = 0;
_render(ctx: CanvasRenderingContext2D) {
ctx.beginPath();
ctx.save();
ctx.transform(1, 0, 0, this.ry / this.rx, 0, 0);
ctx.arc(0, 0, this.rx, 0, Math.PI * 2, false);
this._renderPaintInOrder(ctx);
ctx.restore();
}
}
3.5 Line
画一条线
line这个图元不用设置width、height,它会根据x1、y1、x2、y2属性自动计算出width、height。
代码:
ts
class Line extends FabricObject {
// ...
calcLinePoints(): {
x1: number;
x2: number;
y1: number;
y2: number;
} {
const { x1: _x1, x2: _x2, y1: _y1, y2: _y2, width, height } = this;
const xMult = _x1 <= _x2 ? -1 : 1,
yMult = _y1 <= _y2 ? -1 : 1,
x1 = (xMult * width) / 2,
y1 = (yMult * height) / 2,
x2 = (xMult * -width) / 2,
y2 = (yMult * -height) / 2;
return {
x1,
x2,
y1,
y2,
};
}
_render(ctx: CanvasRenderingContext2D) {
ctx.beginPath();
const p = this.calcLinePoints();
ctx.moveTo(p.x1, p.y1);
ctx.lineTo(p.x2, p.y2);
ctx.lineWidth = this.strokeWidth;
const origStrokeStyle = ctx.strokeStyle;
ctx.strokeStyle = this.stroke ?? ctx.fillStyle;
this.stroke && this._renderStroke(ctx);
ctx.strokeStyle = origStrokeStyle;
}
}
3.6 Polyline
画一个多边形,这个类是Polygon类的父类,多边形的逻辑放在这个类里,而不是Polygon类里
这个类的构造函数接受一个数组,代表多边形的所有顶点,它和Line有点类似,会自动计算出width、height,所以我们不用显式指定它们。
它的绘制逻辑也比较简单,就是不断地moveTo、lineTo然后fill或者stroke,但是它的计算计算宽高的逻辑比较长,这里就不贴了(可以去这里看源码),只贴核心的绘制代码:
ts
class Polyline extends FabricObject{
// ...
_render(ctx: CanvasRenderingContext2D) {
const len = this.points.length,
x = this.pathOffset.x,
y = this.pathOffset.y;
if (!len || isNaN(this.points[len - 1].y)) {
// do not draw if no points or odd points
// NaN comes from parseFloat of a empty string in parser
return;
}
ctx.beginPath();
ctx.moveTo(this.points[0].x - x, this.points[0].y - y);
for (let i = 0; i < len; i++) {
const point = this.points[i];
ctx.lineTo(point.x - x, point.y - y);
}
!this.isOpen() && ctx.closePath();
this._renderPaintInOrder(ctx);
}
}
3.7 Polygon
绘制一个多边形,这个类继承自Polyline,并且,它基本没有功能的扩展,可以理解为它等同于Polyline
ts
export class Polygon extends Polyline {
protected isOpen() {
return false;
}
}
3.8 Path
基于SVG的绘制命令,绘制一个path
fabric内置了一套工具集,用来解析绘制命令,然后调用对应的函数(ctx.lineTo、ctx.moveTo、ctx.bezierCurveTo、ctx.quadraticCurveTo),把这个svg path绘制出来。
ts
class Path extends FabricObject{
_renderPathCommands(ctx: CanvasRenderingContext2D) {
const l = -this.pathOffset.x,
t = -this.pathOffset.y;
ctx.beginPath();
for (const command of this.path) {
switch (
command[0] // first letter
) {
case 'L': // lineto, absolute
ctx.lineTo(command[1] + l, command[2] + t);
break;
case 'M': // moveTo, absolute
ctx.moveTo(command[1] + l, command[2] + t);
break;
case 'C': // bezierCurveTo, absolute
ctx.bezierCurveTo(
command[1] + l,
command[2] + t,
command[3] + l,
command[4] + t,
command[5] + l,
command[6] + t
);
break;
case 'Q': // quadraticCurveTo, absolute
ctx.quadraticCurveTo(
command[1] + l,
command[2] + t,
command[3] + l,
command[4] + t
);
break;
case 'Z':
ctx.closePath();
break;
}
}
}
_render(ctx: CanvasRenderingContext2D) {
this._renderPathCommands(ctx);
this._renderPaintInOrder(ctx);
}
}
3.9 Image
绘制一张图片
核心绘制逻辑是用ctx.drawImage函数来绘制一张图片。
Image类也支持stroke,它的stroke路径是围绕图片一周的一个矩形。
代码:
ts
_render(ctx: CanvasRenderingContext2D) {
ctx.imageSmoothingEnabled = this.imageSmoothing;
this._renderPaintInOrder(ctx);
}
protected _renderStroke(ctx: CanvasRenderingContext2D): void {
this._stroke(ctx);
super._renderStroke(ctx);
}
_stroke(ctx: CanvasRenderingContext2D) {
if (!this.stroke || this.strokeWidth === 0) {
return;
}
const w = this.width / 2,
h = this.height / 2;
ctx.beginPath();
ctx.moveTo(-w, -h);
ctx.lineTo(w, -h);
ctx.lineTo(w, h);
ctx.lineTo(-w, h);
ctx.lineTo(-w, -h);
ctx.closePath();
}
_renderFill(ctx: CanvasRenderingContext2D) {
const elementToDraw = this._element;
if (!elementToDraw) {
return;
}
const scaleX = this._filterScalingX,
scaleY = this._filterScalingY,
w = this.width,
h = this.height,
// crop values cannot be lesser than 0.
cropX = Math.max(this.cropX, 0),
cropY = Math.max(this.cropY, 0),
elWidth =
(elementToDraw as HTMLImageElement).naturalWidth || elementToDraw.width,
elHeight =
(elementToDraw as HTMLImageElement).naturalHeight ||
elementToDraw.height,
sX = cropX * scaleX,
sY = cropY * scaleY,
// the width height cannot exceed element width/height, starting from the crop offset.
sW = Math.min(w * scaleX, elWidth - sX),
sH = Math.min(h * scaleY, elHeight - sY),
x = -w / 2,
y = -h / 2,
maxDestW = Math.min(w, elWidth / scaleX - cropX),
maxDestH = Math.min(h, elHeight / scaleY - cropY);
elementToDraw &&
ctx.drawImage(elementToDraw, sX, sY, sW, sH, x, y, maxDestW, maxDestH);
}
3.10 Group
组元素,Group让我们可以把一堆元素当作一个整体来进行操作。
组是所有渲染引擎都会提供的一个概念,fabric里面的组是Group,pixi里面的组是Container
3.10.1 constructor
依然是用pixiContent来代表元素自身的pixi内容,但是这里加入了一个subPixiContent,它跟pixiContent一样,是一个pixi的Container,这个subPixiContent的目的是:隔绝Group自身的绘制内容 和Group的子元素内容。
diff
class Group extends FabricObject {
//...
constructor(objects: FabricObject[] = [], options: Partial<GroupProps> = {}) {
// ...
+ this.pixiContent.removeChildren();
+ this.pixiContent.label = 'group';
+ this.subPixiContent = new Container({
+ children: [new Graphics()],
+ });
+ this.pixiContent.addChild(this.subPixiContent);
// ...
}
}
3.10.2 添加元素到Group中
用Group的pixiContent来add子元素的pixiContent
diff
class Group extends FabricObject {
//...
private _enterGroup(object: FabricObject, removeParentTransform?: boolean) {
// ...
+ this.pixiContent.addChild(object.pixiContent);
}
}
3.10.3 add函数
add函数用来动态地把其他元素添加到Group里(注意:constructor里并不会调用这个函数)
在将一个元素add到一个Group里时,它的worldTransform就变了(自身的worldTransform = 自身的localTransform 叠加 父元素的worldTransform),导致的结果就是,元素在画布上的位置信息(旋转、缩放、平移)变了,但是fabric并不想让我们看到这种效果,fabric希望调用add函数后,这个元素在画布上看起来原封不动,这样的话使用起来会更加方便。
要实现这种效果,可以从代数的角度来看待这个问题:
设元素的localTransform为l,元素的worldTransform为w,Group的worldTransform为gw,在元素被添加到Group里之前,有w = l,因为这个时候元素没有父元素;在把元素添加到Group里后,有w = gw x l,为了保证元素的原封不动,就要让w保持不变,所以有l(before) = w = gw x l(after),l(before)为元素被添加到Group里之前的localTransform,而l(after)表示元素被添加到Group后的localTransform,l(after)也是我们要求的值,实际上我们就是通过修改元素的l来实现原封不动的效果。
我们继续看l(before) = w = gw x l(after),l(before)等式,l(before)是已知的,w、gw也是已知的,把等式的两边乘以gw的逆矩阵(),就可以得到l(after):
<math xmlns="http://www.w3.org/1998/Math/MathML"> g w − 1 gw^{-1} </math>gw−1 x l(before) = <math xmlns="http://www.w3.org/1998/Math/MathML"> g w − 1 gw^{-1} </math>gw−1 x gw x l(after) = l(after)
具体代码:
diff
class Group extends FabricObject {
//...
+ // add函数里面会调用_enterGroup函数
private _enterGroup(object: FabricObject, removeParentTransform?: boolean) {
// ...
+ applyTransformToObject(
+ object,
+ multiplyTransformMatrices(
+ invertTransform(this.calcTransformMatrix()),
+ object.calcTransformMatrix(),
+ ),
+ );
}
}
3.10.4 remove函数
这个函数用来将Group里的某个元素移除出去,和上面的add函数同样,元素被移除出去之后,在画布上看起来要
原封不动。
这次,我们的等式变成了:gw x l(before) = w = l(after),l(before)为元素被移除前的localTransform,l(after)为元素从Group里被移除后的localTransform,这里也不用叠加一个逆矩阵了,我们可以直接得出l(after) = gw x l(before)
具体代码: 具体代码:
diff
class Group extends FabricObject {
//...
+ // remove函数里面会调用_exitGroup函数
protected _exitGroup(object: FabricObject, removeParentTransform?: boolean) {
// ...
+ applyTransformToObject(
+ object,
+ multiplyTransformMatrices(
+ this.calcTransformMatrix(),
+ object.calcTransformMatrix(),
+ ),
+ );
}
}
3.10.5 render函数
由于Group是一个嵌套结构,所以它的render函数不能只render自身了,而是需要render自身+递归render子元素
diff
class Group extends FabricObject {
//...
+ // render函数里会调用drawObject函数
+ drawObject(ctx: CanvasRenderingContext2D) {
+ this._renderBackground(ctx);
+ for (let i = 0; i < this._objects.length; i++) {
+ const obj = this._objects[i];
+ obj.render(ctx);
+ }
+ }
}
4. 测试一下
测试方式,new一堆内置对象,放到画布上,查看效果。
测试代码:codesandbox地址
效果:

对比fabric的效果:

可以看到效果是一样的。