用pixijs实现fabricjs(一):FakeCanvasRenderingContext2D

友情提示

  • 请先看这里👉前言
  • 本系列文章提到的fabric均为v7版本,pixijs均为v8版本
  • 相关代码👉地址,本文相关的代码在fake-CanvasRenderingContext2D分支上,请切到这个分支查看代码

1. 前言

fabric的自定义元素,是基于ctx对象完成的(CanvasRenderingContext2D),我们要使用ctx.fill()ctx.stroke()等api,来完成图形的绘制,而pixi是webGL渲染,没有canvas2D的这套东西,所以,如果要复刻一个fabric出来,那么我们首先需要用pixi来实现一个ctx,让用户使用起来,像在使用canvas2D一样。既然这个ctx是假的,那就叫它FakeCanvasRenderingContext2D吧!

2. 基础概念

2.1 ctx的状态

ctx是有状态的,这个状态包含很多东西,ctx.fillStylectx.strokeStylectx.textBaseline等等,都是ctx的状态。每次ctx.fillctx.stroke,都会使用当前的状态来进行绘制。我们会调用ctx.save()ctx.restore()来不断保存,恢复这个状态,来实现对画笔的状态的控制,避免状态的污染。

2.2 子图形

在canvas2D中,我们绘制图形的方式,都是使用构造路径(path)+绘制的方式完成的,用ctx.arc()ctx.rect()等函数构造路径,然后用ctx.fill()ctx.stroke()绘制出这个路径,这样就得到了一个子图形,我们可以用pixi提供的Graphics类来模拟这个过程,从pixi从v8开始,Graphics类的api就和ctx非常相似了,这简化了我们的开发过程。

3. 代码实现

3.1 FakeCanvasRenderingContext2D类

为了让用户使用起来像真的在使用ctx一样,这个类会实现CanvasRenderingContext2D接口,这样的话,对外暴露的api就和ctx一模一样了。

ts 复制代码
class FakeCanvasRenderingContext2D implements CanvasRenderingContext2D{
  globalAlpha: number;
  fillStyle: string;
  strokeStyle: string;
  lineCap: CanvasLineCap;
  lineJoin: CanvasLineJoin;
  lineWidth: number;
  miterLimit: number;
  matrix: Matrix = new Matrix();
  font: string = '10px sans-serif';
  textAlign: CanvasTextAlign = 'start';
  textBaseline: CanvasTextBaseline = 'alphabetic';
  // ...
}

3.2 canvas的state

3.2.1 CanvasState类

用CanvasState类来记录canvas的state,这个state里面包含了:fillStyle、strokeStyle等等状态,并且挂载了和save和restore相关的逻辑,用来保存和恢复状态,fakeCtx中的save和restore都是基于此的。fakeCtx对象中,会记录很多个state,这些state将会被保存在一个栈中,save就是把当前state入栈,restore就是从栈顶取一个state,拿来作为当前state

ts 复制代码
CanvasState implements CanvasStateBase {
  fillStyle: string;
  strokeStyle: string;
  globalAlpha: number;
  lineWidth: number;
  lineCap: CanvasLineCap;
  lineJoin: CanvasLineJoin;
  miterLimit: number;
  font: string;
  textAlign: CanvasTextAlign;
  textBaseline: CanvasTextBaseline;
  matrix: Matrix;
  constructor() {
    this.fillStyle = '#000000';
    this.strokeStyle = '#000000';
    this.globalAlpha = 1;
    this.lineWidth = 1;
    this.lineCap = 'butt';
    this.lineJoin = 'miter';
    this.miterLimit = 10;
    this.matrix = new Matrix();
    this.font = '10px sans-serif';
    this.textAlign = 'start';
    this.textBaseline = 'alphabetic';
  }
  copyStateFrom(ctx: FakeCanvasRenderingContext2D) {
    this.fillStyle = ctx.fillStyle;
    this.strokeStyle = ctx.strokeStyle;
    this.globalAlpha = ctx.globalAlpha;
    this.lineWidth = ctx.lineWidth;
    this.lineCap = ctx.lineCap;
    this.lineJoin = ctx.lineJoin;
    this.miterLimit = ctx.miterLimit;
    this.font = ctx.font;
    this.textAlign = ctx.textAlign;
    this.textBaseline = ctx.textBaseline;
    this.matrix.copyFrom(ctx.matrix);
  }
  setStateTo(ctx: FakeCanvasRenderingContext2D) {
    ctx.fillStyle = this.fillStyle;
    ctx.strokeStyle = this.strokeStyle;
    ctx.globalAlpha = this.globalAlpha;
    ctx.lineWidth = this.lineWidth;
    ctx.lineCap = this.lineCap;
    ctx.lineJoin = this.lineJoin;
    ctx.miterLimit = this.miterLimit;
    ctx.font = this.font;
    ctx.textAlign = this.textAlign;
    ctx.textBaseline = this.textBaseline;
    ctx.matrix.copyFrom(this.matrix);
  }
}

