g-canvas 源码解读(一):g中基于TypeScript对于canvas的抽象

1,228 阅读8分钟

最近选型使用Antv的G6来做流程设计器,研究G6的源码发现其操作Canvas的方法是使用自己封装的g-canvas库,也是来自于Antv的g开源项目,所以我们一起看下大厂如何用ts做canvas的类库;

我们可以进入g 的代码仓库一步步来看,大家可以看下read.me,我习惯看一个开源工程的package.json

一眼我就看到了一个没有见过的库

"@antv/gatsby-theme-antv": "^0.10.17",
"gatsby": "^2.15.34",

简单搜索下npm gatsby不难得知这是一个React的快速建站工具,手动羡慕眼;

做为一个资深的vue开发,坚信应该也会有类似的vue的快速建站工具,尝试google搜索"gatsby-vue",啊哈,Gridsome映入眼帘,果然react有的,vue社区也不会拉下,看下github 6.2K的start,和6天前的PR,果断start一波,加入我的试用List;还没开始读源码,就有收获,开心!

带着愉快的心情继续往下看,明明是一个库,但是能打包出g-canvas,g-svg供G6使用,果不其然在devDependencies发现了

"lerna": "^3.4.3",

接下来在扫描下哪些库看起来比较陌生

"benchmark": "^2.1.4",
"prettier": "^1.18.2",

以上这都是什么鬼?

benchmark:A benchmarking library that supports high-resolution timers & returns statistically significant results. 高分辨率计时器的基准库,所以这是G6 canvas动画更流畅的原因么?

prettier:Prettier is an opinionated code formatter. It enforces a consistent style by parsing your code and re-printing it with its own rules that take the maximum line length into account, wrapping code when necessary. 欧呦,还有这种美化代码的工具,学到了学到了,mark。

OK,既然是g-canvas那么我们还是直入正题来到g-canvas的部分

import * as Shape from './shape';
const pkg = require('../package.json');

export const version = pkg.version;
export * from './types';
export * from './interfaces';
export { Event } from '@antv/g-base';
export { default as Canvas } from './canvas';
export { default as Group } from './group';
export { Shape };

入口的index.ts向我们展示了在G6中我们使用的shape,event,canvas都是出自哪里,既然是处理canvas,我们就从canvas.ts看起;

首先我们可以看到import,以及声明了Canvas类

import { AbstractCanvas } from '@antv/g-base';
import { ChangeType } from '@antv/g-base/lib/types';
import { IElement } from './interfaces';
import { getShape } from './util/hit';
import * as Shape from './shape';
import Group from './group';
import { applyAttrsToContext, drawChildren, getMergedRegion, mergeView, checkRefresh, clearChanged } from './util/draw';
import { getPixelRatio, requestAnimationFrame, clearAnimationFrame } from './util/util';

class Canvas extends AbstractCanvas {

而从代码中可以得知这里引入了抽象的AbstractCanvas,并让Canvas继承了AbstractCanvas,我们很快可以找到同一工程中的另一个packageg-base,g-base官方定义是:可视化的绘图引擎的接口定义和抽象实现。

AbstractCanvas,细节暂且不表,这个AbstractCanvas继承了AbstractCanvas,实现了ICanvas接口

import Container from './container';
import { ICanvas } from '../interfaces';

abstract class Canvas extends Container implements ICanvas {

进一步我们可以去找到Container的源码,同样我们也只关注继承关系,和实现的接口

import { IContainer, IShape, IGroup, IElement, ICanvas } from '../interfaces';
import Element from './element';

abstract class Container extends Element implements IContainer {

俄罗斯套娃一样,没事,我们继续往里看

import { IElement, IShape, IGroup, ICanvas, ICtor } from '../interfaces';
import Base from './base';

abstract class Element extends Base implements IElement {

看到了base仿佛看到了最后一层

import EE from '@antv/event-emitter';
import { IBase } from '../interfaces';

abstract class Base extends EE implements IBase {

等等说好的最后一层呢,怎么还有个EE,EE还是个新的依赖,感觉💊; 但是我还不能放弃,EE走着, 这个代码量比较少,全量贴一下;

interface EventType {
  readonly callback: Function;
  readonly once: boolean;
}

type EventsType = Record<string, EventType[]>;

const WILDCARD = '*';

/* event-emitter */
export default class EventEmitter {
  private _events: EventsType = {};

  /**
   * 监听一个事件
   * @param evt
   * @param callback
   * @param once
   */
  on(evt: string, callback: Function, once?: boolean) {
    if (!this._events[evt]) {
      this._events[evt] = [];
    }
    this._events[evt].push({
      callback,
      once: !!once,
    });
    return this;
  }

  /**
   * 监听一个事件一次
   * @param evt
   * @param callback
   */
  once(evt: string, callback: Function) {
    return this.on(evt, callback, true);
  }

