konva.js 原理与源码解析

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 拾取图形
  • 使用几何运算拾取图形
  • 混杂上面的几种方式来拾取图形

拾取的主要流程如下:

  1. Stage::_mousedown => Stage::getIntersection 在最上层的Stage上监听鼠标事件,根据光标位置及传入的选择器从最上层的layer中查找目标图形
ini 复制代码
for (n = end; n >= 0; n--) {
  shape = layers[n].getIntersection(pos, selector);
  if (shape) {
    return shape;
  }
} 
  1. 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;
  }
}
  1. 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;
  }
相关推荐
某公司摸鱼前端3 天前
推荐 uniapp 相对好用的海报生成插件
小程序·uni-app·canvas
&活在当下&5 天前
js 实现视频封面截图
前端·javascript·音视频·canvas
xachary10 天前
前端使用 Konva 实现可视化设计器(23)- 绘制曲线、属性面板
前端·canvas·konva
Jiaberrr10 天前
微信小程序教程:如何在个人中心实现头像贴纸功能
前端·微信小程序·小程序·canvas·头像
xiangxiongfly91510 天前
微信小程序-canvas
微信小程序·小程序·canvas
胡西风_foxww12 天前
canvas分享,从入门到入门。
javascript·教程·canvas·入门·分享
漠河愁1 个月前
pdf文件渲染到canvas
canvas·pdf.js·fabirc.js
xachary2 个月前
前端使用 Konva 实现可视化设计器(21)- 绘制图形(椭圆)
canvas·konva
x007xyz2 个月前
前端纯手工绘制音频波形图
前端·音视频开发·canvas