Skip to main content

保存和加载 HTML5 Canvas 舞台的最佳实践

保存/加载完整舞台内容的最佳方式是什么?如何实现撤销/重做?

如果你想保存/加载简单的画布内容,可以使用内置的 Konva 方法:node.toJSON()Node.create(json)。 请参见 简单示例复杂示例

但这些方法只适用于非常小的应用。在较大的应用程序中,使用这些方法非常困难。为什么?因为树结构通常非常复杂,你可能会有许多事件监听器、图片、滤镜等等。这些数据无法序列化成 JSON(或者说很难做到)。

此外,树中的节点常常包含大量信息,这些信息并不直接关系到应用的状态,而只是用来描述应用的视觉视图。

举个例子,假设我们有一个游戏,在画布中绘制几个球。这些球不仅仅是圆形,而是带有阴影和文字(比如“Made in China”)的复杂视觉对象组。现在假设你想序列化应用状态并在别处使用,比如发送到另一台电脑或实现撤销/重做功能。几乎所有的视觉信息(阴影、文字、大小)都不是关键,可能不需要保存。因为所有的球的阴影、大小等都是一样的。但关键是什么?在这种情况下,就是球的数量和它们的坐标。你只需要保存/加载这些信息。它就只是一个简单的数组:

var state = [{x: 10, y: 10}, { x: 160, y: 1041}]

现在你拥有这些信息,需要一个能够创建整个画布结构的函数。 如果你想更新画布,比如创建一个新球,不需要直接创建新的画布节点(比如实例化新的 Konva.Circle),只需要把新对象加入状态数组,然后更新(或者重建)画布。

这样,你不需要在保存/加载阶段考虑图片加载、滤镜、事件监听等问题。 因为这些操作都在你的 createupdate 函数中完成。

如果你熟悉许多现代框架(如 ReactVueAngular 等),会更容易理解我的意思。

此外,请看看这些示例,帮助你更好理解:

  1. 基于 React 的撤销/重做
  2. 基于 Vue 的保存/加载

如何实现 createupdate 函数?这要视情况而定。依我看,使用能帮你完成这项工作的框架会更简单,比如 react-konva

如果你不想使用这类框架,需要针对你的应用来设计。这里我会写个简单的演示给你一个思路。

最简单粗暴的方法是实现一个 create(state) 函数,做所有复杂的加载任务。 如果应用有更改,就销毁旧画布,再创建新画布。但这种方式可能性能较差。

稍微智能一点的做法是实现两个函数:create(state)update(state)create 负责实例化所有对象、绑定事件和加载图片。update 负责更新节点属性。如果对象数量改变,就销毁全部并重新创建;如果只改动了某些属性,就调用 update

说明: 该示例中,我们将有一组带滤镜的图片,你可以添加图片、移动它们,通过点击应用不同滤镜,并使用撤销/重做功能。

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);
};