  /**
   * 触发一个事件
   * @param evt
   * @param args
   */
  emit(evt: string, ...args: any[]) {
    const events = this._events[evt] || [];
    const wildcardEvents = this._events[WILDCARD] || [];

    // 实际的处理 emit 方法
    const doEmit = (es: EventType[]) => {
      let length = es.length;
      for (let i = 0; i < length; i++) {
        if (!es[i]) {
          continue;
        }
        const { callback, once } = es[i];

        if (once) {
          es.splice(i, 1);

          if (es.length === 0) {
            delete this._events[evt];
          }

          length--;
          i--;
        }

        callback.apply(this, args);
      }
    };

    doEmit(events);
    doEmit(wildcardEvents);
  }

  /**
   * 取消监听一个事件,或者一个channel
   * @param evt
   * @param callback
   */
  off(evt?: string, callback?: Function) {
    if (!evt) {
      // evt 为空全部清除
      this._events = {};
    } else {
      if (!callback) {
        // evt 存在,callback 为空,清除事件所有方法
        delete this._events[evt];
      } else {
        // evt 存在,callback 存在,清除匹配的
        const events = this._events[evt] || [];

        let length = events.length;
        for (let i = 0; i < length; i++) {
          if (events[i].callback === callback) {
            events.splice(i, 1);
            length--;
            i--;
          }
        }

        if (events.length === 0) {
          delete this._events[evt];
        }
      }
    }

    return this;
  }

  /* 当前所有的事件 */
  getEvents() {
    return this._events;
  }
}

不知道你啥么感觉,这不就是个ts写的观察者模式么,那么回头去看base.ts,扫一眼构造函数;

/**
    * @protected
    * 默认的配置项
    * @returns {object} 默认的配置项
*/
getDefaultCfg() {
    return {};
}

constructor(cfg) {
    super();
    const defaultCfg = this.getDefaultCfg();
    this.cfg = mix(defaultCfg, cfg);
}

然后再看看每一层都在用的interface.ts了解下抽象的Ibase接口,基本确定base只是提供了底层抽象,1、在当前对象上实现了事件的绑定/解绑/触发,2、初始化cfg;

再回到上一层套娃依然只是看构造函数

/**
   * @protected
   * 图形属性
   * @type {ShapeAttrs}
   */
  attrs: ShapeAttrs = {};

  constructor(cfg) {
    super(cfg);
    const attrs = this.getDefaultAttrs();
    mix(attrs, cfg.attrs);
    this.attrs = attrs;
    this.initAttrs(attrs);
    this.initAnimate(); // 初始化动画
  }

相比于base,这一层进一步的做了初始化attrs,具体attrs是什么请参看api,此外初始化了动画;接下来看内部方法,或者直接看IElement的抽象接口可知,这一层是在提取单个元素的书属性,或者是裁剪/移动/缩放/旋转,或者是直接通过矩阵转换,同时提供动画,大概率这一层是对canvas内的元素做的一层通用方法的封装,只是Container是element的一个实现;所以我们在Container.ts都没有看到构造函数,再看下IContainer的抽象接口;

export interface IContainer extends IElement {
  /**
   * 添加图形
   * @param {ShapeCfg} cfg  图形配置项
   * @returns 添加的图形对象
   */
  addShape(cfg: ShapeCfg): IShape;

  /**
   * 添加图形
   * @param {string} type 图形类型
   * @param {ShapeCfg} cfg  图形配置项
   * @returns 添加的图形对象
   */
  addShape(type: string, cfg: ShapeCfg): IShape;

  /**
   * 容器是否是 Canvas 画布
   */
  isCanvas();

  /**
   * 添加图形分组,增加一个默认的 Group
   * @returns 添加的图形分组
   */
  addGroup(): IGroup;

  /**
   * 添加图形分组,并设置配置项
   * @param {GroupCfg} cfg 图形分组的配置项
   * @returns 添加的图形分组
   */
  addGroup(cfg: GroupCfg): IGroup;

  /**
   * 添加图形分组,指定类型
   * @param {IGroup} classConstructor 图形分组的构造函数
   * @param {GroupCfg} cfg 图形分组配置项
   * @returns 添加的图形分组
   */
  addGroup(classConstructor: IGroup, cfg: GroupCfg): IGroup;

  /**
   * 根据 x,y 获取对应的图形
   * @param {number} x x 坐标
   * @param {number} y y 坐标
   * @param {Event} 浏览器事件对象
   * @returns 添加的图形分组
   */
  getShape(x: number, y: number, ev: Event): IShape;

  /**
   * 添加图形元素,已经在外面构造好的类
   * @param {IElement} element 图形元素(图形或者分组)
   */
  add(element: IElement);

  /**
   * 获取父元素
   * @return {IContainer} 父元素一般是 Group 或者是 Canvas
   */
  getParent(): IContainer;

