Canvas2D渲染库简析:(二)Konva
与古老的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
基础元素及上下文扩展
元素的使用及自定义
先来从一个例子来看看它的用法
可以使用一些内置的图形元素,如矩形,圆形等等,也可以自定义图形。
在自定义图形时,需要实现它的绘制方法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) {
// 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: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实现
用法是:先创建一个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') { // ... }
-
通过计算变化后的控制器位置形成的区域,得到节点需要适应的变换后区域
_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 ); }
-
根据这个区域计算变化后的节点尺寸与位置属性
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)) });
-
在下一次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中判断光标与图形的碰撞使用了基于像素的方法,并非几何判断。
目标检测的主要流程如下:
-
Stage::_mousedown => Stage::getIntersection
在最上层的Stage上监听鼠标事件,根据光标位置及传入的选择器从最上层的layer中查找目标图形
for (n = end; n >= 0; n--) { shape = layers[n].getIntersection(pos, selector); if (shape) { return shape; } }
-
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; } }
-
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 }; }
-
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为例,来看看层级渲染的处理。
在界面上显示一个图形可以用下面步骤:
- 创建一个Stage
let stage = new Konva.Stage()
- 创建一个Layer
let layer = new Konva.Layer()
- 创建一个Shape
let box = new Konva.Rect()
- 在Layer上添加Shape
layer.add(box)
- 在Stage上添加Layer
stage.add(layer)
之后就会看到一个矩形显示在界面上。
若此时在layer上添加了新的图形: layer.add(new_box)
,可以看到新的图形并没有展示出来,需要在执行一次layer.draw()
。如果在上面步骤的基础上修改次序,要达到同样的效果,就变成了:
- 创建一个Stage
let stage = new Konva.Stage()
- 创建一个Layer
let layer = new Konva.Layer()
- 在Stage上添加Layer
stage.add(layer)
- 创建一个Shape
let box = new Konva.Rect()
- 在Layer上添加Shape
layer.add(box)
- 执行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进行了重写,并得益于编辑器与代码辅助工具,不管是阅读源码还是使用都较为方便。
由于自身在实现业务时是写的原生,某些部分的实现与这些框架的思路也不谋而合,不过更多的地方还是框架们设计的好,值得借鉴的地方很多。
参考
- 原文作者:yrq110
- 原文链接:http://yrq110.me/post/front-end/dive-into-2d-canvas-framework-ii-konva/
- 版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议进行许可,非商业转载请注明出处(作者,原文链接),商业转载请联系作者获得授权。