与古老的Fabric相比,Konva的使用更为便捷,性能更加优益,这些得益于其内部的种种设计,本次通过以下几个方面来对其进行分析:

  • 基础元素及上下文的扩展
  • 图形变换处理(变换计算及独立的图形控制器)
  • 光标交互处理(基于像素的目标检测)
  • 层级渲染处理

Konva.js

Konva的自我简介是:一个通过扩展2d上下文,使其功能在桌面和移动端均可交互的canvas库,包含高性能的动画、变换、节点嵌套、事件处理、分层等等。

Konva源自Eric的KineticJS项目,年龄比fabric要小一点,在19年初进行了部分重构,使用TypeScript进行了改写,走上了现代化建设的道路。现在看来虽然是用ts写了但由于要保存API的一致性,在一些奇怪的地方可以看到历史的影子。

本文所用Konva.js版本为4.1.0

基础元素及上下文扩展

元素的使用及自定义

先来从一个例子来看看它的用法

See the Pen konva-base-and-custom-elements by yrq110 (@yrq110) on CodePen.

可以使用一些内置的图形元素,如矩形,圆形等等,也可以自定义图形。

自定义图形时,需要实现它的绘制方法sceneFunc,并可以通过实现hitFunc来自定义它的碰撞检测区域,后者是fabric中所没有的。

基础元素

Konva中设计了多种不同的基础元素来管理canvas的层级与图形,可以使用这些元素构成一个可嵌套的图层树。

其中:

  • Stage中包含多个绘图层Layer
  • Layer中可以添加ShapeGroup元素
  • Shape为最细粒度的元素,即具体的图形对象
  • Group为容器元素,用于管理多个Shape或其他Group
  • 每个Layer在内部包含两个<canvas>元素,场景层(scene graph)与交互层(hit graph)
    • 场景层包含绘制的图形,即实际看到的图形
    • 交互层用于高性能的交互事件检测
  • 以上元素的基类均为Node

一颗Konva图形树的结构如下:

Stage
├── Layer
|   ├── Group
|       └── Shape
|   └── Group
|       ├── Shape
|       └── Group
|           └── Shape
└── Layer
    └── Shape

上下文扩展

可以使用canvas的2d上下文来操作包含样式、变换和的剪裁等属性的状态栈。Konva在上下文对象上做了一些封装,包括API的兼容性与参数处理、指定场景的属性设置等等。

API的处理:

// 直接使用
moveTo(a0, a1) {
  this._context.moveTo(a0, a1);
}
// 参数简单检查
createImageData(a0, a1) {
  var a = arguments;
  if (a.length === 2) {
    return this._context.createImageData(a0, a1);
  } else if (a.length === 1) {
    return this._context.createImageData(a0);
  }
}
// 兼容性处理
setLineDash(a0) {
  // works for Chrome and IE11
  if (this._context.setLineDash) {
    this._context.setLineDash(a0);
  } else if ('mozDash' in this._context) {
    // verified that this works in firefox
    (this._context['mozDash']) = a0;
  } else if ('webkitLineDash' in this._context) {
    // does not currently work for Safari
    (this._context['webkitLineDash']) = a0;
  }
  // no support for IE9 and IE10
}

为了SceneCanvas和HitCanvas准备特殊的Context:SceneContextHitContext

两者是绑定于Layer中SceneCanvas和HitCanvas的Context对象,继承自Context,实现了各自的_fill()_stroke()方法。如HitContext:

export class HitContext extends Context {
  _fill(shape) {
    this.save();
    this.setAttr('fillStyle', shape.colorKey);
    shape._fillFuncHit(this);
    this.restore();
  }
  _stroke(shape) {
    if (shape.hasHitStroke()) {
      this._applyLineCap(shape);
      var hitStrokeWidth = shape.hitStrokeWidth();
      var strokeWidth =
        hitStrokeWidth === 'auto' ? shape.strokeWidth() : hitStrokeWidth;
      this.setAttr('lineWidth', strokeWidth);
      this.setAttr('strokeStyle', shape.colorKey);
      shape._strokeFuncHit(this);
      if (!strokeScaleEnabled) {
        this.restore();
      }
    }
  }
}

在Canvas类中的扩展及Layer中的使用:

export class HitCanvas extends Canvas {
  hitCanvas = true;
  constructor(config: ICanvasConfig = { width: 0, height: 0 }) {
    super(config);
    this.context = new HitContext(this);
    this.setSize(config.width, config.height);
  }
}

