KonvaJS 原理解析

尹光耀發表於2021-10-11

前言

用過 Canvas 的都知道它的 API 比較多,使用起來也很麻煩,比如我想繪製一個圓形就要調一堆 API,對開發算不上友好。

const canvas = document.querySelector('canvas');
const context = canvas.getContext('2d');
// 設定字型樣式
context.font = '24px SimSun, Songti SC';
context.fillText('24px的宋體呈現', 20, 50);
// 繪製完整圓
context.fillStyle = 'RGB(255, 0, 0)';
context.beginPath();
context.arc(150, 75, 50, 0, Math.PI * 2);
context.stroke();

為了解決這個痛點,誕生了例如 PIXI、ZRender、Fabric 等 Canvas 庫。今天要講的 Konva 也是一個很優秀的 Canvas 框架,API 封裝簡潔易懂,基於 TypeScript 實現,有 React 和 Vue 版本。

      const stage = new Konva.Stage({
        container: 'root',
        width: 1000,
        height: 1000,
      });
      const layer = new Konva.Layer();
      const group = new Konva.Group();
      
      const text = new Konva.Text({
        text: 'Hello, this is some good text',
        fontSize: 30,
      });

      const circle = new Konva.Circle({
        x: stage.width() / 2,
        y: stage.height() / 2,
        radius: 70,
        fill: 'red',
        stroke: 'black',
        strokeWidth: 4
      });
      group.add(text);
      group.add(circle);
      layer.add(group);
      stage.add(layer);

架構設計

Konva Tree

從前言裡面給的那段程式碼可以看出來,Konva 有一定的巢狀結構,有些類似 DOM 結構。通過 add 和 remove 就能實現子節點的新增和刪除。

Konva Tree 主要包括這麼幾部分:

  1. Stage 根節點:這是應用的根節點,會建立一個 div 節點,作為事件的接收層,根據事件觸發時的座標來分發出去。一個 Stage 節點可以包含多個 Layer 圖層。
  2. Layer 圖層:Layer 裡面會建立一個 Canvas 節點,主要作用就是繪製 Canvas 裡面的元素。一個 Layer 可以包含多個 Group 和 Shape。
  3. Group 組:Group 包含多個 Shape,如果對其進行變換和濾鏡,裡面所有的 Shape 都會生效。
  4. Shape:指 Text、Rect、Circle 等圖形,這些是 Konva 封裝好的類。

build dom

Stage 建立的時候會去建立兩個 Canvas 節點以及 content 容器節點,這兩個 Canvas 節點是用於 perfectDrawEnabled 的,後面會講到。

這裡需要注意的就是這個 content 節點,作為整個 Konva 畫布的容器,之後的 Layer 都會被 append 進去。

  _buildDOM() {
    this.bufferCanvas = new SceneCanvas({
      width: this.width(),
      height: this.height(),
    });
    this.bufferHitCanvas = new HitCanvas({
      pixelRatio: 1,
      width: this.width(),
      height: this.height(),
    });

    if (!Konva.isBrowser) {
      return;
    }
    var container = this.container();
    if (!container) {
      throw 'Stage has no container. A container is required.';
    }
    // clear content inside container
    container.innerHTML = '';

    // content
    this.content = document.createElement('div');
    this.content.style.position = 'relative';
    this.content.style.userSelect = 'none';
    this.content.className = 'konvajs-content';

    this.content.setAttribute('role', 'presentation');

    container.appendChild(this.content);

    this._resizeDOM();
  }

在呼叫 Stage.add 的時候,不僅會呼叫 Layer 的繪製方法,還會把 Layer 的 Canvas 節點 append 進去。


  add(layer: Layer, ...rest) {
    if (arguments.length > 1) {
      for (var i = 0; i < arguments.length; i++) {
        this.add(arguments[i]);
      }
      return this;
    }
    super.add(layer);

    var length = this.children.length;
    if (length > MAX_LAYERS_NUMBER) {
      Util.warn(
        'The stage has ' +
          length +
          ' layers. Recommended maximum number of layers is 3-5. Adding more layers into the stage may drop the performance. Rethink your tree structure, you can use Konva.Group.'
      );
    }
    layer.setSize({ width: this.width(), height: this.height() });

    // draw layer and append canvas to container
    layer.draw();

    if (Konva.isBrowser) {
      this.content.appendChild(layer.canvas._canvas);
    }

    // chainable
    return this;
  }

