与古老的 Fabric 相比,Konva 的使用更为便捷,性能更加优益,这些得益于其内部的种种设计,本次通过以下几个方面来对其进行分析:
- 基础元素及上下文的扩展
- 图形变换处理 (变换计算及独立的图形控制器)
- 光标交互处理 (基于像素的目标检测)
- 层级渲染处理
系列目录
- Canvas2D 渲染库简析:(一)Fabric
- Canvas2D 渲染库简析:(二)Konva
- Canvas2D 渲染库简析:(三)Pixi
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中可以添加Shape或Group元素
- 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) {
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:SceneContext与HitContext
两者是绑定于 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
});
}
复制代码
其次是事件监听与变换过程
- 初始化时在每个控制器上添加 mousedown 事件监听
_createAnchor(name) { var anchor = new Rect({...}); var self = this; anchor.on('mousedown touchstart', function(e) { self._handleMouseDown(e); }); } 复制代码
- 触发回调时添加 mousemove 事件监听
_handleMouseDown(e) { window.addEventListener('mousemove', this._handleMouseMove); window.addEventListener('touchmove', this._handleMouseMove); } 复制代码
计算移动的变化量,更新需要变动的控制器位置 ``` _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 进行了重写,并得益于编辑器与代码辅助工具,不管是阅读源码还是使用都较为方便。
由于自身在实现业务时是写的原生,某些部分的实现与这些框架的思路也不谋而合,不过更多的地方还是框架们设计的好,值得借鉴的地方很多。
