保存和加载 HTML5 Canvas 舞台的最佳实践
保存和加载完整舞台内容并实现撤销和重做的最佳方式是什么?
如果要保存或加载简单的 Canvas 内容,可以使用内置的 Konva 方法:node.toJSON() 和 Node.create(json)。
请参阅简单加载和复杂加载示例。
但这些方法只适用于很小的应用。在较大的应用中,这些方法很难使用。因为大型应用的树结构通常很复杂,其中可能包含大量事件 监听器、图像和滤镜等内容。这些数据无法序列化为 JSON,或很难完成序列化。
树中的节点通常还包含大量与应用状态没有直接关系的信息。这些信息仅用于描述应用的视觉外观。
例如,假设一个游戏在 Canvas 中绘制了多个球。每个球不仅是一个圆形,还是一个复杂的视觉对象组,其中包含阴影和文本(例如“中国制造”)。现在,假设你要序列化应用状态并在其他位置使用,例如发送到另一台计算机或实现撤销和重做。几乎所有视觉信息(阴影、文本和尺寸)都不是关键信息,可能不需要保存,因为所有球都使用相同的阴影和尺寸等设置。真正关键的信息只有球的数量及其坐标。你只需要保存和加载这些信息。它们只是一个简单的数组:
var state = [{x: 10, y: 10}, { x: 160, y: 1041}]
获得这些信息后,你需要一个可以创建完整 Canvas 结构的函数。
如果要更新 Canvas,例如创建一个新球,不需要直接创建新的 Canvas 节点(例如创建 Konva.Circle 的新实例)。只需向状态中添加一个新对象,然后更新或重新创建 Canvas。
这样,在保存和加载阶段就不需要处理图像加载、滤镜和事件监听器等内容。因为这些操作都在 create 或 update 函数中完成。
如果你了解 React、Vue 和 Angular 等现代框架的工作方式,就能更好地理解这种方法。
还可以查看以下示例以进一步了解这种方法:
如何实现 create 和 update 函数取决于具体情况。使用 react-konva 等能处理这些工作的框架会更容易。
如果不想使用此类框架,需要根据自己的应用来设计。下面的小型示例介绍一种实现方式。
最简单的方法是只实现一个 create(state) 函数,由它完成所有复杂的加载工作。
应用发生更改时,只需销毁 Canvas 并创建一个新 Canvas。但这种方法可能导致性能下降。
更合理的实现是创建 create(state) 和 update(state) 两个函数。create 创建所有必需对象的实例、绑定事件并加载图像。update 更新节点的属性。如果对象数量发生变化,则销毁所有对象并从头创建。如果只有部分属性发生变化,则调用 update。
**操作说明:此示例包含多个带滤镜的图像。你可以添加和移动图像,单击图像以应用新滤镜,并使用撤销和重做功能。
- Vanilla
- React
- Vue
import Konva from 'konva';
// Initial state
let state = {
images: [
{ x: 50, y: 50, filter: 'none' },
{ x: 150, y: 50, filter: 'blur' }
]
};
// History for undo/redo
const history = [JSON.stringify(state)];
let historyStep = 0;
const stage = new Konva.Stage({
container: 'container',
width: window.innerWidth,
height: window.innerHeight,
});
const layer = new Konva.Layer();
stage.add(layer);
// Create container
const container = document.createElement('div');
container.style.position = 'relative';
document.body.appendChild(container);
// Create button container
const buttonContainer = document.createElement('div');
buttonContainer.style.position = 'absolute';
buttonContainer.style.top = '10px';
buttonContainer.style.left = '10px';
buttonContainer.style.zIndex = '10';
container.appendChild(buttonContainer);
// Create UI buttons
const addButton = document.createElement('button');
addButton.textContent = 'Add Image';
addButton.style.margin = '0 5px';
buttonContainer.appendChild(addButton);
const undoButton = document.createElement('button');
undoButton.textContent = 'Undo';
undoButton.style.margin = '0 5px';
buttonContainer.appendChild(undoButton);
const redoButton = document.createElement('button');
redoButton.textContent = 'Redo';
redoButton.style.margin = '0 5px';
buttonContainer.appendChild(redoButton);
// Move stage container into our container
const stageContainer = document.getElementById('container');
container.appendChild(stageContainer);
stageContainer.style.position = 'absolute';
stageContainer.style.top = '0';
stageContainer.style.left = '0';
// Load image
const imageObj = new Image();
imageObj.src = 'https://konvajs.org/assets/lion.png';
function createImage(imageConfig) {
const image = new Konva.Image({
image: imageObj,
x: imageConfig.x,
y: imageConfig.y,
width: 100,
height: 100,
draggable: true
});
if (imageConfig.filter === 'blur') {
image.cache();
image.filters([Konva.Filters.Blur]);
image.blurRadius(10);
}
return image;
}
function create(state) {
layer.destroyChildren();
state.images.forEach(imgConfig => {
const image = createImage(imgConfig);
image.on('dragend', () => {
const pos = image.position();
const index = layer.children.indexOf(image);
state.images[index] = {
...state.images[index],
x: pos.x,
y: pos.y
};
saveHistory();
});
image.on('click', () => {
const index = layer.children.indexOf(image);
state.images[index] = {
...state.images[index],
filter: state.images[index].filter === 'none' ? 'blur' : 'none'
};
saveHistory();
create(state);
});
layer.add(image);
});
}
function saveHistory() {
historyStep++;
history.length = historyStep;
history.push(JSON.stringify(state));
}
// Add event listeners
addButton.addEventListener('click', () => {
state.images.push({
x: Math.random() * stage.width(),
y: Math.random() * stage.height(),
filter: 'none'
});
saveHistory();
create(state);
});
undoButton.addEventListener('click', () => {
if (historyStep === 0) return;
historyStep--;
state = JSON.parse(history[historyStep]);
create(state);
});
redoButton.addEventListener('click', () => {
if (historyStep === history.length - 1) return;
historyStep++;
state = JSON.parse(history[historyStep]);
create(state);
});
imageObj.onload = () => {
create(state);
};
import Konva from 'konva';
import { Stage, Layer, Image } from 'react-konva';
import { useState, useEffect, useRef } from 'react';
import useImage from 'use-image';
const FilteredImage = ({ filter, ...props }) => {
const imageRef = useRef();
useEffect(() => {
imageRef.current?.cache();
}, [props.image]);
return (
<Image
ref={imageRef}
{...props}
filters={filter === 'blur' ? [Konva.Filters.Blur] : []}
blurRadius={filter === 'blur' ? 10 : 0}
/>
);
};
const App = () => {
const [images, setImages] = useState([
{ x: 50, y: 50, filter: 'none' },
{ x: 150, y: 50, filter: 'blur' }
]);
const [history, setHistory] = useState([]);
const [historyStep, setHistoryStep] = useState(0);
const [lionImage] = useImage('https://konvajs.org/assets/lion.png', 'anonymous');
useEffect(() => {
if (lionImage) {
setHistory([JSON.stringify(images)]);
}
}, [lionImage]);
const handleDragEnd = (index, e) => {
const newImages = [...images];
newImages[index] = {
...newImages[index],
x: e.target.x(),
y: e.target.y()
};
setImages(newImages);
saveHistory(newImages);
};
const handleClick = (index) => {
const newImages = [...images];
newImages[index] = {
...newImages[index],
filter: newImages[index].filter === 'none' ? 'blur' : 'none'
};
setImages(newImages);
saveHistory(newImages);
};
const saveHistory = (newImages) => {
const newHistory = history.slice(0, historyStep + 1);
newHistory.push(JSON.stringify(newImages));
setHistory(newHistory);
setHistoryStep(newHistory.length - 1);
};
const handleAdd = () => {
const newImages = [...images, {
x: Math.random() * window.innerWidth,
y: Math.random() * window.innerHeight,
filter: 'none'
}];
setImages(newImages);
saveHistory(newImages);
};
const handleUndo = () => {
if (historyStep === 0) return;
const newStep = historyStep - 1;
setHistoryStep(newStep);
setImages(JSON.parse(history[newStep]));
};
const handleRedo = () => {
if (historyStep === history.length - 1) return;
const newStep = historyStep + 1;
setHistoryStep(newStep);
setImages(JSON.parse(history[newStep]));
};
return (
<div style={{ position: 'relative' }}>
<div style={{
position: 'absolute',
top: 10,
left: 10,
zIndex: 10
}}>
<button style={{ margin: '0 5px' }} onClick={handleAdd}>Add Image</button>
<button style={{ margin: '0 5px' }} onClick={handleUndo}>Undo</button>
<button style={{ margin: '0 5px' }} onClick={handleRedo}>Redo</button>
</div>
<Stage width={window.innerWidth} height={window.innerHeight}>
<Layer>
{lionImage && images.map((img, i) => (
<FilteredImage
key={i}
image={lionImage}
x={img.x}
y={img.y}
width={100}
height={100}
draggable
filter={img.filter}
onDragEnd={(e) => handleDragEnd(i, e)}
onClick={() => handleClick(i)}
/>
))}
</Layer>
</Stage>
</div>
);
};
export default App;
<template>
<div style="position: relative">
<div style="position: absolute; top: 10px; left: 10px; z-index: 10">
<button style="margin: 0 5px" @click="handleAdd">Add Image</button>
<button style="margin: 0 5px" @click="handleUndo">Undo</button>
<button style="margin: 0 5px" @click="handleRedo">Redo</button>
</div>
<v-stage :config="stageSize">
<v-layer>
<v-image
v-for="(img, i) in images"
:key="i"
:config="getImageConfig(img)"
ref="imageRefs"
@dragend="handleDragEnd(i, $event)"
@click="handleClick(i)"
/>
</v-layer>
</v-stage>
</div>
</template>
<script setup>
import { nextTick, ref, watch } from 'vue';
import Konva from 'konva';
import { useImage } from 'vue-konva';
const stageSize = {
width: window.innerWidth,
height: window.innerHeight
};
const images = ref([
{ x: 50, y: 50, filter: 'none' },
{ x: 150, y: 50, filter: 'blur' }
]);
const history = ref([]);
const historyStep = ref(0);
const imageRefs = ref([]);
const [lionImage] = useImage('https://konvajs.org/assets/lion.png', 'anonymous');
const getImageConfig = (img) => ({
image: lionImage.value,
x: img.x,
y: img.y,
width: 100,
height: 100,
draggable: true,
filters: img.filter === 'blur' ? [Konva.Filters.Blur] : [],
blurRadius: img.filter === 'blur' ? 10 : 0
});
const saveHistory = (newImages) => {
const newHistory = history.value.slice(0, historyStep.value + 1);
newHistory.push(JSON.stringify(newImages));
history.value = newHistory;
historyStep.value = newHistory.length - 1;
};
const handleDragEnd = (index, e) => {
const newImages = [...images.value];
const pos = e.target.position();
newImages[index] = {
...newImages[index],
x: pos.x,
y: pos.y
};
images.value = newImages;
saveHistory(newImages);
};
const handleClick = (index) => {
const newImages = [...images.value];
newImages[index] = {
...newImages[index],
filter: newImages[index].filter === 'none' ? 'blur' : 'none'
};
images.value = newImages;
saveHistory(newImages);
};
const handleAdd = () => {
const newImages = [...images.value, {
x: Math.random() * window.innerWidth,
y: Math.random() * window.innerHeight,
filter: 'none'
}];
images.value = newImages;
saveHistory(newImages);
};
const handleUndo = () => {
if (historyStep.value === 0) return;
historyStep.value--;
images.value = JSON.parse(history.value[historyStep.value]);
};
const handleRedo = () => {
if (historyStep.value === history.value.length - 1) return;
historyStep.value++;
images.value = JSON.parse(history.value[historyStep.value]);
};
const cacheImages = async () => {
await nextTick();
imageRefs.value.forEach((imageRef) => imageRef.getNode().cache());
};
watch(lionImage, (loadedImage) => {
if (!loadedImage) return;
if (history.value.length === 0) {
history.value = [JSON.stringify(images.value)];
}
cacheImages();
}, { immediate: true });
watch(images, cacheImages);
</script>