与古老的 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 图形树的结构如下:

  1. Stage
  2. ├── Layer
  3. | ├── Group
  4. | └── Shape
  5. | └── Group
  6. | ├── Shape
  7. | └── Group
  8. | └── Shape
  9. └── Layer
  10. └── Shape
  11. 复制代码

上下文扩展

可以使用 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) {

  if (this._context.setLineDash) {
    this._context.setLineDash(a0);
  } else if ('mozDash' in this._context) {

    (this._context['mozDash']) = a0;
  } else if ('webkitLineDash' in this._context) {

    (this._context['webkitLineDash']) = a0;
  }

}
复制代码

为了 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);
    });
    }
    复制代码
    
  1. 触发回调时添加 mousemove 事件监听
    _handleMouseDown(e) {
    window.addEventListener('mousemove', this._handleMouseMove);
    window.addEventListener('touchmove', this._handleMouseMove);
    }
    复制代码
    
  1. 计算移动的变化量,更新需要变动的控制器位置 ``` _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 渲染中重绘

this.getLayer().batchDraw();

batchDraw() { if (!this._waitingForDraw) { this._waitingForDraw = true; Util.requestAnimFrame(() => { this.draw(); this._waitingForDraw = false; }); } return this; } 复制代码




<a name="6d3b6d8b"></a>
## 交互事件处理

<a name="7692bbdb"></a>
### 目标检测

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

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

1. 
Stage::_mousedown => Stage::getIntersection
<br />在最上层的 Stage 上监听鼠标事件,根据光标位置及传入的选择器从最上层的 layer 中查找目标图形

for (n = end; n >= 0; n—) { shape = layers[n].getIntersection(pos, selector); if (shape) { return shape; } } 复制代码



2. 
Layer::getIntersection

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;

if (shape && selector) { return shape.findAncestor(selector, true); } else if (shape) { return shape; } } 复制代码



3. 
Layer::_getInersection 目标检测中**最核心**的部分在这里

var p = this.hitCanvas.context.getImageData(Math.round(pos.x ratio), Math.round(pos.y ratio), 1, 1).data;

var colorKey = Util._rgbToHex(p[0], p[1], p[2]);

var shape = shapes[‘#’ + colorKey];

if (shape) { return { shape: shape }; } 复制代码



4. 
Stage::targetShape
<br />得到 targetShape 后,就会触发各种交互事件了

this.targetShape._fireAndBubble(SOME_MOUSE_EVENT, { evt: evt, pointerId }); 复制代码




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

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

while (true) { key = Util.getRandomColor(); if (key && !(key in shapes)) { break; } }

this.colorKey = key;

shapes[key] = this; 复制代码



- 
当将图形添加到 layer 上后,执行 layer.draw() 时会绘制它的 SceneCanvas 和**HitCanvas**

draw() { this.drawScene(); this.drawHit(); return this; }

this._drawChildren(canvas, ‘drawHit’, top, false, caching, caching);

this.children.each(function(child) {

childdrawMethod; });

drawHit(can) {

var drawFunc = this.hitFunc() || this.sceneFunc(); context.save(); 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();

this.setAttr('fillStyle', shape.colorKey);
shape._fillFuncHit(this); 
this.restore();

} } 复制代码


<a name="c5424e45"></a>
## 元素渲染处理

<a name="ecff77a8"></a>
### 使用

以在 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()`

<a name="b6724cff"></a>
### 原理

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

add(layer) {

super.add(layer);

layer._setCanvasSize(this.width(), this.height());

layer.draw();

if (Konva.isBrowser) {

this.content.appendChild(layer.canvas._canvas);

} } 复制代码


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

add(…children: ChildType[]) { var child = arguments[0];

if (child.getParent()) { child.moveTo(this); return this; } var _children = this.children;

this._validateAdd(child); child.index = _children.length; child.parent = this;

_children.push(child); } 复制代码


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

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

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 进行了重写,并得益于编辑器与代码辅助工具,不管是阅读源码还是使用都较为方便。

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

参考