渲染

批量渲染

從前面的程式碼中可以看到,沒有手動呼叫繪製方法,但依然會進行繪製,說明會在一定的時機進行渲染。
這個時機就在 add 方法裡面,不管 Group、Layer、Stage 哪個先 add,最終都會觸發渲染。

他們三個都繼承了 Container 類,在 Container 類裡面有一個 add 方法,我們來一探究竟。

  add(...children: ChildType[]) {
    if (arguments.length > 1) {
      for (var i = 0; i < arguments.length; i++) {
        this.add(arguments[i]);
      }
      return this;
    }
    var child = children[0];
    // 如果要新增的子節點已經有個父節點,那就先將其從父節點移除,再插入到當前節點裡面
    if (child.getParent()) {
      child.moveTo(this);
      return this;
    }
    this._validateAdd(child);
    // 設定子節點的 index 和 parent
    child.index = this.getChildren().length;
    child.parent = this;
    child._clearCaches();
    this.getChildren().push(child);
    this._fire('add', {
      child: child,
    });
    // 請求繪製
    this._requestDraw();
    return this;
  }

除了一些常規的處理之外,渲染的關鍵就在 _requestDraw 方法裡面。這裡呼叫了 Layer 上面的 batchDraw 進行批量重繪。

  _requestDraw() {
    if (Konva.autoDrawEnabled) {
      const drawNode = this.getLayer() || this.getStage();
      drawNode?.batchDraw();
    }
  }

這個批量重繪的原理是利用 requestAnimationFrame 方法將要繪製的內容放到下一幀來繪製。這樣同時修改多個圖形多個屬性就不需要反覆繪製了。

  batchDraw() {
    // _waitingForDraw 保證只會執行一次 requestAnimFrame
    if (!this._waitingForDraw) {
      this._waitingForDraw = true;
      // 如果呼叫多次方法修改 Shape 屬性,這裡就會批量繪製
      // 避免了多次繪製帶來的開銷
      Util.requestAnimFrame(() => {
        this.draw();
        this._waitingForDraw = false;
      });
    }
    return this;
  }

Shape 繪製

所有涉及到圖形繪製的地方都是呼叫 Shape 實現類上的 _sceneFunc 方法,以 Circle 為例:

  _sceneFunc(context) {
    context.beginPath();
    context.arc(0, 0, this.attrs.radius || 0, 0, Math.PI * 2, false);
    context.closePath();
    context.fillStrokeShape(this);
  }

在 Shape 和 Node 兩個基類上面只負責呼叫,具體的實現放到具體的 Shape 實現上面。這樣帶來兩個好處,一個是可以實現自定義圖形,另一個是以後要是支援 SVG、WebGL 會很方便。

離屏渲染

什麼是離屏渲染?就是在螢幕之外預渲染一個 Canvas,之後通過 drawImage 的形式將其繪製到螢幕要顯示的 Canvas 上面,對形狀相似或者重複的物件繪製效能提升非常高。

假設我們有個列表頁,每次滾動的時候全部重新繪製開銷會比較大。但如果我們實現一個 Canvas 池,把已經繪製過的列表項存起來。下次滾動到這裡的時候,就可以直接從 Canvas 池裡面取出來 drawImage 到頁面上了。

在 Node 類上面有個 cache 方法,這個方法可以實現細粒度的離屏渲染。

cache 方法內部會建立三個 canvas,分別是:

  1. cachedSceneCanvas:用於繪製圖形的 canvas 的離屏渲染。
  2. cachedFilterCanvas:用於處理濾鏡效果。
  3. cachedHitCanvas:用於處理 hitCanvas 的離屏渲染。
  _drawCachedSceneCanvas(context: Context) {
    context.save();
    context._applyOpacity(this);
    context._applyGlobalCompositeOperation(this);
    // 獲取離屏的 Canvas
    const canvasCache = this._getCanvasCache();
    context.translate(canvasCache.x, canvasCache.y);

    var cacheCanvas = this._getCachedSceneCanvas();
    var ratio = cacheCanvas.pixelRatio;
    // 將離屏 Canvas 繪製到要展示的 Canvas 上面
    context.drawImage(
      cacheCanvas._canvas,
      0,
      0,
      cacheCanvas.width / ratio,
      cacheCanvas.height / ratio
    );
    context.restore();
  }

perfectDrawEnabled

