konva 简介
Konva是一个基于2d的canvas类库,其功能在桌面端和移动端均可交互,包含常用图形组件、事件系统、变换、高性能的动画、节点嵌套、分层等。
konva与fabricjs都是比较优秀的高性能2d渲染库,同时也都适用于编辑器场景,在使用、设计、性能等方面各有优劣,下面对其内部进行剖析。
konva 架构
从下图可以看出konva的代码结构设计基于图形树,类似DOM结构,通过add和remove实现增删节点
其中Tree包括几个主要部分:
- Stage中包含多个绘图层Layer
- Layer中可以添加Shape或Group元素
- Shape为最细粒度的元素,即具体的图形对象,包括Text/Image/Rect/Circle等
- Stage、Layer、Group都属于容器元素,继承于Container,Group用于管理多个Shape或其他Group
- 每个Layer在内部包含两个元素,场景层(SceneCanvas)与交互层(HitCanvas)
- 场景层 - 包含绘制的图形,即实际看到的图形
- 交互层 - 用于高性能的交互事件检测
SceneContext与HitContext
在konva中设计了两个重要的Canvas,分别是SceneCanvas和HitCanvas,一个Layer绑定了一个SceneCanvas和一个HitCanvas,他们的Context对象,都继承自Context。
SceneContext与HitContext实现了各自的_fill()与_stroke()方法, HitContext如下
kotlin
export class HitContext extends Context {
_fill(shape) {
this.save();
// 为当前绘制图形设置随机色
this.setAttr('fillStyle', shape.colorKey);
// 回调当前绘制图形的自定义_fillFuncHit方法
shape._fillFuncHit(this);
this.restore();
}
strokeShape(shape: Shape) {
if (shape.hasHitStroke()) {
this._stroke(shape);
}
}
_stroke(shape) {
if (shape.hasHitStroke()) {
// ignore strokeScaleEnabled for Text
var strokeScaleEnabled = shape.getStrokeScaleEnabled();
if (!strokeScaleEnabled) {
this.save();
var pixelRatio = this.getCanvas().getPixelRatio();
this.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
}
this._applyLineCap(shape);
var hitStrokeWidth = shape.hitStrokeWidth();
var strokeWidth =
hitStrokeWidth === 'auto' ? shape.strokeWidth() : hitStrokeWidth;
this.setAttr('lineWidth', strokeWidth);
this.setAttr('strokeStyle', shape.colorKey);
// 回调当前绘制图形的自定义_strokeFuncHit方法
shape._strokeFuncHit(this);
if (!strokeScaleEnabled) {
this.restore();
}
}
}
}
拾取方案
由于 Canvas 不会保存绘制图形的信息,一旦绘制完成用户在浏览器中得到的是一张图片,用户在图片上点击时时不能获取对应的图形信息,所以需要缓存图形的信息,根据用户点击的位置进行判断击中了那些图形。常见的拾取方案有以下几种:
- 使用缓存 Canvas 通过颜色拾取图形
- 使用 Canvas 内置的 API 拾取图形
- 使用几何运算拾取图形
- 混杂上面的几种方式来拾取图形
拾取的主要流程如下:
- Stage::_mousedown => Stage::getIntersection 在最上层的Stage上监听鼠标事件,根据光标位置及传入的选择器从最上层的layer中查找目标图形
ini
for (n = end; n >= 0; n--) {
shape = layers[n].getIntersection(pos, selector);
if (shape) {
return shape;
}
}
- Layer::getIntersection
ini
// 使用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
css
// 取得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.得到目标图形后,就会触发各种交互事件了
kotlin
this.targetShape._fireAndBubble(SOME_MOUSE_EVENT, { evt: evt, pointerId });
在 SceneCanvas 上绘制图形的同时,会在缓存(隐藏)的 HitCanvas 上也绘制一遍相应的图形,使用随机的图形索引值作为图形的颜色来绘制图形
kotlin
// Shape::contructor
// 生成唯一key
while (true) {
key = Util.getRandomColor();
if (key && !(key in shapes)) { break; }
}
// 保存颜色,用于之后的hit graph绘制
this.colorKey = key;
// 将该对象保存在shapes对象中,用于目标检测时的查询
shapes[key] = 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();
}
小结一下上面的流程:
- 在 SceneCanvas 上绘制图形
- 在缓存(隐藏)的 HitCanvas 上重新绘制一下所有的图形,使用随机的图形索引值作为图形的颜色来绘制图形
- 当在画布进行点击,获取 HitCanvas 上对应位置的1x1像素点,通过getImageData获取像素数据转成16进制颜色数据,以这个16进制颜色作为索引值,去获取具体的目标图形。
Node 类
konva的Node同fabric的object,是konva tree的底层超级聚类,其中包括
- 图形包围盒getClientRect
- 缓存
- 事件处理on、off、fire等
- 设置、获取属性setAttrs、getAttrs
- 导出toDataURL
- 滤镜Filters
- 拖拽Drag
- ...
等众多方法,所有konva节点最终都继承Node,下面我们来看下具体的渲染流程。
渲染流程
先来回顾一下konva在界面上渲染一个图形的开发流程
ini
let stage = new Konva.Stage({});
let layer = new Konva.Layer();
let rect = new Konva.Rect({});
layer.add(rect);
stage.add(layer);
如果需要添加新的图形,需要执行下列代码
ini
let rect2 = new Konva.Rect({});
layer.add(rect2);
layer.draw();
在调用 Stage.add 的时候,不仅会调用 Layer 的绘制方法draw,还会把 Layer 的 Canvas 节点 append 进去。
按照上面 代码可以看出,在调用 Stage.add 的时候,不仅会调用 Layer 的绘制方法draw,还会把 Layer 的 Canvas 节点 append 进去。接下来分别看下Stage::add和Layer::draw方法:
javascript
/**
* Staege::add
*/
add(layer: Layer, ...rest) {
// 遍历Stage下所有Layer,并执行其add方法
if (arguments.length > 1) {
for (var i = 0; i < arguments.length; i++) {
this.add(arguments[i]);
}
return this;
}
// 在父类Container中调用add方法
super.add(layer);
// ...
// 一次性将layer中的内容,输出到canvas进行绘制
layer.draw();
// 这里仅添加了SceneCanvas
this.content.appendChild(layer.canvas._canvas);
// ...
}
由上述代码块可看出,stage.add最终也会调用Layer::draw,也就是所有绘制最后都会经过layer.draw,但Layer类上并没有draw方法,而是在抽象类Node上,如下:
csharp
/**
* Layer.draw -> Node::draw
*/
draw() {
// drawScene - 绘制主逻辑
this.drawScene();
this.drawHit();
return this;
}
// 定义抽象方法drawScene,由子类实现
abstract drawScene(canvas?: Canvas, top?: Node): void;
// 定义抽象方法drawHit,由子类实现
abstract drawHit(canvas?: Canvas, top?: Node): void;
draw方法随后会调用drawScene和drawHit,Layer.drawScene -> Container.drawScene -> forEach children -> Shape.drawScene
kotlin
/**
* Layer::drawScene
*/
drawScene(can?: SceneCanvas, top?: Node) {
var layer = this.getLayer(),
canvas = can || (layer && layer.getCanvas());
this._fire(BEFORE_DRAW, {
node: this,
});
if (this.clearBeforeDraw()) {
canvas.getContext().clear();
}
// 调用Container类上的drawScene方法
Container.prototype.drawScene.call(this, canvas, top);
this._fire(DRAW, {
node: this,
});
return this;
}
kotlin
/**
* Container::drawScene
*/
drawScene(can?: SceneCanvas, top?: Node) {
var layer = this.getLayer(),
canvas = can || (layer && layer.getCanvas()),
context = canvas && canvas.getContext(),
cachedCanvas = this._getCanvasCache(),
cachedSceneCanvas = cachedCanvas && cachedCanvas.scene;
// ...
if (cachedSceneCanvas) {
// ...
// 绘制缓存SceneCanvas
this._drawCachedSceneCanvas(context);
// ...
} else {
// 去Layer下遍历子类(Group、Image、Text等)的drawScene方法,drawScene在Shape类上
this._drawChildren('drawScene', canvas, top);
}
return this;
}
_drawChildren(drawMethod, canvas, top) {
// ...
// 去Layer下遍历子类(Group、Image、Text等)调用其drawScene方法,drawScene在Shape类上
this.children?.forEach(function (child) {
child[drawMethod](canvas, top);
});
// ...
}
按照上面两个类的drawScene内部实现可以看出,最后都会走到Container类的drawScene方法中,然后再去执行所有需要绘制的具体图形类(Image、Text等)的drawScene方法,但实际上Image、Text类上并没有drawScene方法,而是在他们继承的父类Shape.drawScene上,实现如下:
kotlin
/**
* Shape::drawScene
*/
drawScene(can?: SceneCanvas, top?: Node) {
var layer = this.getLayer(),
canvas = can || layer.getCanvas(),
context = canvas.getContext() as SceneContext,
cachedCanvas = this._getCanvasCache(),
drawFunc = this.getSceneFunc(),
hasShadow = this.hasShadow(),
stage,
bufferCanvas,
bufferContext;
// ...
// 如果有缓存场景,则调用Node::_drawCachedSceneCanvas方法,去执行drawImage
if (cachedCanvas) {
// ...
this._drawCachedSceneCanvas(context);
return this;
}
// ...
// if buffer canvas is needed
if (this._useBufferCanvas() && !skipBuffer) {
stage = this.getStage();
bufferCanvas = stage.bufferCanvas;
bufferContext = bufferCanvas.getContext();
// ...
drawFunc.call(this, bufferContext, this);
// ...
// 通过drawImage绘制到canvas上
context.drawImage(
bufferCanvas._canvas,
0,
0,
bufferCanvas.width / ratio,
bufferCanvas.height / ratio
);
} else {
// ...
// 调用相应Shape类的_sceneFunc方法
drawFunc.call(this, context, this);
}
context.restore();
return this;
}
getSceneFunc() {
return this.attrs.sceneFunc || this['_sceneFunc'];
}
Shape.drawScene方法的核心逻辑很简单,判断如果有缓存SceneCanvas就通过_drawCachedSceneCanvas方法执行drawImage绘制画布,另一种情况是直接执行drawImage绘制画布,而在drawImage前会先通过上面代码中的drawFunc(也就是具体Shape类的_sceneFunc)进行绘制前的描边、填充等操作。
整个渲染流程如下图:
属性更新流程
konva中的Factory模块虽然很简单,也是一个很基础核心的底层模块,图形类在定义属性时都用到了Factory模块中的方法:
scala
import { Factory } from './Factory';
// ...
export abstract class Node<Config extends NodeConfig = NodeConfig> {
// ...
}
const addGetterSetter = Factory.addGetterSetter;
addGetterSetter(Node, 'position');
addGetterSetter(Node, 'opacity', 1, getNumberValidator());
addGetterSetter(Node, 'width', 0, getNumberValidator());
addGetterSetter(Node, 'height', 0, getNumberValidator());
addGetterSetter(Node, 'filters', null, function (val) {
this._filterUpToDate = false;
return val;
});
// ...
从字面上来看,上面代码调用了Factory中的addGetterSetter方法为Node类绑定了很多getter和setter,如position、opacity、width、height、filters等。按照现代es6编写getter和setter方式应该是直接在class中加入get xxx和set xxx,猜想这么做主要还是由于历史原因,konva本身源自Eric的KineticJS项目,打开KineticJS的源码一看便知,时间比较久远,当时的项目还是es3定义类的编程方式。 然后,看下src/Factory.ts中的方法,下面注释刚刚看到的 addGetterSetter(Node, 'width', 0, getNumberValidator()) 为例
kotlin
var GET = 'get',
SET = 'set';
export const Factory = {
/**
* addGetterSetter 模拟es6的getter和setter
* @param constructor Node类
* @param attr 'width'
* @param def '0'
* @param validator def参数的数据类型检查器
* @param after 设置属性之后的回调事件
*/
addGetterSetter(constructor, attr, def?, validator?, after?) {
// 添加getXXX方法
Factory.addGetter(constructor, attr, def);
// 添加setXXX方法
Factory.addSetter(constructor, attr, validator, after);
// 添加getter和setter方法
Factory.addOverloadedGetterSetter(constructor, attr);
},
// 设置getter
addGetter(constructor, attr, def?) {
// method = getWidth
var method = GET + Util._capitalize(attr);
// 为Node类的原型对象添加getWidth方法,这里有个很关键的判断,如果Node类上定义了getWidth方法直接使用,没定义则使用下面的函数
constructor.prototype[method] =
constructor.prototype[method] ||
function () {
var val = this.attrs[attr];
// 初始化时,获取def,例:addGetterSetter(Node, 'width', 0, getNumberValidator())
// 更新时,获取val,例:node.width(10)
return val === undefined ? def : val;
};
},
// 设置setter
addSetter(constructor, attr, validator?, after?) {
// method = setWidth
var method = SET + Util._capitalize(attr);
// 如果Node类中没有定义setWidth,则调用overWriteSetter重写setWidth
if (!constructor.prototype[method]) {
Factory.overWriteSetter(constructor, attr, validator, after);
}
},
// 重写setter,绑定到原型对象上
overWriteSetter(constructor, attr, validator?, after?) {
// method = setWidth
var method = SET + Util._capitalize(attr);
constructor.prototype[method] = function (val) {
// 检查更新时传入值得数据类型,不符合则抛错
if (validator && val !== undefined && val !== null) {
val = validator.call(this, val, attr);
}
// 更新node.attrs上属性,触发on('widthChange', xxx)事件,最终调用到Shape::drawScene重绘
this._setAttr(attr, val);
// 事后的回调,上面绑定filters时有用到
if (after) {
after.call(this);
}
return this;
};
},
// ...
// 设置getter和setter方法
addOverloadedGetterSetter(constructor, attr) {
var capitalizedAttr = Util._capitalize(attr),
setter = SET + capitalizedAttr, // setWidth
getter = GET + capitalizedAttr; // getWidth
// 为Node类的原型对象设置width方法,有传参则为setter,未传参则为getter
constructor.prototype[attr] = function () {
// node.width(10)
if (arguments.length) {
this[setter](arguments[0]);
return this;
}
// node.width()
return this[getter]();
};
},
// ...
};
小结一下上面源码,以绑定position为例,调用Factory.addGetterSetter后做了三件事 1.添加setPosition方法,若Node类有定义setPosition,则直接执行,没有定义则调用_setAttr方法 2.添加getPosition方法,同上,若Node类有定义getPosition,则直接执行,没有则绑定function 3.模拟getter和setter方法
这里的属性更新会统一调用到Node._setASttr方法,这个方法除了更新node.attrs.xxx的值,还有一个重要的操作,就是执行Node._requireDraw,再通过requestAnimationFrame批量重绘当前这一帧要更新的所有属性。
kotlin
// Node::_requestDraw
_requestDraw() {
if (Konva.autoDrawEnabled) {
const drawNode = this.getLayer() || this.getStage();
drawNode?.batchDraw();
}
}
// Layer::batchDraw
batchDraw() {
if (!this._waitingForDraw) {
this._waitingForDraw = true;
Util.requestAnimFrame(() => {
this.draw();
this._waitingForDraw = false;
});
}
return this;
}