export class Layer extends BaseLayer {
  hitCanvas = new HitCanvas({
    pixelRatio: 1
  });
}

图形变换处理

变换属性、操作与矩阵处理

与Fabric类似,也是先通过显式调用Node的变换方法或通过控制器来修改变换属性,再计算变换矩阵重新渲染。其中使用Trasnform类来管理操作与矩阵的关系。

Konva中变换属性转换为变换矩阵的过程:属性 => 变换操作 => 变换矩阵。

变换属性 => 变换操作

_getTransform(): Transform {
    var m = new Transform();
    if (x !== 0 || y !== 0) {
      m.translate(x, y);
    }
    if (rotation !== 0) {
      m.rotate(rotation);
    }
    if (scaleX !== 1 || scaleY !== 1) {
      m.scale(scaleX, scaleY);
    }
    // ...
    return m;
}

变换操作 => 变换矩阵

export class Transform {
  m: Array<number>;
  constructor(m = [1, 0, 0, 1, 0, 0]) {
    this.m = (m && m.slice()) || [1, 0, 0, 1, 0, 0];
  }
  translate(x: number, y: number) {
    this.m[4] += this.m[0] * x + this.m[2] * y;
    this.m[5] += this.m[1] * x + this.m[3] * y;
    return this;
  }
  scale(sx: number, sy: number) {
    this.m[0] *= sx;
    this.m[1] *= sx;
    this.m[2] *= sy;
    this.m[3] *= sy;
    return this;
  }
  // ...
}

图形控制器的变换处理

控制器使用独立于Node元素之外的Transformer实现

See the Pen konva-control by yrq110 (@yrq110) on CodePen.

用法是:先创建一个Transformer对象,再使用attachTo()绑定到需要控制的Shape上。

与Fabric中的控制器相比,不仅是使用方法不同,其中的内部处理很大区别,处理过程大致如下:

首先是将控制器与节点绑定

attachTo(node) {
  this.setNode(node);
}
setNode(node) {
  // 绑定节点,清空缓存
  this._node = node;
  this._resetTransformCache();
  // 监听节点属性的变化,回调中更新控制器
  const onChange = () => {
    this._resetTransformCache();
    if (!this._transforming) {
      this.update();
    }
  };
  node.on(additionalEvents, onChange);
  node.on(TRANSFORM_CHANGE_STR, onChange);
}
update() {
  // ...
  // 更新每个控制器的位置等属性
  this.findOne('.top-left').setAttrs({
    x: -padding,
    y: -padding,
    scale: invertedScale,
    visible: resizeEnabled && enabledAnchors.indexOf('top-left') >= 0
  });
  // ...
}

