保存和加载 HTML5 Canvas 舞台的最佳实践
保存/加载完整舞台内容的最佳方式是什么?如何实现撤销/重做?
如果你想保存/加载简单的画布内容,可以使用内置的 Konva
方法:node.toJSON()
和 Node.create(json)
。
请参见 简单示例 和 复杂示例。
但这些方法只适用于非常小的应用。在较大的应用程序中,使用这些方法非常困难。为什么?因为树结构通常非常复杂,你可能会有许多事件监听器、图片、滤镜等等。这些数据无法序列化成 JSON(或者说很难做到)。
此外,树中的节点常常包含大量信息,这些信息并不直接关系到应用的状态,而只是用来描述应用的视觉视图。
举个例子,假设我们有一个游戏,在画布中绘制几个球。这些球不仅仅是圆形,而是带有阴影和文字(比如“Made in China”)的复杂视觉对象组。现在假设你想序列化应用状态并在别处使用,比如发送到另一台电脑或实现撤销/重做功能。几乎所有的视觉信息(阴影、文字、大小)都不是关键,可能不需要保存。因为所有的球的阴影、大小等都是一样的。但关键是什么?在这种情况下,就是球的数量和它们的坐标。你只需要保存/加载这些信息。它就只是一个简单的数组:
var state = [{x: 10, y: 10}, { x: 160, y: 1041}]
现在你拥有这些信息,需要一个能够创建整个画布结构的函数。
如果你想更新画布,比如创建一个新球,不需要直接创建新的画布节点(比如实例化新的 Konva.Circle
),只需要把新对象加入状态数组,然后更新(或者重建)画布。
这样,你不需要在保存/加载阶段考虑图片加载、滤 镜、事件监听等问题。
因为这些操作都在你的 create
或 update
函数中完成。
如果你熟悉许多现代框架(如 React
、Vue
、Angular
等),会更容易理解我的意思。
此外,请看看这些示例,帮助你更好理解:
如何实现 create
和 update
函数?这要视情况而定。依我看,使用能帮你完成这项工作的框架会更简单,比如 react-konva。
如果你不想使用这类框架,需要针对你的应用来设计。这里我会写个简单的演示给你一个思路。
最简单粗暴的方法是实现一个 create(state)
函数,做所有复杂的加载任务。
如果应用有更改,就销毁旧画布,再创建新画布。但这种方式可能性能较差。
稍微智能一点的做法是实现两个函数:create(state)
和 update(state)
。create
负责实例化所有对象、绑定事件和加载图片。update
负责更新节点属性。如果对象数量改变,就销毁全部并重新创建;如果只改动了某些属性,就调用 update
。
说明: 该示例中,我们将有一组带滤镜的图片,你可以添加图片、移动它们,通过点击应用不同滤镜,并使用撤销/重做功能。
- Vanilla
- React
- Vue
import Konva from 'konva'; // 初始状态 let state = { images: [ { x: 50, y: 50, filter: 'none' }, { x: 150, y: 50, filter: 'blur' } ] }; // 撤销/重做历史记录 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); // 创建容器 const container = document.createElement('div'); container.style.position = 'relative'; document.body.appendChild(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); // 创建 UI 按钮 const addButton = document.createElement('button'); addButton.textContent = '添加图片'; addButton.style.margin = '0 5px'; buttonContainer.appendChild(addButton); const undoButton = document.createElement('button'); undoButton.textContent = '撤销'; undoButton.style.margin = '0 5px'; buttonContainer.appendChild(undoButton); const redoButton = document.createElement('button'); redoButton.textContent = '重做'; redoButton.style.margin = '0 5px'; buttonContainer.appendChild(redoButton); // 将舞台容器移动到我们的容器中 const stageContainer = document.getElementById('container'); container.appendChild(stageContainer); stageContainer.style.position = 'absolute'; stageContainer.style.top = '0'; stageContainer.style.left = '0'; // 加载图片 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)); } // 绑定事件监听器 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}>添加图片</button> <button style={{ margin: '0 5px' }} onClick={handleUndo}>撤销</button> <button style={{ margin: '0 5px' }} onClick={handleRedo}>重做</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">添加图片</button> <button style="margin: 0 5px" @click="handleUndo">撤销</button> <button style="margin: 0 5px" @click="handleRedo">重做</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]); }; // 图片加载完成时初始化历史记录 if (lionImage.value) { history.value = [JSON.stringify(images.value)]; } </script>