3.2.2 保存和恢复状态

用stateStack记录所有保存下来的状态,curStateIdx记录当前状态的下标位置,save和restore函数就是不断地在stateStack上穿梭,实现保存、恢复状态

ts 复制代码
class FakeCanvasRenderingContext2D implements CanvasRenderingContext2D {
  private stateStack: CanvasState[] = [];
  private curStateIdx: number = -1;
  save(): void {
    this.curStateIdx++;
    if (!this.stateStack[this.curStateIdx]) {
      this.stateStack[this.curStateIdx] = new CanvasState();
    }

    this.stateStack[this.curStateIdx].copyStateFrom(this);
  }
  restore(): void {
    if (this.curStateIdx < 0) return;

    this.stateStack[this.curStateIdx].setStateTo(this);
    this.curStateIdx--;
  }
  // ...
}

3.3 ctx.beginPath()

虽然这个api名字叫begin path,但是它的作用其实是清理当前的path,所以从这个角度理解,它也可以叫clear path,在调用这个函数之前,我们要最好要调用fill或者stroke来把当前的path画出来,不然这些path就被clear掉了。

3.3.1 path

用pixi的Graphics类来表示当前path,这里注意一个问题,canvas2D中的path,是可以多次进行fill和stroke的,在这里我打算简化一下,改造成:一个path只能进行一次fill或者stroke,我们用fillIdx和strokeIdx来记录当前fill过的和stroke过的path,用drawIdx来记录当前最新的path在哪个位置。

3.3.2 beginPath实现

先判断当前有path内容,如果有内容,则记录当前的ctx状态以及当前path,然后开启下一个path(新建一个Graphics)

ts 复制代码
class FakeCanvasRenderingContext2D implements CanvasRenderingContext2D {
  // ...
  private curGraphics!: Graphics;
  public getCurGraphics(): Graphics {
    const children = this.app.stage.children;
    if (!children[this.drawIdx]) {
      this.app.stage.addChild(new Graphics());
    }

    return children[this.drawIdx] as Graphics;
  }
  private finishPath(): void {
    this.curGraphics.setFromMatrix(this.matrix);
    this.drawIdx++;
    this.curGraphics = this.getCurGraphics();
  }
  beginPath(): void {
    if (this.curGraphics.pathDirty) {
      this.finishPath();
    }

    this.fillIdx = this.drawIdx;
    this.strokeIdx = this.drawIdx;
  }
}

3.4 ctx.fill()和ctx.stroke()

path是一个抽象的几何形状,fill和stroke才会真正地把内容绘制出来。在进行fill和stroke之后,立即进行finishPath,新建一个Graphics,避免后续的fill和stroke对已经fill或者stroke过的图形产生污染

ts 复制代码
class FakeCanvasRenderingContext2D implements CanvasRenderingContext2D {
  // ...
  fill(): void {
    const fillIdx = this.fillIdx;
    const drawIdx = this.drawIdx;
    for (let i = fillIdx; i <= drawIdx; i++) {
      const g = this.app.stage.children[i] as Graphics;
      if (!g.skipFillOrStroke) {
        g.fill({ color: this.fillStyle || '#000000' });
      }
    }

    if (this.curGraphics.pathDirty) {
      this.finishPath();
    }

    this.fillIdx = this.drawIdx;
  }
  stroke(): void {
    const strokeIdx = this.strokeIdx;
    const drawIdx = this.drawIdx;
    for (let i = strokeIdx; i <= drawIdx; i++) {
      const g = this.app.stage.children[i] as Graphics;
      if (!g.skipFillOrStroke) {
        g.stroke({
          color: this.strokeStyle,
          width: this.lineWidth,
          miterLimit: this.miterLimit,
          join: this.lineJoin,
          cap: this.lineCap,
        });
      }
    }

    if (this.curGraphics.pathDirty) {
      this.finishPath();
    }

    this.strokeIdx = this.drawIdx;
  }
}

