Preface
Everyone who has used Canvas knows that it has a lot of APIs, and it's also very difficult to use. For example, if I want to draw a circle, I need to adjust a bunch of APIs, which is not friendly for development.
const canvas = document.querySelector('canvas'); const context = canvas.getContext('2d'); // Set Font Style context.font = '24px SimSun, Songti SC'; context.fillText('24px Song Style Rendering', 20, 50); // Draw a complete circle context.fillStyle = 'RGB(255, 0, 0)'; context.beginPath(); context.arc(150, 75, 50, 0, Math.PI * 2); context.stroke();
To address this pain point, Canvas libraries such as PIXI, ZRender, Fabric were created. Konva is also an excellent Canvas framework today. API encapsulation is simple and easy to understand, based on TypeScript implementation, with React and Vue versions.
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);
architecture design
Konva Tree
From the code given in the preface, it can be seen that Konva has some nested structure, some similar to DOM structure. Add and remove can be used to add and remove child nodes.
Konva Tree mainly consists of these parts:
- Stage root node: This is the root node of the application. A div node is created as the receiving layer for events to be distributed according to the coordinates at the time the event is triggered. A Stage node can contain multiple Layer layers.
- Layer layer: A Canvas node is created inside a Layer to draw elements inside a Canvas. A Layer can contain multiple Group s and Shape s.
- Group Group: GroupContains multiple Shape s, all of which will take effect if transformed and filtered.
- Shape: Graphics such as Text, Rect, Circle, etc., which are packaged in Konva.
build dom
When a Stage is created, it creates two Canvas nodes and content container nodes for perfectDrawEnabled, which are described later.
It is important to note that this context node serves as a container for the entire Konva canvas, and Layer s are append ed into it afterwards.
_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(); }
When you call Stage.add, you not only call Layer's drawing method, but also append Layer's Canvas node.
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; }
Rendering
Batch Render
As you can see from the previous code, there is no manual call to the drawing method, but it will still be drawn, indicating that rendering will occur at a certain time.
This time is inside the add method, and the rendering will eventually trigger regardless of which Group, Layer, or Stage adds first.
All three of them inherit the Container class, and there is an add method in the Container class that we'll explore.
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 the child node you want to add already has a parent node, remove it from the parent node before inserting it into the current node if (child.getParent()) { child.moveTo(this); return this; } this._validateAdd(child); // Set index and parent for child nodes child.index = this.getChildren().length; child.parent = this; child._clearCaches(); this.getChildren().push(child); this._fire('add', { child: child, }); // Request Drawing this._requestDraw(); return this; }
In addition to some general processing, the key to rendering is within the _requestDraw method, where batchDraw above Layer is called for batch redrawing.
_requestDraw() { if (Konva.autoDrawEnabled) { const drawNode = this.getLayer() || this.getStage(); drawNode?.batchDraw(); } }
The principle of this batch redrawing is to use the requestAnimationFrame method to place the content to be drawn in the next frame so that multiple attributes of multiple graphics can be modified simultaneously without drawing repeatedly.
batchDraw() { // _waitingForDraw guarantees that only requestAnimFrame will be executed once if (!this._waitingForDraw) { this._waitingForDraw = true; // If you call multiple methods to modify the Shape property, it will be drawn in batches here // Avoid the overhead of drawing multiple times Util.requestAnimFrame(() => { this.draw(); this._waitingForDraw = false; }); } return this; }
Shape Drawing
All that involves drawing graphics calls the _sceneFunc method on the Shape implementation class, taking Circle as an example:
_sceneFunc(context) { context.beginPath(); context.arc(0, 0, this.attrs.radius || 0, 0, Math.PI * 2, false); context.closePath(); context.fillStrokeShape(this); }
On the base classes of Shape and Node, only calls are made, and the specific implementation is placed on the specific Shape implementation. This has two benefits: one is to implement custom graphics, the other is to support SVG, WebGL in the future.
offscreen rendering
What is off-screen rendering? Pre-rendering a Canvas out of the screen and then drawing it on top of the Canvas to be displayed on the screen by drawImage improves drawing performance for similar or duplicate objects.
Suppose we have a list page, it would be expensive to redraw it all each time we scroll. But if we implement a Canvas pool, save the list items that have already been drawn. The next time we scroll here, we can take the drawImage directly out of the Canvas pool and onto the page.
There is a cache method on the Node class that allows fine-grained off-screen rendering.
Inside the cache method, three canvas are created:
- cachedSceneCanvas: Offscreen rendering of canvas used to draw graphics.
- cachedFilterCanvas: Used to handle filter effects.
- cachedHitCanvas: Offscreen rendering for processing hitCanvas.
_drawCachedSceneCanvas(context: Context) { context.save(); context._applyOpacity(this); context._applyGlobalCompositeOperation(this); // Get Canvas off-screen const canvasCache = this._getCanvasCache(); context.translate(canvasCache.x, canvasCache.y); var cacheCanvas = this._getCachedSceneCanvas(); var ratio = cacheCanvas.pixelRatio; // Draw off-screen Canvas on top of the Canvas you want to show context.drawImage( cacheCanvas._canvas, 0, 0, cacheCanvas.width / ratio, cacheCanvas.height / ratio ); context.restore(); }
perfectDrawEnabled
When Canvas draws strokes and fills, when transparency is encountered, strokes coincide with a portion of the fill, which is not what we expected.
For example, here is the code:
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);
The actual effect is that the stroke and fill in the middle overlap partially.
In this case, KonvaJS implements a perfectDrawEnabled function that does this:
- Draw Shape on bufferCanvas
- Draw fill and stroke
- Applying transparency on layer s
- Draw bufferCanvas on top of sceneCanvas
You can see that the difference between turning on perfectDrawEnabled and turning off perfectDrawEnabled is obvious:
It creates bufferCanvas and bufferHitCanvas within Stage, the former for sceneCanvas and the latter for hitCanvas.
In Shape's drawScene method, it determines whether bufferCanvas is used:
// 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]); // Draw fill and stroke in bufferCanvas drawFunc.call(this, bufferContext, this); bufferContext.restore(); var ratio = bufferCanvas.pixelRatio; if (hasShadow) { context._applyShadow(this); } // Applying transparency to sceneCanvas context._applyOpacity(this); context._applyGlobalCompositeOperation(this); // Draw bufferCanvas to sceneCanvas context.drawImage( bufferCanvas._canvas, 0, 0, bufferCanvas.width / ratio, bufferCanvas.height / ratio ); }
Event
Events inside Konva create a div node on the outside of Canvas that receives DOM events, determines which Shape is currently clicked based on coordinate points, and distributes the events.
So the key is how to tell which Shape you're clicking on right now? Konva uses a fairly clever method than the more complex calculations in ZRender.
Event Distribution
Konva currently supports so many of the following events that EVENTS is a mapping of event name-event handling methods.
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'], ]; // Binding Events _bindContentEvents() { if (!Konva.isBrowser) { return; } EVENTS.forEach(([event, methodName]) => { // Events are bound to this dom node, content this.content.addEventListener(event, (evt) => { this[methodName](evt); }); }); }
Let's take mousedown as an example, which is handled in _pointerdown.
_pointerdown first executes setPointersPositions, calculates the coordinates of the current mouse click, subtracts the coordinates of the content relative to the page, and gets the coordinates of the current click relative to the content. It is also stored in _changedPointersPositions.
The _changedPointerPositions is then traversed to get the hit Shape graphic from the getIntersection, which calls each Layer's getIntersection method to get the corresponding Shape from the Layer.
Since there can be multiple Layers and each Layer can draw multiple Shapes at the same location, you can theoretically get multiple Shapes. Konva takes only the first Shape here, in Layer -> Shape order.
Stage then calls the _fireAndBubble method above Shape, which calls _fire to send Konva's own event, where event callbacks via on-binding trigger, a bit like jQuery.
Konva then continues to find the parent node up and calls the parent node's _fireAndBubble method until the parent node is no longer found, which implements event bubbling.
For Shape s that you don't want to be clicked on, you can set the isListening property to false so that the event does not fire.
Match Shape
So how does Layer get the corresponding Shape from clicking coordinates? If it's a regular graphic (rectangle, circle) it's easier to calculate, if it's an irregular graphic like this one below?
It is well known that there is a getImageData method in Canvas that returns an ImageData information based on the coordinates passed in, with the corresponding color values for the current coordinates. So can we get the corresponding Shape from this color value?
Therefore, when Konva creates a Layer, it creates two Canvas, one for sceneCanvas to draw Shape and the other for hitCanvas in memory to determine whether it was hit or not.
canvas = new SceneCanvas(); hitCanvas = new HitCanvas({ pixelRatio: 1, });
When Shape is initialized, a random color is generated and stored as a key in the shapes array.
constructor(config?: Config) { super(config); // set colorKey let key: string; while (true) { // Generate random color values key = Util.getRandomColor(); if (key && !(key in shapes)) { break; } } this.colorKey = key; // Save in shapes array shapes[key] = this; }
Each time you draw on sceneCanvas, it is also drawn once in hitCanvas in memory, and the randomly generated color values above are filled as fills and stroke s.
When you click sceneCanvas, you get the coordinate point you click on, you can get the colorKey by calling getImageData of hitCanvas, and then you can find the corresponding Shape by colorKey. This is a fairly clever implementation.
However, this method has its drawbacks, because the random hex colors generated are limited, up to 256*256*256=16777216, which can lead to inaccurate matching.
Consider, however, that if there were 16777216 DOM nodes, the browser would crash, and replacing them with so many Canvas graphics would cause performance explosion as well.
Customize hitFunc
If you want to customize the event response area, Konva also provides a hitFunc method for you to implement. When drawing hitCanvas, the original scene Func will no longer work and instead will draw 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(), // If there is hitFunc, sceneFunc is not used 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); // ... }
Drag Events
Konva's drag event does not use the native method, but calculates the distance of movement based on mousemove and touchmove, which in turn sets Shape's position manually, making the logic simpler, let's not go into details here.
Filters
Konva supports a variety of filters. Shape cache needs to be set up before using the filters, then filter() method is used to add the filters.
In addition to creating Canvas for off-screen rendering in cache, a filter Canvas is also created. Filters are processed inside _getCachedSceneCanvas.
sceneCanvas is first drawn on top of filterCanvas by drawImage, then filterCanvas gets all ImageData, traverses through all the set filter methods, and passes ImageData to the filter method for processing.
After you have processed the ImageData, you can draw it on the filterCanvas using putImageData.
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; }
What about the effect of the filter? Special treatment has been done in konva. If there are filterCanvas, cacheCanvas will not be used, that is, the off-screen Canvas we used for caching will be replaced by filterCanvas.
Finally, filterCanvas is drawn on top of sceneCanvas by drawImage.
selector
Konva implements selectors that allow us to quickly find a Shape. There are currently three main selectors: the id selector, the name selector, and the type selector.
The first two need to pass in an id or name attribute when instantiating, and the second one is based on the class name (Rect, Line, and so on).
The find method, which is mounted on the Container class, needs to be called when the selector finds. It calls _descendants to traverse the child nodes and calls the isMatch method on the traversed node to determine whether it matches.
_generalFind<ChildNode extends Node = Node>( selector: string | Function, findOne: boolean ) { var retArr: Array<ChildNode> = []; // Call_descendants to get all the child nodes this._descendants((node: ChildNode) => { const valid = node._isMatch(selector); if (valid) { retArr.push(node); } // If it is findOne, the subsequent execution will not continue 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; } // If a child node also has children, traverse recursively shouldStop = (child as any)._descendants(fn); // If you should stop looking (typically you don't need to look behind findOne) if (shouldStop) { return true; } } return false; }
You can see in isMatch that matches are made separately based on what type of selector it is.
// 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; }
serialize
Konva also supports serialization and deserialization of Stage, simply by exporting Stage's data to a JSON data and importing JSON data for service-side rendering on the NodeJS side.
Serialization is primarily within the toObject method, which filters functions and DOM nodes, leaving only one description, such as Layer's information, Shape's information, and so on, somewhat like Virtual DOM in React.
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]; // Special handler that executes and mounts the result on the current 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); }
Deserialization, on the other hand, parses the incoming JSON information, creates different objects based on the className, recursively add s the deep structure to the parent node.
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'; } // Instantiate from the incoming className const Class = Konva[className]; no = new Class(obj.attrs); if (children) { len = children.length; for (n = 0; n < len; n++) { // If there are child nodes, create them recursively no.add(Node._createNode(children[n])); } } return no; }
React
Instead of re-encapsulating the component once, Konva and React bindings implement a set of hostConfig s based on react-reconciler in the same form as react-dom and react-native to customize their own Host Component.
react-reconciler
After the React Fiber architecture was born, they extracted the original React core code, mainly including react, react-reconciler and platform implementation (react-dom, react-native, and so on).
The well-known IFF algorithm, time slicing, scheduling and so on are implemented in react-reconciler, which also exposes a hostConfig file that allows us to render ourselves in various hook functions.
Within React, there are two types of components: Host Component and Composition Component.
In DOM, the former is elements such as h1, div, span, etc. In react-native, the former is elements such as View, Text, ScrollView, etc. The latter is components we customize based on Host Component, such as App, Header, etc.
Inside react-reconciler, it allows us to customize the rendering of Host Component s (add, delete and change), which also means cross-platform capabilities. We just need to write a hostConfig file to achieve our own rendering.
Referring to the schematic diagram above, you can see that it can be rendered to native, canvas or even applets. There are already solutions based on this, which can be referred to as the remax of ant gold clothing: Remax - Build applets with real React
react-konva
The main implementation of react-konva is in ReactKonvaHostConfig.js, which maps Virtual DOM using Konva's original API in response to the addition or deletion of Virtual DOM.
Part of the source code is extracted here:
// Create an instance 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]; } } // Create an instance from the type passed in, equivalent to new Layer, new Rect, and so on const instance = new NodeClass(propsWithoutEvents); // Set the incoming props on the instance // If it's a normal prop, update it directly through instance.setAttr // If it's an event like onClick, bind it through instance.on applyNodeProps(instance, propsWithOnlyEvents); return instance; } // Insert a child node and call konva's add method directly export function appendChild(parentInstance, child) { if (child.parent === parentInstance) { child.moveToTop(); } else { parentInstance.add(child); } updatePicture(parentInstance); } // Remove the child node and call the destroy method directly export function removeChild(parentInstance, child) { child.destroy(); child.off(EVENTS_NAMESPACE); updatePicture(parentInstance); } // InsrtBefore by setting zIndex 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
Above the Vue, Konva registered a plug-in through Vue.use, in which each component was registered separately.
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); }
Let's also look at the implementation of KonvaNode, where the addition and deletion of nodes are all implemented in the life cycle of Vue.
InititKonva is called to new NodeClass in the Vue's created life cycle in almost the same way as React above.
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(); },
Update Props while Update, and destroy nodes within destroyed to make the implementation more concise.
updated() { this.uploadKonva(); checkOrder(this.$vnode, this._konvaNode); }, destroyed() { updatePicture(this._konvaNode); this._konvaNode.destroy(); this._konvaNode.off(EVENTS_NAMESPACE); },
defect
Dirty Rectangle
In terms of performance, Konva is not enough to compare these libraries with PIXI and ZRender. If we have a lot of Shapes on our Layer, if you want to update a Shape, it will still draw in full as Konva implements it.
Although Konva supports single Shape redrawing, the implementation does not brainstorm over the original location, which also means that if your graphics are below other node graphics, there will be problems.
So there is a lack of very important local update capability, which is what we often call dirty rectangles.
A dirty rectangle means that when we update a Shape, we use collision detection to compute all the Shapes that intersect it, merge them, and calculate a dirty area. Then we restrict Canvas to draw only in this dirty area by clip, which enables local updates.
Unfortunately, Konva's bounding box is so simple that it is not suitable for collision detection, nor does it provide the capability of dirty rectangles.