Canvas 在繪製 stroke 和 fill 的時候,如果遇到透明度的時候,stroke 會和 fill 的一部分重合到一起,就不符合我們的預期了。

比如下面這段程式碼:

      const canvas = document.getElementById("canvas");
      const bufferCanvas = document.createElement("canvas");
      const bufferCtx = bufferCanvas.getContext("2d");
      const ctx = canvas.getContext("2d");

      ctx.strokeStyle="green";
      ctx.lineWidth=10;
      ctx.strokeRect(30,30,50,50);
      ctx.globalAlpha = 0.5;
      ctx.fillStyle="RGB(255, 0, 0)";
      ctx.fillRect(30,30,50,50);

它的實際展示效果是這樣的,中間的 stroke 和 fill 有一部分重疊。

在這種情況下,KonvaJS 實現了一個 perfectDrawEnabled 功能,它會這樣做:

  1. 在 bufferCanvas 上繪製 Shape
  2. 繪製 fill 和 stroke
  3. 在 layer 上應用透明度
  4. 將 bufferCanvas 繪製到 sceneCanvas 上面

可以看到開啟 perfectDrawEnabled 和關閉 perfectDrawEnabled 的區別很明顯:

它會在 Stage 裡面建立一個 bufferCanvas 和 bufferHitCanvas,前者就是針對 sceneCanvas 的,後者是針對 hitCanvas 的。

在 Shape 的 drawScene 方法裡面,會判斷是否使用 bufferCanvas:

    // if buffer canvas is needed
    if (this._useBufferCanvas() && !skipBuffer) {
      stage = this.getStage();
      bufferCanvas = stage.bufferCanvas;
      bufferContext = bufferCanvas.getContext();
      bufferContext.clear();
      bufferContext.save();
      bufferContext._applyLineJoin(this);
      // layer might be undefined if we are using cache before adding to layer
      var o = this.getAbsoluteTransform(top).getMatrix();
      bufferContext.transform(o[0], o[1], o[2], o[3], o[4], o[5]);
      
      // 在 bufferCanvas 繪製 fill 和 stroke
      drawFunc.call(this, bufferContext, this);
      bufferContext.restore();

      var ratio = bufferCanvas.pixelRatio;

      if (hasShadow) {
        context._applyShadow(this);
      }
      // 在 sceneCanvas 應用透明度
      context._applyOpacity(this);
      context._applyGlobalCompositeOperation(this);
      // 將 bufferCanvas 繪製到 sceneCanvas
      context.drawImage(
        bufferCanvas._canvas,
        0,
        0,
        bufferCanvas.width / ratio,
        bufferCanvas.height / ratio
      );
    }

事件

Konva 裡面的事件是在 Canvas 外層建立了一個 div 節點,在這個節點上面接收了 DOM 事件,再根據座標點來判斷當前點選的是哪個 Shape,然後進行事件分發。

所以關鍵就在如何判斷當前點選的 Shape 是哪個?相比 ZRender 裡面比較複雜的計算,Konva 使用了一個相當巧妙的方式。

事件分發

Konva 目前支援下面這麼多事件,EVENTS 是 事件名-事件處理方法 的對映。

EVENTS = [
    [MOUSEENTER, '_pointerenter'],
    [MOUSEDOWN, '_pointerdown'],
    [MOUSEMOVE, '_pointermove'],
    [MOUSEUP, '_pointerup'],
    [MOUSELEAVE, '_pointerleave'],
    [TOUCHSTART, '_pointerdown'],
    [TOUCHMOVE, '_pointermove'],
    [TOUCHEND, '_pointerup'],
    [TOUCHCANCEL, '_pointercancel'],
    [MOUSEOVER, '_pointerover'],
    [WHEEL, '_wheel'],
    [CONTEXTMENU, '_contextmenu'],
    [POINTERDOWN, '_pointerdown'],
    [POINTERMOVE, '_pointermove'],
    [POINTERUP, '_pointerup'],
    [POINTERCANCEL, '_pointercancel'],
    [LOSTPOINTERCAPTURE, '_lostpointercapture'],
  ];
  // 繫結事件
  _bindContentEvents() {
    if (!Konva.isBrowser) {
      return;
    }
    EVENTS.forEach(([event, methodName]) => {
      // 事件繫結在 content 這個 dom 節點上面
      this.content.addEventListener(event, (evt) => {
        this[methodName](evt);
      });
    });
  }

