When building canvas-based applications with Konva inside a Vue environment, a common challenge is managing the rendering stack so that text appears on top of images. Since image loading is asynchronous, the drawing logic often executes before the image is ready, causing the text to render underneath the image.
Problem Scenario
Imagine a requirement to render multiple image blocks and then overlay a number on each block. The naive approach involves looping through items and adding the image first, then the text. However, because the image loading happens in the background, the text might be added to the layer before the image exists, resulting in incorrect z-index behavior.
Initial Implementation
We define two helper functions. One handles the image instantiation, and the other handles generic shape creation (specifically text).
const insertImageNode = (config, url, isInteractive, container, evtType, handler, done) => {
const targetLayer = konvaStore.layers['mainLayer'];
const imgElement = new Image();
let canvasImage = null;
imgElement.onload = () => {
canvasImage = new Konva.Image({
...config,
image: imgElement,
listening: isInteractive
});
if (evtType && handler) {
canvasImage.on(evtType, handler);
}
container ? container.add(canvasImage) : targetLayer.add(canvasImage);
targetLayer.batchDraw();
if (done) done();
};
imgElement.src = url;
return imgElement;
};
const insertTextNode = (config, type, isInteractive, container, content) => {
const targetLayer = konvaStore.layers['mainLayer'];
let node = null;
if (type === 'text') {
node = new Konva.Text({
...config,
listening: isInteractive,
text: content,
fontSize: 20,
align: 'center',
verticalAlign: 'middle'
});
}
container ? container.add(node) : targetLayer.add(node);
targetLayer.batchDraw();
return node;
};
The Async Race Condition
The core issue lies in the imgElement.onload event. JavaScript does not wait for the image to finish loading before moving to the next line of code. If you call insertTextNode immediately after insertImageNode without waiting for the load event, the text is drawn first.
Incorrect Approach: External Callback
A common mistake is placing the callback outside the onload block. This causes the callback to fire immediately, ignoring the async nature of the image loading.
const insertImageNode = (config, url, isInteractive, container, evtType, handler, done) => {
const targetLayer = konvaStore.layers['mainLayer'];
const imgElement = new Image();
let canvasImage = null;
imgElement.onload = () => {
canvasImage = new Konva.Image({ ...config, image: imgElement, listening: isInteractive });
container ? container.add(canvasImage) : targetLayer.add(canvasImage);
targetLayer.batchDraw();
};
// WRONG: This executes immediately, not after the image loads
if (done) done();
imgElement.src = url;
return imgElement;
};
Correct Approach: Internal Callback
To guarantee the text renders after the image, the callback must be invoked strictly inside the onload handler.
const insertImageNode = (config, url, isInteractive, container, evtType, handler, done) => {
const targetLayer = konvaStore.layers['mainLayer'];
const imgElement = new Image();
imgElement.onload = () => {
const canvasImage = new Konva.Image({ ...config, image: imgElement, listening: isInteractive });
if (evtType && handler) {
canvasImage.on(evtType, handler);
}
container ? container.add(canvasImage) : targetLayer.add(canvasImage);
targetLayer.batchDraw();
// CORRECT: Executes only after the image is ready
if (done) done();
};
imgElement.src = url;
return imgElement;
};
Managing Loop Variables in Async Callbacks
When running this inside a loop, variable scoping becomes critical. If you use var or fail to capture the current state, the callback might reference the last value of the loop iterator.
Solution 1: Using Block Scoping (let)
Using let inside the loop creates a new binding for each iteration, ensuring the callback captures the correct values.
for (let idx = 0; idx < 4; idx++) {
let rectProps = {
x: 100 + (idx % 2 === 0 ? 0 : 150),
y: 50 + (idx < 2 ? 0 : 100),
width: 100,
height: 100
};
insertImageNode(
rectProps,
'./img/b.png',
true,
group,
'click',
() => {},
() => {
// 'rectProps' and 'idx' are correctly captured here
insertTextNode(rectProps, 'text', false, group, `${idx + 1}`);
}
);
}
Solution 2: Immediately Invoked Function Expression (IIFE)
If you must support environments where block scoping is tricky, wrap the logic in an IIFE to snapshot the current values.
for (var idx = 0; idx < 4; idx++) {
(function(currentIdx, currentProps) {
insertImageNode(
currentProps,
'./img/b.png',
true,
group,
'click',
() => {},
() => {
insertTextNode(currentProps, 'text', false, group, `${currentIdx + 1}`);
}
);
})(
idx,
{ x: 100 + (idx % 2 === 0 ? 0 : 150), y: 50 + (idx < 2 ? 0 : 100), width: 100, height: 100 }
);
}
The key takeaway is that let creates a fresh scope per iteration, while var is function-scoped and leads to race conditions where the callback sees the final loop value (e.g., 4) instead of the intended index.