保存和加载 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.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 { Stage, Layer, Image } from 'react-konva'; import { useState, useEffect } from 'react'; import useImage from 'use-image'; 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) => ( <Image key={i} image={lionImage} x={img.x} y={img.y} width={100} height={100} draggable filters={img.filter === 'blur' ? [Konva.Filters.Blur] : []} blurRadius={img.filter === 'blur' ? 10 : 0} 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)" @dragend="handleDragEnd(i, $event)" @click="handleClick(i)" /> </v-layer> </v-stage> </div> </template> <script setup> import { ref, computed } 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 [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]); }; // Initialize history when image is loaded if (lionImage.value) { history.value = [JSON.stringify(images.value)]; } </script>