我們以 mousedown 這個具體的事件作為例子來分析,它的處理方法在 _pointerdown 裡面。
_pointerdown 先執行了 setPointersPositions,計算當前滑鼠點選的座標,減去 content 相對頁面的座標,得到了當前點選相對於 content 的座標。同時將其存入了 _changedPointerPositions 裡面。

然後遍歷 _changedPointerPositions,通過 getIntersection 獲取到了點選的 Shape 圖形。這個 getIntersection 遍歷呼叫了每個 Layer 的 getIntersection 方法,通過 Layer 獲取到了對應的 Shape。

因為可以存在多個 Layer,每個 Layer 也可以在同一個位置繪製多個 Shape,所以理論上可以獲取到多個 Shape,Konva 這裡只取了第一個 Shape,按照 Layer -> Shape 的順序來的。

然後 Stage 會呼叫 Shape 上面的 _fireAndBubble 方法,這個方法呼叫 _fire 傳送 Konva 自己的事件,此時通過 on 繫結的事件回撥就會觸發,有點兒像 jQuery 那樣。

然後 Konva 會繼續往上找到父節點,繼續呼叫父節點的 _fireAndBubble 方法,直到再也找不到父節點為止,這樣就實現了事件冒泡。

對於不想被點選到的 Shape 來說,可以設定 isListening 屬性為 false,這樣事件就不會觸發了。

匹配 Shape

那麼 Layer 是怎麼根據點選座標獲取到對應的 Shape 呢?如果是規則的圖形(矩形、圓形)還比較容易計算,要是下面這種不規則圖形呢?

眾所周知,在 Canvas 裡面有個 getImageData 方法,它會根據傳入的座標來返回一個 ImageData 資訊,裡面有當前座標對應的色值。那麼我們能不能根據這個色值來獲取到對應的 Shape 呢?

因此,Konva 在建立 Layer 的時候會建立兩個 Canvas,一個用於 sceneCanvas 用於繪製 Shape,另一個 hitCanvas 在記憶體裡面,用於判斷是否被打擊。

canvas = new SceneCanvas();
hitCanvas = new HitCanvas({
  pixelRatio: 1,
});

當 Shape 初始化的時候,會生成一個隨機的顏色,以這個顏色作為 key 存入到 shapes 陣列裡面。

  constructor(config?: Config) {
    super(config);
    // set colorKey
    let key: string;

    while (true) {
      // 生成隨機色值
      key = Util.getRandomColor();
      if (key && !(key in shapes)) {
        break;
      }
    }
    this.colorKey = key;
    // 存入 shapes 陣列
    shapes[key] = this;
  }

每次在 sceneCanvas 上面繪製的時候,同樣會在記憶體中的 hitCanvas 裡面繪製一遍,並且將上面隨機生成的色值作為 fill 和 stroke 的顏色填充。

當點選 sceneCanvas 的時候,獲取到點選的座標點,通過呼叫 hitCanvas 的 getImageData 就可以獲取到 colorKey,然後再通過 colorKey 就能找到對應的 Shape 了,真是相當巧妙的實現。

但這種方式也有缺陷,因為生成的隨機 hex 顏色是有上限的,最多會會有256 256 256 = 16777216種,如果超過了這麼多就會導致匹配不準確。

不過考慮一下如果有16777216個 DOM 節點,瀏覽器就會卡爆了,換成這麼多 Canvas 圖形一樣會導致效能爆炸。

自定義 hitFunc

如果你想自定義事件響應區域,Konva 也提供了 hitFunc 方法給你實現。在繪製 hitCanvas 的時候,原本的繪製 sceneFunc 就失效了,取而代之的是繪製 hitFunc。

  drawHit(can?: HitCanvas, top?: Node, skipDragCheck = false) {
    if (!this.shouldDrawHit(top, skipDragCheck)) {
      return this;
    }

    var layer = this.getLayer(),
      canvas = can || layer.hitCanvas,
      context = canvas && canvas.getContext(),
      // 如果有 hitFunc,就不使用 sceneFunc
      drawFunc = this.hitFunc() || this.sceneFunc(),
      cachedCanvas = this._getCanvasCache(),
      cachedHitCanvas = cachedCanvas && cachedCanvas.hit;

    if (!this.colorKey) {
      Util.warn(
        'Looks like your canvas has a destroyed shape in it. Do not reuse shape after you destroyed it. If you want to reuse shape you should call remove() instead of destroy()'
      );
    }
    // ...
    drawFunc.call(this, context, this);
    // ...
}