3.5 ctx.arc()、ctx.arcTo()、ctx.bezierCurveTo()、ctx.moveTo()、ctx.lineTo()、ctx.quadraticCurveTo()、ctx.rect()、ctx.roundRect()、ctx.closePath()

这一系列的api主要是绘制一些特定的路径出来,pixi v8版本的api与这些api完美兼容,所以可以直接拿来用。有一个点需要注意:每次调用了这些api,都会将当前的Graphics标记为dirty,代表这个Graphics是包含了路径的,可以进行fill和stroke

ts 复制代码
class FakeCanvasRenderingContext2D implements CanvasRenderingContext2D {
  // ...
    arc(
    x: number,
    y: number,
    radius: number,
    startAngle: number,
    endAngle: number,
    counterclockwise?: boolean,
  ): void {
    this.curGraphics.arc(x, y, radius, startAngle, endAngle, counterclockwise);
    this.curGraphics.pathDirty = true;
  }
  arcTo(x1: number, y1: number, x2: number, y2: number, radius: number): void {
    this.curGraphics.arcTo(x1, y1, x2, y2, radius);
    this.curGraphics.pathDirty = true;
  }
  bezierCurveTo(
    cp1x: number,
    cp1y: number,
    cp2x: number,
    cp2y: number,
    x: number,
    y: number,
  ): void {
    this.curGraphics.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y);
    this.curGraphics.pathDirty = true;
  }
  moveTo(x: number, y: number): void {
    this.curGraphics.moveTo(x, y);
  }
  lineTo(x: number, y: number): void {
    this.curGraphics.lineTo(x, y);
    this.curGraphics.pathDirty = true;
  }
  quadraticCurveTo(cpx: number, cpy: number, x: number, y: number): void {
    this.curGraphics.quadraticCurveTo(cpx, cpy, x, y);
    this.curGraphics.pathDirty = true;
  }
  rect(x: number, y: number, w: number, h: number): void {
    this.curGraphics.rect(x, y, w, h);
    this.curGraphics.pathDirty = true;
  }
  roundRect(x: number, y: number, w: number, h: number, radii?: number): void {
    this.curGraphics.roundRect(x, y, w, h, radii);
    this.curGraphics.pathDirty = true;
  }
  closePath(): void {
    this.curGraphics.closePath();
  }
}

3.6 ctx.fillText()和ctx.strokeText()

pixijs并不具备文字能力,其底层是基于canvas2D的能力进行文字生成的,所以,在这里,也将使用canvas2D的能力进行文字生成。

3.6.1 TextFactory

在实际的使用场景中,可能对某个text进行多次绘制,如果每次都去生成一个text,那么性能将会大打折扣,所以,我们可以将那些一模一样的text缓存起来,把它当作一张图片,用的时候,再去取这张图片,而不用实时生成text。TextFactory类就是干这件事情的

TextFactory的具体代码可以看这里👉:地址

这个类的getOne方法会根据font、textAlign、textBaseline、fillStyle、strokeStyle、maxWidth等参数,来从缓存里获取一个text,如果缓存里没有,就生成一个

3.6.2 texture函数

用pixi的Graphics类中的textrue函数来将textFactory中获取的text绘制出来

ts 复制代码
const textTexture = textFactory.getOne(
  text,
  this.font,
  this.textAlign,
  this.textBaseline,
  fillOrStroke,
  this.fillStyle,
  this.strokeStyle,
  maxWidth,
);
const {
  _pixiFabricOffsetX, // 偏移量x
  _pixiFabricOffsetY, // 偏移量y
  _pixiFabricWidth, // 宽
  _pixiFabricHeight, // 高
} = textTexture;
this.curGraphics.texture(
  textTexture,
  0xffffff,
  x + _pixiFabricOffsetX,
  y + _pixiFabricOffsetY,
  _pixiFabricWidth,
  _pixiFabricHeight,
);