其次是事件监听与变换过程

  1. 初始化时在每个控制器上添加mousedown事件监听

    _createAnchor(name) {
      var anchor = new Rect({...});
      var self = this;
      anchor.on('mousedown touchstart', function(e) {
       self._handleMouseDown(e);
      });
    }
    
  2. 触发回调时添加mousemove事件监听

    _handleMouseDown(e) {
      window.addEventListener('mousemove', this._handleMouseMove);
      window.addEventListener('touchmove', this._handleMouseMove);
    }
    
  3. 计算移动的变化量,更新需要变动的控制器位置

    _handleMouseMove(e) {
      // ...
      if (this._movingAnchorName === 'bottom-center') {
        this.findOne('.bottom-right').y(anchorNode.y());
      } else if (this._movingAnchorName === 'bottom-right') {
        if (keepProportion) {
          newHypotenuse = Math.sqrt( Math.pow(this.findOne('.bottom-right').x() - padding, 2) + Math.pow(this.findOne('.bottom-right').y() - padding, 2));
          var reverseX = this.findOne('.top-left').x() > this.findOne('.bottom-right').x() ? -1 : 1;
          var reverseY = this.findOne('.top-left').y() > this.findOne('.bottom-right').y() ? -1 : 1;
          x = newHypotenuse * this.cos * reverseX;
          y = newHypotenuse * this.sin * reverseY;
          this.findOne('.bottom-right').x(x + padding);
          this.findOne('.bottom-right').y(y + padding);
        }
      } else if (this._movingAnchorName === 'rotater') {
      // ...
    }
    
  4. 通过计算变化后的控制器位置形成的区域,得到节点需要适应的变换后区域

    _handleMouseMove(e) {
      // ...
      x = absPos.x;
      y = absPos.y;
      var width = this.findOne('.bottom-right').x() - this.findOne('.top-left').x();
      var height = this.findOne('.bottom-right').y() - this.findOne('.top-left').y();
      this._fitNodeInto(
        {
          x: x + this.offsetX(),
          y: y + this.offsetY(),
          width: width,
          height: height
        },
        e
      );
    }
    
  5. 根据这个区域计算变化后的节点尺寸与位置属性

    this.getNode().setAttrs({
      scaleX: scaleX,
      scaleY: scaleY,
      x: newAttrs.x - (dx * Math.cos(rotation) + dy * Math.sin(-rotation)),
      y: newAttrs.y - (dy * Math.cos(rotation) + dx * Math.sin(rotation))
    });
    
  6. 在下一次rAF渲染中重绘

    // src/shapes/Transformer.ts
    this.getLayer().batchDraw();
    // src/BaseLayer.ts
    batchDraw() {
      if (!this._waitingForDraw) {
        this._waitingForDraw = true;
        Util.requestAnimFrame(() => {
          this.draw();
          this._waitingForDraw = false;
        });
      }
      return this;
    }
    

交互事件处理

目标检测

konva中判断光标与图形的碰撞使用了基于像素的方法,并非几何判断。

目标检测的主要流程如下:

  1. Stage::_mousedown => Stage::getIntersection

    在最上层的Stage上监听鼠标事件,根据光标位置及传入的选择器从最上层的layer中查找目标图形

    for (n = end; n >= 0; n--) {
      shape = layers[n].getIntersection(pos, selector);
      if (shape) {
        return shape;
      }
    }
    
  2. Layer::getIntersection

    // 使用INTERSECTION_OFFSETS扩展光标的范围,使其易于产生相交情况
    for (i = 0; i < INTERSECTION_OFFSETS_LEN; i++) {
      intersectionOffset = INTERSECTION_OFFSETS[i];
      // 计算得到相交对象
      obj = this._getIntersection({
        x: pos.x + intersectionOffset.x * spiralSearchDistance,
        y: pos.y + intersectionOffset.y * spiralSearchDistance
      });
      shape = obj.shape;
      // 若存在图形且包含元素选择器,则向其祖先查找,如'Group',否则直接返回图形
      if (shape && selector) {
        return shape.findAncestor(selector, true);
      } else if (shape) {
        return shape;
      }
    }
    
  3. Layer::_getInersection 目标检测中最核心的部分在这里

    // 取得hitCanvas上下文中光标位置的像素值
    var p = this.hitCanvas.context.getImageData(Math.round(pos.x * ratio), Math.round(pos.y * ratio), 1, 1).data;
    // 将rga转换为hex,与shape的colorKey比较
    var colorKey = Util._rgbToHex(p[0], p[1], p[2]);
    // shapes中包含所有添加过的图形对象,每个图形用一个随机hex颜色表示它的key
    var shape = shapes['#' + colorKey];
    // 若hit graph中当前位置的颜色与某个图形的代表颜色相同,则该图形为光标命中的对象
    if (shape) { return { shape: shape }; }
    
  4. Stage::targetShape

    得到targetShape后,就会触发各种交互事件了

    this.targetShape._fireAndBubble(SOME_MOUSE_EVENT, { evt: evt, pointerId });
    

要达到通过比较hit graph上光标位置与代表图形key的像素值是否相同来判断是否命中的目的,需要事先在layer的HitCanvas上画出Shape对象的hit graph,在这一部分做了以下工作:

  • 在创建图形时,生成该图形的唯一key,即随机颜色

    // 生成唯一key
    while (true) {
      key = Util.getRandomColor();
      if (key && !(key in shapes)) { break; }
    }
    // 保存颜色,用于之后的hit graph绘制
    this.colorKey = key;
    // 将该对象保存在shapes对象中,用于目标检测时的查询
    shapes[key] = this;
    
  • 当将图形添加到layer上后,执行layer.draw()时会绘制它的SceneCanvas和HitCanvas

    // Layer::draw() => Node::draw()
    draw() {
      this.drawScene();
      this.drawHit();
      return this;
    }
    // Layer::drawHit() => Container::drawHit(), Container继承自Node,实现了抽象类drawHit()
    this._drawChildren(canvas, 'drawHit', top, false, caching, caching);
    // Container::_drawChildren()
    this.children.each(function(child) {
      // 在每一个子元素上执行drawHit(),子元素为Shape或Group类型
      child[drawMethod](canvas, top, caching, skipBuffer);
    });
    // Shape::drawHit
    drawHit(can) {
      // 获取内置或自定义Shape对象中实现的_hitFunc或_sceneFunc
      var drawFunc = this.hitFunc() || this.sceneFunc();
      context.save(); // 这里的context为HitContext对象
      layer._applyTransform(this, context, top);
      drawFunc.call(this, context, this);
      context.restore();
    }
    

此时还有一个问题,就是在绘制HitCanvas时并没有体现出使用了colorKey的颜色去绘制,其实这个fillStyle的设置操作在之前出现过,在HitContext类中:

export class HitContext extends Context {
  _fill(shape) {
    this.save();
    // 在这里设置hit graph的填充样式
    this.setAttr('fillStyle', shape.colorKey);
    shape._fillFuncHit(this); // => this.fill()
    this.restore();
  }
}

元素渲染处理

使用

以在Stage上添加一个Layer和一个Shape为例,来看看层级渲染的处理。

在界面上显示一个图形可以用下面步骤:

  1. 创建一个Stage let stage = new Konva.Stage()
  2. 创建一个Layer let layer = new Konva.Layer()
  3. 创建一个Shape let box = new Konva.Rect()
  4. 在Layer上添加Shape layer.add(box)
  5. 在Stage上添加Layer stage.add(layer)

之后就会看到一个矩形显示在界面上。

若此时在layer上添加了新的图形: layer.add(new_box),可以看到新的图形并没有展示出来,需要在执行一次layer.draw()。如果在上面步骤的基础上修改次序,要达到同样的效果,就变成了:

  1. 创建一个Stage let stage = new Konva.Stage()
  2. 创建一个Layer let layer = new Konva.Layer()
  3. 在Stage上添加Layer stage.add(layer)
  4. 创建一个Shape let box = new Konva.Rect()
  5. 在Layer上添加Shape layer.add(box)
  6. 执行Layer的绘制 layer.draw()

原理

Stage的add方法中,绘制了layer内容,并将layer的SceneCanvas元素插入到DOM树中

add(layer) {
  // 在父类Container中处理layer的当前父子关系等
  super.add(layer);
  // 设置当前尺寸
  layer._setCanvasSize(this.width(), this.height());
  // 绘制layer中的内容
  layer.draw();
  // 将Canvas元素插入到DOM树中
  if (Konva.isBrowser) {
    // 这里仅添加了SceneCanvas,而没有添加HitCanvas
    this.content.appendChild(layer.canvas._canvas);
  }
}

Layer并没有实现自身的add方法,默认执行Container中的add方法

add(...children: ChildType[]) {
   var child = arguments[0];
   // 1. 处理父子关系,若已有父辈,则"领养"
   if (child.getParent()) {
     child.moveTo(this);
     return this;
   }
   var _children = this.children;
   // 2. 验证child可用性,该方法为子类实现
   this._validateAdd(child);
   child.index = _children.length;
   child.parent = this;
   // 3. 保存到children数组中
   _children.push(child);
}

关于Layer的draw()方法的执行在上面目标检测的部分刚刚提到过,会依次执行children中每个child的相关绘制方法。

需要注意一点的是: 当在Stage对象上执行draw()时,会清空并重绘所有Layer的内容,这是由于Layer作为Stage的child,在执行它的drawScene方法时会根据其clearBeforeDraw属性(默认为true)来清空内容,之后再执行绘制。

// src/Layer.ts
drawScene(can, top) {
  var layer = this.getLayer(),
    canvas = can || (layer && layer.getCanvas());  
  if (this.clearBeforeDraw()) {
    canvas.getContext().clear();
  }
  Container.prototype.drawScene.call(this, canvas, top);
  return this;
}

这样应该就明白了,在layer上添加图形时并没有实际执行绘制,因此当layer包含的图形变化时需要手动执行draw()才有效果,而将layer添加到stage时,stage的内部自动执行了Layer对象的draw(),因此不需要显式的调用。

最后

Konva的主要模块虽然也是多年前的设计,但个人觉得模块化做的较Fabric更好,不管是更灵活的层级管理还是组件自定义的方面。其次,由于使用ts进行了重写,并得益于编辑器与代码辅助工具,不管是阅读源码还是使用都较为方便。

由于自身在实现业务时是写的原生,某些部分的实现与这些框架的思路也不谋而合,不过更多的地方还是框架们设计的好,值得借鉴的地方很多。

参考