拖拽事件

Konva 的拖拽事件沒有使用原生的方法,而是基於 mousemove 和 touchmove 來計算移動的距離,進而手動設定 Shape 的位置,實現邏輯比較簡單,這裡不細說。

濾鏡

Konva 支援多種濾鏡,在使用濾鏡之前需要先將 Shape cache 起來,然後使用 filter() 方法新增濾鏡。
在 cache 裡面除了建立用於離屏渲染的 Canvas,還會建立濾鏡 Canvas。濾鏡處理在 _getCachedSceneCanvas 裡面。

首先將 sceneCanvas 通過 drawImage 繪製到 filterCanvas 上面,接著 filterCanvas 獲取所有的 ImageData,遍歷所有設定的濾鏡方法,將 ImageData 傳給濾鏡方法來處理。

處理完 ImageData 之後,再將其通過 putImageData 繪製到 filterCanvas 上面。

    if (filters) {
      if (!this._filterUpToDate) {
        var ratio = sceneCanvas.pixelRatio;
        filterCanvas.setSize(
          sceneCanvas.width / sceneCanvas.pixelRatio,
          sceneCanvas.height / sceneCanvas.pixelRatio
        );
        try {
          len = filters.length;
          filterContext.clear();

          // copy cached canvas onto filter context
          filterContext.drawImage(
            sceneCanvas._canvas,
            0,
            0,
            sceneCanvas.getWidth() / ratio,
            sceneCanvas.getHeight() / ratio
          );
          imageData = filterContext.getImageData(
            0,
            0,
            filterCanvas.getWidth(),
            filterCanvas.getHeight()
          );

          // apply filters to filter context
          for (n = 0; n < len; n++) {
            filter = filters[n];
            if (typeof filter !== 'function') {
              Util.error(
                'Filter should be type of function, but got ' +
                  typeof filter +
                  ' instead. Please check correct filters'
              );
              continue;
            }
            filter.call(this, imageData);
            filterContext.putImageData(imageData, 0, 0);
          }
        } catch (e) {
          Util.error(
            'Unable to apply filter. ' +
              e.message +
              ' This post my help you https://konvajs.org/docs/posts/Tainted_Canvas.html.'
          );
        }

        this._filterUpToDate = true;
      }

      return filterCanvas;
    }

那濾鏡效果怎麼畫上去的呢?在 konva 裡面進行了特殊處理,如果存在 filterCanvas,那就不會使用 cacheCanvas 了,也就是我們原本用於快取的離屏 Canvas 會被 filterCanvas 進行替代。

最終 filterCanvas 會通過 drawImage 的方式繪製到 sceneCanvas 上面。

選擇器

Konva 實現了選擇器,方便我們快速查詢到某個 Shape。目前主要有三種選擇器,分別是 id 選擇器、name 選擇器、type 選擇器。

前兩者需要在例項化的時候傳入一個 id 或者 name 屬性,後者則是根據類名(Rect、Line等)來查詢的。

選擇器查詢的時候需要呼叫 find 方法,這個 find 方法掛載在 Container 類上面。它呼叫了 _descendants 進行子節點的遍歷,將遍歷的 node 節點呼叫 isMatch 方法來判斷是否匹配上。

  _generalFind<ChildNode extends Node = Node>(
    selector: string | Function,
    findOne: boolean
  ) {
    var retArr: Array<ChildNode> = [];
    
    // 呼叫 _descendants 獲取所有的子節點
    this._descendants((node: ChildNode) => {
      const valid = node._isMatch(selector);
      if (valid) {
        retArr.push(node);
      }
      // 如果是 findOne,後面的就不繼續執行了
      if (valid && findOne) {
        return true;
      }
      return false;
    });

    return retArr;
  }
  
  private _descendants(fn: (n: Node) => boolean) {
    let shouldStop = false;
    const children = this.getChildren();
    for (const child of children) {
      shouldStop = fn(child);
      if (shouldStop) {
        return true;
      }
      if (!child.hasChildren()) {
        continue;
      }
      // 如果子節點也有子節點,那就遞迴遍歷
      shouldStop = (child as any)._descendants(fn);
      // 如果應該停止查詢(一般是 findOne 的時候就不需要查詢後面的了)
      if (shouldStop) {
        return true;
      }
    }
    return false;
  }

