与古老的Fabric相比,Konva的使用更为便捷,性能更加优益,这些得益于其内部的种种设计,本次经过如下几个方面来对其进行分析:html
系列:node
Konva的自我简介是:一个经过扩展2d上下文,使其功能在桌面和移动端都可交互的canvas库,包含高性能的动画、变换、节点嵌套、事件处理、分层等等。git
Konva源自Eric的KineticJS项目,年龄比fabric要小一点,在19年初进行了部分重构,使用TypeScript进行了改写,走上了现代化建设的道路。如今看来虽然是用ts写了但因为要保存API的一致性,在一些奇怪的地方能够看到历史的影子。github
本文所用Konva.js版本为4.1.0web
先来从一个例子来看看它的用法canvas
See the Pen konva-base-and-custom-elements by yrq110 (@yrq110) on CodePen. 数组
可使用一些内置的图形元素,如矩形,圆形等等,也能够自定义图形。缓存
在自定义图形时,须要实现它的绘制方法sceneFunc
,并能够经过实现hitFunc
来自定义它的碰撞检测区域,后者是fabric中所没有的。bash
Konva中设计了多种不一样的基础元素来管理canvas的层级与图形,可使用这些元素构成一个可嵌套的图层树。app
其中:
<canvas>
元素,场景层(scene graph)与交互层(hit graph)
一颗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实现
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') {
// ...
}
复制代码
经过计算变化后的控制器位置造成的区域,获得节点须要适应的变换后区域
_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为例,来看看层级渲染的处理。
在界面上显示一个图形能够用下面步骤:
let stage = new Konva.Stage()
let layer = new Konva.Layer()
let box = new Konva.Rect()
layer.add(box)
stage.add(layer)
以后就会看到一个矩形显示在界面上。
若此时在layer上添加了新的图形: layer.add(new_box)
,能够看到新的图形并无展现出来,须要在执行一次layer.draw()
。若是在上面步骤的基础上修改次序,要达到一样的效果,就变成了:
let stage = new Konva.Stage()
let layer = new Konva.Layer()
stage.add(layer)
let box = new Konva.Rect()
layer.add(box)
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进行了重写,并得益于编辑器与代码辅助工具,不论是阅读源码仍是使用都较为方便。
因为自身在实现业务时是写的原生,某些部分的实现与这些框架的思路也不谋而合,不过更多的地方仍是框架们设计的好,值得借鉴的地方不少。