3.7 ctx.drawImage()

canvas2D用这个api来进行图片的绘制,我们依然是用Graphics类的texture方法来实现drawImage函数

ts 复制代码
class FakeCanvasRenderingContext2D implements CanvasRenderingContext2D {
  // ...
  // 这个函数有4种传参方式
  drawImage(
    image: HTMLImageElement | HTMLCanvasElement,
    dx: number,
    dy: number,
  ): void;
  drawImage(
    image: HTMLImageElement | HTMLCanvasElement,
    dx: number,
    dy: number,
    dw: number,
    dh: number,
  ): void;
  drawImage(
    image: HTMLImageElement | HTMLCanvasElement,
    sx: number,
    sy: number,
    sw: number,
    sh: number,
    dx: number,
    dy: number,
    dw: number,
    dh: number,
  ): void;
  drawImage(
    image: HTMLImageElement | HTMLCanvasElement,
    a: number,
    b: number,
    c?: number,
    d?: number,
    e?: number,
    f?: number,
    g?: number,
    h?: number,
  ): void {
    if (this.curGraphics.pathDirty) {
      this.finishPath();
    }

    // 判断参数的个数并使用不同的方式传给texture函数
    const hasEightArgs =
      typeof e === 'number' &&
      typeof f === 'number' &&
      typeof g === 'number' &&
      typeof h === 'number';
    const hasFourArgs = typeof c === 'number' && typeof d === 'number';

    let texture: Texture;
    if (hasEightArgs) {
      const baseTexture = TextureSource.from(image);
      texture = new Texture({
        source: baseTexture,
        frame: new Rectangle(a, b, c, d),
      });
      this.curGraphics.texture(texture, 0xffffff, e, f, g, h);
    } else if (hasFourArgs) {
      texture = Texture.from(image);
      this.curGraphics.texture(texture, 0xffffff, a, b, c, d);
    } else {
      texture = Texture.from(image);
      this.curGraphics.texture(texture, 0xffffff, a, b);
    }

    this.curGraphics.skipFillOrStroke = true;

    this.finishPath();
  }
}

3.8 ctx.rotate()、ctx.scale()、ctx.translate()、ctx.transform()

这几个api的作用是将当前2D空间进行某些变换,这样就能实现一些特殊效果了,其底层是:将当前的变换矩阵右乘一个新的变换矩阵。

ts 复制代码
class FakeCanvasRenderingContext2D implements CanvasRenderingContext2D {
  // ...
  rotate(radian: number): void {
    this.matrix.append(Matrix.IDENTITY.clone().rotate(radian));
  }
  scale(x: number, y: number): void {
    this.matrix.append(Matrix.IDENTITY.clone().scale(x, y));
  }
  translate(x: number, y: number): void {
    this.matrix.append(Matrix.IDENTITY.clone().translate(x, y));
  }
  transform(
    a: number,
    b: number,
    c: number,
    d: number,
    e: number,
    f: number,
  ): void {
    this.matrix.append(new Matrix(a, b, c, d, e, f));
  }
}

3.9 ctx.getTransform()、ctx.setTransform()、ctx.resetTransform()

这3个api分别用来获取、设置、重置当前空间的变换矩阵

ts 复制代码
class FakeCanvasRenderingContext2D implements CanvasRenderingContext2D {
  // ...
  getTransform() {
    const domMatrix = new DOMMatrix();
    domMatrix.a = this.matrix.a;
    domMatrix.b = this.matrix.b;
    domMatrix.c = this.matrix.c;
    domMatrix.d = this.matrix.d;
    domMatrix.e = this.matrix.tx;
    domMatrix.f = this.matrix.ty;
    return domMatrix;
  }
  setTransform(
    a: number,
    b: number,
    c: number,
    d: number,
    e: number,
    f: number,
  ): void;
  setTransform(transform?: DOMMatrix2DInit): void;
  setTransform(
    a?: number | DOMMatrix2DInit,
    b?: number,
    c?: number,
    d?: number,
    e?: number,
    f?: number,
  ): void {
    if (!a) {
      this.matrix.reset();
    } else if (typeof a === 'object') {
      const matrix = a as DOMMatrix2DInit;
      this.matrix.set(
        matrix.a ?? 1,
        matrix.b ?? 0,
        matrix.c ?? 0,
        matrix.d ?? 1,
        matrix.e ?? 0,
        matrix.f ?? 0,
      );
    } else {
      this.matrix.set(a ?? 1, b ?? 0, c ?? 0, d ?? 1, e ?? 0, f ?? 0);
    }
  }
  resetTransform(): void {
    this.matrix.reset();
  }
}