在 isMatch 裡面可以看到後根據是什麼型別的選擇器來分別進行匹配。

      // id selector
      if (sel.charAt(0) === '#') {
        if (this.id() === sel.slice(1)) {
          return true;
        }
      } else if (sel.charAt(0) === '.') {
        // name selector
        if (this.hasName(sel.slice(1))) {
          return true;
        }
      } else if (this.className === sel || this.nodeType === sel) {
        return true;
      }

序列化

Konva 還支援對 Stage 的序列化和反序列化,簡單來說就是把 Stage 的資料匯出成一份 JSON 資料以及把 JSON 資料匯入,方便我們在 NodeJS 端進行服務端渲染。

序列化主要在 toObject 方法裡面,它會對函式和 DOM 節點進行過濾,只保留一份描述資訊,比如 Layer 的資訊、Shape 的資訊等等,有點兒類似 React 裡面的 Virtual DOM。

  toObject() {
    var obj = {} as any,
      attrs = this.getAttrs(),
      key,
      val,
      getter,
      defaultValue,
      nonPlainObject;

    obj.attrs = {};

    for (key in attrs) {
      val = attrs[key];
      nonPlainObject =
        Util.isObject(val) && !Util._isPlainObject(val) && !Util._isArray(val);
      if (nonPlainObject) {
        continue;
      }
      getter = typeof this[key] === 'function' && this[key];
      delete attrs[key];
      // 特殊處理函式,將其執行後把結果掛載到當前key上面
      defaultValue = getter ? getter.call(this) : null;
      // restore attr value
      attrs[key] = val;
      if (defaultValue !== val) {
        obj.attrs[key] = val;
      }
    }

    obj.className = this.getClassName();
    return Util._prepareToStringify(obj);
  }

而反序列化則是對傳入的 JSON 資訊進行解析,根據 className 來建立不同的物件,對深層結構進行遞迴,然後 add 到父節點裡面。


  static _createNode(obj, container?) {
    var className = Node.prototype.getClassName.call(obj),
      children = obj.children,
      no,
      len,
      n;

    // if container was passed in, add it to attrs
    if (container) {
      obj.attrs.container = container;
    }

    if (!Konva[className]) {
      Util.warn(
        'Can not find a node with class name "' +
          className +
          '". Fallback to "Shape".'
      );
      className = 'Shape';
    }
    // 根據傳入的 className 來例項化
    const Class = Konva[className];

    no = new Class(obj.attrs);
    if (children) {
      len = children.length;
      for (n = 0; n < len; n++) {
        // 如果還有子節點,那就遞迴建立
        no.add(Node._createNode(children[n]));
      }
    }

    return no;
  }

React

Konva 和 React 繫結沒有使用重新封裝一遍元件的方式,而是採用了和 react-dom、react-native 一樣的形式,基於 react-reconciler 來實現一套 hostConfig,從而定製自己的 Host Component(宿主元件)。

react-reconciler

React Fiber 架構誕生之後,他們就將原來的 React 核心程式碼做了抽離。主要包括 react、react-reconciler 和 platform 實現(react-dom、react-native等)三部分。

在 react-reconciler 裡面實現了大名鼎鼎的 Diff 演算法、時間切片、排程等等,它還暴露給了我們一個 hostConfig 檔案,允許我們在各種鉤子函式中實現自己的渲染。

在 React 裡面,有兩種元件型別,一種是 Host Component(宿主元件),另一種是 Composition Component(複合元件)。

在 DOM 裡面,前者就是 h1、div、span 等元素,在 react-native 裡面,前者就是 View、Text、ScrollView 等元素。後者則是我們基於 Host Component 自定義的元件,比如 App、Header 等等。

在 react-reconciler 裡面,它允許我們去自定義 Host Component 的渲染(增刪查改),這也意味著跨平臺的能力。我們只需要編寫一份 hostConfig 檔案,就能夠實現自己的渲染。

參考上面的架構圖,會發現不管是渲染到 native、canvas,甚至是小程式都可以。業界已經有方案是基於這個來實現了,可以參考螞蟻金服的 remax:[Remax - 使用真正的 React 構建小程式
][11]

react-konva

react-konva 的主要實現就在 ReactKonvaHostConfig.js 裡面,它利用 Konva 原本的 API 實現了對 Virtual DOM 的對映,響應了 Virtual DOM 的增刪查改。

這裡從中抽取了部分原始碼:

// 建立一個例項
export function createInstance(type, props, internalInstanceHandle) {
  let NodeClass = Konva[type];

  const propsWithoutEvents = {};
  const propsWithOnlyEvents = {};

  for (var key in props) {
    var isEvent = key.slice(0, 2) === 'on';
    if (isEvent) {
      propsWithOnlyEvents[key] = props[key];
    } else {
      propsWithoutEvents[key] = props[key];
    }
  }
  // 根據傳入的 type 來建立一個例項,相當於 new Layer、new Rect 等
  const instance = new NodeClass(propsWithoutEvents);
  // 將傳入的 props 設定到例項上面
  // 如果是普通的 prop,就直接通過 instance.setAttr 更新
  // 如果是 onClick 之類的事件,就通過 instance.on 來繫結
  applyNodeProps(instance, propsWithOnlyEvents);

  return instance;
}
// 插入子節點,直接呼叫 konva 的 add 方法
export function appendChild(parentInstance, child) {
  if (child.parent === parentInstance) {
    child.moveToTop();
  } else {
    parentInstance.add(child);
  }

  updatePicture(parentInstance);
}

// 移除子節點,直接呼叫 destroy 方法
export function removeChild(parentInstance, child) {
  child.destroy();
  child.off(EVENTS_NAMESPACE);
  updatePicture(parentInstance);
}

// 通過設定 zIndex 實現 insertBefore
export function insertBefore(parentInstance, child, beforeChild) {
  // child._remove() will not stop dragging
  // but child.remove() will stop it, but we don't need it
  // removing will reset zIndexes
  child._remove();
  parentInstance.add(child);
  child.setZIndex(beforeChild.getZIndex());
  updatePicture(parentInstance);
}

vue-konva

在 Vue 上面,Konva 通過 Vue.use 註冊了一個外掛,這個外掛裡面分別註冊了每個元件。

const components = [
  {
    name: 'Stage',
    component: Stage
  },
  ...KONVA_NODES.map(name => ({
    name,
    component: KonvaNode(name)
  }))
];
const VueKonva = {
  install: (Vue, options) => {
    let prefixToUse = componentPrefix;
    if(options && options.prefix){
      prefixToUse = options.prefix;
    }
    components.forEach(k => {
      Vue.component(`${prefixToUse}${k.name}`, k.component);
    })
  }
};

export default VueKonva;

if (typeof window !== 'undefined' && window.Vue) {
  window.Vue.use(VueKonva);
}

再來看看 KonvaNode 的實現,在 KonvaNode 裡面,對於節點的增刪查改都在 Vue 的生命週期裡面實現的。
在 Vue 的 created 生命週期裡面呼叫 initKonva 去 new 一個 NodeClass,和上面 React 的方式幾乎一樣。

      initKonva() {
        const NodeClass = window.Konva[nameNode];

        if (!NodeClass) {
          console.error('vue-konva error: Can not find node ' + nameNode);
          return;
        }

        this._konvaNode = new NodeClass();
        this._konvaNode.VueComponent = this;

        this.uploadKonva();
      },

而在 Updated 的時候去進行 Props 的更新,在 destroyed 裡面對節點進行 destroy,實現上更加簡潔一些。

    updated() {
      this.uploadKonva();
      checkOrder(this.$vnode, this._konvaNode);
    },
    destroyed() {
      updatePicture(this._konvaNode);
      this._konvaNode.destroy();
      this._konvaNode.off(EVENTS_NAMESPACE);
    },

缺陷

髒矩形

在效能方面,Konva 對比 PIXI、ZRender 這些庫還是不太夠看。如果我們 Layer 上有非常多的 Shape,如果想更新某個 Shape,按照 Konva 的實現方式依然會全量繪製。

雖然 Konva 支援單個 Shape 重繪,但實現上是無腦覆蓋原來的位置,這也意味著如果你的圖形在其他節點圖形下面,就會出現問題。

所以這裡缺少非常重要的區域性更新能力,也就是我們常說的髒矩形。

髒矩形就是指當我們更新一個 Shape 的時候,利用碰撞檢測計算出和他相交的所有 Shape,將其進行合併,計算出一塊兒髒區域。然後我們通過 clip 限制 Canvas 只在這塊兒髒區進行繪製,這樣就實現了區域性更新。

可惜 Konva 的包圍盒實現的非常簡單,不適合做碰撞檢測,它也沒有提供髒矩形的能力。