  /**
   * 获取所有的子元素
   * @return {IElement[]} 子元素的集合
   */
  getChildren(): IElement[];

  /**
   * 子元素按照 zIndex 进行排序
   */
  sort();

  /**
   * 清理所有的子元素
   */
  clear();

  /**
   * 获取第一个子元素
   * @return {IElement} 第一个元素
   */
  getFirst(): IElement;

  /**
   * 获取最后一个子元素
   * @return {IElement} 元素
   */
  getLast(): IElement;

  /**
   * 根据索引获取子元素
   * @return {IElement} 第一个元素
   */
  getChildByIndex(index: number): IElement;

  /**
   * 子元素的数量
   * @return {number} 子元素数量
   */
  getCount(): number;

  /**
   * 是否包含对应元素
   * @param {IElement} element 元素
   * @return {boolean}
   */
  contain(element: IElement): boolean;

  /**
   * 移除对应子元素
   * @param {IElement} element 子元素
   * @param {boolean} destroy 是否销毁子元素,默认为 true
   */
  removeChild(element: IElement, destroy?: boolean);

  /**
   * 查找所有匹配的元素
   * @param  {ElementFilterFn} fn 匹配函数
   * @return {IElement[]} 元素数组
   */
  findAll(fn: ElementFilterFn): IElement[];

  /**
   * 查找元素,找到第一个返回
   * @param  {ElementFilterFn} fn 匹配函数
   * @return {IElement|null} 元素,可以为空
   */
  find(fn: ElementFilterFn): IElement;

  /**
   * 根据 ID 查找元素
   * @param {string} id 元素 id
   * @return {IElement | null} 元素
   */
  findById(id: string): IElement;

  /**
   * 该方法即将废弃,不建议使用
   * 根据 className 查找元素
   * TODO: 该方法暂时只给 G6 3.3 以后的版本使用,待 G6 中的 findByClassName 方法移除后,G 也需要同步移除
   * @param {string} className 元素 className
   * @return {IElement | null} 元素
   */
  findByClassName(className: string): IElement;

  /**
   * 根据 name 查找元素列表
   * @param {string}      name 元素名称
   * @return {IElement[]} 元素
   * 是否是实体分组,即对应实际的渲染元素
   * @return {boolean} 是否是实体分组
   */
  findAllByName(name: string): IElement[];
}

不难看出这是在做容器内的元素,元素组的增、删、清、查;

接下来我们回到canvas.ts,还是先看构造函数:

constructor(cfg: CanvasCfg) {
    super(cfg);
    this.initContainer();
    this.initDom();
    this.initEvents();
    this.initTimeline();
}

做了大量的初始化,初始化容器,初始化Dom,初始化事件,初始化时间线

然后再看看ICanvas的抽象接口

/**
 * @interface ICanvas
 * 画布,图形的容器
 */
export interface ICanvas extends IContainer {
  /**
   * 获取当前的渲染引擎
   * @return {Renderer} 返回当前的渲染引擎
   */
  getRenderer(): Renderer;

  /**
   * 为了兼容持续向上查找 parent
   * @return {IContainer} 返回元素的父容器,在 canvas 中始终是 null
   */
  getParent(): IContainer;

  /**
   * 获取画布的 cursor 样式
   * @return {Cursor}
   */
  getCursor(): Cursor;

  /**
   * 设置画布的 cursor 样式
   * @param {Cursor} cursor  cursor 样式
   */
  setCursor(cursor: Cursor);

  /**
   * 改变画布大小
   * @param {number} width  宽度
   * @param {number} height 高度
   */
  changeSize(width: number, height: number);

  /**
   * 根据事件对象获取画布坐标
   * @param  {Event} ev 事件对象
   * @return {object} 画布坐标
   */
  getPointByEvent(ev: Event): Point;

  /**
   * 根据事件对象获取窗口坐标
   * @param  {Event} ev 事件对象
   * @return {object} 窗口坐标
   */
  getClientByEvent(ev: Event): Point;

  /**
   * 将窗口坐标转变成画布坐标
   * @param  {number} clientX 窗口 x 坐标
   * @param  {number} clientY 窗口 y 坐标
   * @return {object} 画布坐标
   */
  getPointByClient(clientX: number, clientY: number): Point;

  /**
   * 将 canvas 坐标转换成窗口坐标
   * @param {number} x canvas 上的 x 坐标
   * @param {number} y canvas 上的 y 坐标
   * @returns {object} 窗口坐标
   */
  getClientByPoint(x: number, y: number): Point;

  /**
   * 绘制
   */
  draw();
}

到了这一层是不是看到了之前canvas学习笔记中耳熟能详的东西,获取画布坐标,获取窗口坐标,画布/坐标的窗口转换,绘图表面那一节我们有提到;

结合canvas实现的初始化方法,基本已经理清了了g-canvas中对于g-base一侧依赖路径,具体实现细节暂且按下不表,且待下回分解;