3.10 ctx.measureText()

这个api用来测量字体的尺寸,我们直接利用canvas2D的api来实现这个api

ts 复制代码
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d')!;
class FakeCanvasRenderingContext2D implements CanvasRenderingContext2D {
  // ...
  measureText(text: string): TextMetrics {
    ctx.font = this.font;
    ctx.textAlign = this.textAlign;
    ctx.textBaseline = this.textBaseline;
    const metrics = ctx.measureText(text);
    return metrics;
  }
}

3.11 其他api

由于时间问题,作者并没有把100%的CanvasRenderingContext2D api实现,而是只实现了一些常用的api,如果有需求,大家可以自己去实现一下。

4. 测试一下

用使用ctx的方式,来测试一下fakeCtx的效果

测试代码:

ts 复制代码
ctx.scale(1.1, 1.1);
ctx.translate(50, 0);
ctx.beginPath();
ctx.rotate((Math.PI / 180) * 10);

ctx.fillStyle = "red";
ctx.strokeStyle = "white";
ctx.lineWidth = 10;
ctx.lineJoin = "round";
ctx.lineCap = "round";

ctx.moveTo(100, 100);
ctx.lineTo(200, 100);
ctx.lineTo(200, 200);
ctx.lineTo(100, 200);
ctx.closePath();

ctx.fill();
ctx.stroke();

ctx.beginPath();
// ctx.translate(300, 0);
ctx.moveTo(0, 0);
ctx.quadraticCurveTo(300, 100, 400, 200);
ctx.stroke();

ctx.beginPath();
ctx.fillStyle = "blue";
ctx.rect(0, 200, 100, 150);
ctx.fill();

ctx.scale(1, 1.1);
ctx.beginPath();
ctx.fillStyle = "gold";
ctx.rect(100, 350, 100, 100);
ctx.fill();

ctx.beginPath();
ctx.moveTo(300, 200);
ctx.strokeStyle = "green";
ctx.bezierCurveTo(300, 300, 400, 300, 400, 200);
ctx.stroke();

ctx.translate(300, 0);
ctx.beginPath();
ctx.lineWidth = 1;
ctx.strokeStyle = "cyan";
ctx.fillStyle = "green";
ctx.font = "40px sans-serif";
ctx.fillText("hello", 150, 150, 30);
ctx.strokeText("你好", 250, 150);

codesandBox地址

效果:

相关推荐
下雨打伞干嘛16 小时前
redux的使用
开发语言·javascript·ecmascript
small_white_robot16 小时前
idek-2022 web 全wp——持续更新
开发语言·前端·javascript·网络·安全·web安全·网络安全
sp4216 小时前
NativeScript 5.1:直接集成 Objective-C 代码
前端·javascript
px不是xp16 小时前
【灶台导航】 RAG系统的容错设计:从向量搜索到关键词降级,一个都不能少
javascript·微信小程序·notepad++·rag
Sanri.16 小时前
JavaScript基础语法6
开发语言·javascript·ecmascript
hhb_61816 小时前
JavaScript核心技术要点梳理与实战应用案例解析
开发语言·javascript·ecmascript
廖松洋(Alina)17 小时前
03主入口页面与导航结构-鸿蒙PC端Electron开发
前端·javascript·华为·electron·开源·harmonyos·鸿蒙
廖松洋(Alina)17 小时前
09词根分解与水印展示-鸿蒙PC端Electron开发
前端·javascript·华为·electron·开源·harmonyos·鸿蒙
matrixmind817 小时前
sindresorhustype-fest:TypeScript 工具类型集合
前端·javascript·其他·typescript
山海鲸可视化17 小时前
数字孪生项目案例 | 物流园区可视化
webgl·可视化·数据可视化·数据表格·搜索框