前端图形学实战: 100行代码实现几何画板的撤销重做等功能(vue3 + vite版)

6,211 阅读9分钟

前言

本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究

hello, 大家好, 我是徐小夕, 今天又到了我们的博学时间。

本文是 100+前端几何学应用案例 专栏的第三篇文章, 在第一篇文章 几何学在前端边界计算中的应用和原理分析 和第二篇文章 前端图形学实战: 从零开发几何画板(vue3 + vite版) 中我介绍了几何学在前端领域的应用以及如何从零开发一个几何画板:

2022-10-15 20.46.21.gif

如果大家感兴趣可以在 gitee 查看我的具体代码实现: gitee.com/lowcode-chi…

接下来就继续上次的话题, 来从零实现几何画板撤销重做功能。

我们都知道各种设计工具如figma, PhotoShop, 或者最近比较火的可视化低代码平台如H5-Dooring 都有撤销重做功能, 主要是为了降低用户的误操作成本, 带来更好的搭建体验, 并且这两个功能基本成为了可视化领域的标配功能, 接下来我将带大家介绍一下市面上常用的几种撤销重做的实现方案以及撤销重做功能底层的实用价值。

你将收获

  • 撤销重做的实现思路
  • vuereact 框架下的撤销重做库介绍
  • 从零实现几何画板的撤销重做功能
  • 挖掘 撤销重做 的扩展场景

demo演示

2022-11-06 11.27.44.gif

技术实现

在实现撤销重做功能之前, 我们需要先理清设计思路, 这样才能让自己的代码更健壮。

实现思路

分析了几种撤销重做的场景后我总结出如下几个要点:

  • 支持基础的撤销重做能力(取消和恢复用户操作的能力)
  • 需要限制最大可操作记录数(防止历史记录数过大导致前端性能问题)
  • 操作记录的当前索引(方便做更可控的撤销重做控制)
  • 在撤销的过程中发生的任何改动, 都会清空当前步骤之后的所有记录
  • 操作历史持久化(可选, 即是否需要在用户刷新页面之后仍然保留操作记录历史)

为了让大家更好的理解这些要点,我画了一个 撤销重做 过程的流程图:

image.png

image.png

还有一点需要注意的是: 在撤销的过程中发生的任何改动, 都会清空当前步骤之后的所有记录,最终产生一个新的状态分支:

image.png

好了, 有了以上的思路, 我们就开始来一步步实现撤销重做功能。

创建记录管理器(recordManager)

为了保证专栏文章的连贯性, 我还是以上一篇文章前端图形学实战: 从零开发几何画板(vue3 + vite版) 中实现的几何画板为例, 采用 vue3 来实现, 其他常用框架如React, Angular 或者 Svelte 用类似的方式也能实现, 如果大家感兴趣也可以尝试用不同框架来实现一套自己的撤销重做插件。

image.png

之前我们的画板数据是存放在 canvasBox 对象中的, 如下:

type shapeType = "rect" | "circle" | "line";

interface IBaseShapeProp {
  type: shapeType;
  key: string;
  style: any;
}

const canvasBox = ref<{ [key in shapeType]: IBaseShapeProp[] }>({
  rect: [],
  circle: [],
  line: [],
});

每次更改画布状态我们都会更新 canvasBox, 为了实现撤销重做功能, 我们需要将每一次操作更改的内容保存起来, 这里我采用目前比较流行的数据快照技术来实现, 接下来我会和大家详细介绍, 这里我们先构建我们的记录管理器(recordManager):

const recordManager = ref({
  snapshots: [
    {
      rect: [],
      circle: [],
      line: [],
    },
  ],
  curIndex: 0,
  maxLimit: 50,
});

其中 snapshots 就是存放我们操作记录的集合, curIndex 是当前操作索引的下标, maxLimit 是最大保存的历史记录数。

往记录管理器中添加操作快照

那么构造好了记录管理器, 我们如何往记录管理器中添加操作快照呢?

最直接的方法就是在每个操作入口(如移动, 创建元素, 删除元素等)都手动往记录管理器(recordManager)中添加变更快照, 但是我们的操作类型很多, 比如:

  • 移动元素
  • 新建元素
  • 删除元素
  • 缩放元素
  • 编辑元素属性

等等, 如果按照上述方法维护成本会很大, 而且可能会出现遗漏操作的风险, 所以不建议采用。

同时在设计可视化编辑器的时候, 我们对页面描述协议(page schema)或组件描述协议(component schema)最好进行统一性设计, 即可以通过一套统一的Schema 来描述整个页面, 对于开放协议可以通过属性引用(ref)来协调。

具体可以参考我之前的分享(点击下图可查看):

在我们的画板应用中, canvasBox 即使这样的一个统一的协议约定, 在canvasBox 对象中我们可以描述整个画布元素, 所以我们可以很轻松的用响应式设计来对 canvasBox 进行监听, 每次canvasBox 的更新即代表一个新的快照, 好在 vue3 提供了非常方便的响应式hooks, 我们可以使用组合式函数的 watch 钩子来实现监听:

import { ref, watch } from "vue";

const canvasBox = ref<{ [key in shapeType]: IBaseShapeProp[] }>({
  rect: [],
  circle: [],
  line: [],
});

watch(
 canvasBox, 
 (state, prevState) => {
     // 我们的业务逻辑...
 }, 
 { deep: true }
);

通过上面的方法我们就能实现对 canvasBox 进行监听, 并在其变化时添加我们的操作快照。 (因为我们监听的数据是对象, 所以这里我们设置了 { deep: true })

1. 快照的生成方式

首先我们先来聊聊为什么要创建快照。

这里有一个场景, 假如我们在画布上操作了50次, 如下:

2022-11-06 13.23.52.gif

如果我们单纯的用数组 push 的方式记录每一次操作:

snapshots.push(canvasBox.value);

这种操作会存在很大的风险,因为 JavaScript 的对象是引用类型,变量名只是保存了它们的引用,真正的数据存放在堆内存中,所以 snapshots 和 canvasBox 会共享一份最新的数据, 导致无法存储之前的历史状态, 所以我们需要使用 深拷贝 或者 创建不可变数据 的方式来生成快照。

对于 创建不可变数据 的方式, 大家如果熟悉 redux 的话不会很陌生, 为了更简单易懂, 我这里采用深拷贝的方式, 首先需要安装一下 xijs :

yarn add xijs

image.png

它是一款面向复杂场景的 javascript 工具库, 内置了很多开箱即用的工具函数, 感兴趣的可以自行体验, 这里就不详细介绍了。

接下来看看我们初级版本的数据快照方法:

import { debounce, cloneDeep } from "xijs";
import { ref, watch } from "vue";
const recordManager = ref({
  snapshots: [
    {
      rect: [],
      circle: [],
      line: [],
    },
  ],
  curIndex: 0,
  maxLimit: 50,
});

const pushRecordFn = (
  state: { [key in shapeType]: IBaseShapeProp[] },
  prevState: { [key in shapeType]: IBaseShapeProp[] }
) => {
  // 记录快照
  recordManager.value.snapshots.push(cloneDeep(state));
  // 更新索引指针
  recordManager.value.curIndex = recordManager.value.snapshots.length - 1;
};

watch(canvasBox, pushRecordFn, { deep: true });

这样我们就能将每次的才操作状态保存下来了。

2. 如果两个状态相同, 则不推入历史记录

我们此时还会发现一种情况, 即canvasBox 更新了, 但是更新的内容没有变, 比如元素从默认状态变成可编辑状态:

image.png

此时是不需要记录到快照里的, 所以针对类似的情况我们需要进行过滤:

const pushRecordFn = (
  state: { [key in shapeType]: IBaseShapeProp[] },
  prevState: { [key in shapeType]: IBaseShapeProp[] }
) => {
  const { snapshots, maxLimit, curIndex } = recordManager.value;
  
  // 如果两个状态相同, 则不推入历史记录
  if (!diff(state, snapshots[curIndex])) {
    return;
  }

  recordManager.value.snapshots.push(cloneDeep(state));
  recordManager.value.curIndex = recordManager.value.snapshots.length - 1;
};

上面代码的 diff 方法可以比较两个对象是否相同, 我们可以在 xijs 中找到对应的方法, 或者自己实现一个比较对象的方法也可。

3. 如果在撤销的过程中重新执行了新的操作, 则创建新的操作分支

这个操作逻辑也就是我们开头介绍的, 这里回顾一下:

image.png

具体实现如下:

const pushRecordFn = (
  state: { [key in shapeType]: IBaseShapeProp[] },
  prevState: { [key in shapeType]: IBaseShapeProp[] }
) => {
  const { snapshots, maxLimit, curIndex } = recordManager.value;
  
  // 如果两个状态相同, 则不推入历史记录
  if (!diff(state, snapshots[curIndex])) {
    return;
  }
  
  // 如果在撤销的过程中重新执行了新的操作, 则覆盖上一个状态
  if (snapshots.length - 1 !== curIndex) {
    snapshots.splice(curIndex + 1, snapshots.length);
  }

  recordManager.value.snapshots.push(cloneDeep(state));
  recordManager.value.curIndex = recordManager.value.snapshots.length - 1;
};

4. 超过了最大限制记录, 删除头部第一项

因为我们限制了最大的历史记录数, 所以当超过了记录上限时, 我们需要删除最前面的一项:

image.png

所以我们完整的快照管理方法如下:

const pushRecordFn = (
  state: { [key in shapeType]: IBaseShapeProp[] },
  prevState: { [key in shapeType]: IBaseShapeProp[] }
) => {
  const { snapshots, maxLimit, curIndex } = recordManager.value;
  
  // 如果两个状态相同, 则不推入历史记录
  if (!diff(state, snapshots[curIndex])) {
    return;
  }
  
  // 如果在撤销的过程中重新执行了新的操作, 则覆盖上一个状态
  if (snapshots.length - 1 !== curIndex) {
    snapshots.splice(curIndex + 1, snapshots.length);
  }
  
  // 超过了最大限制记录
  if (snapshots.length >= maxLimit) {
    snapshots.shift();
  }

  recordManager.value.snapshots.push(cloneDeep(state));
  recordManager.value.curIndex = recordManager.value.snapshots.length - 1;
};

同时为了更好的性能, 我们还可以对监听进行防抖优化( 毕竟移动元素等时候高频操作, 对性能开销相对较大), 具体代码如下:

import { debounce } from "xijs";

watch(canvasBox, debounce(pushRecordFn, 300), { deep: true });

实现撤销重做功能

在实现上面的快照管理之后, 实现撤销重做就非常简单了, 我们只需要做好阈值管理即可:

import { cloneDeep } from "xijs";

const undo = () => {
  // 撤销
  const { snapshots, maxLimit, curIndex } = recordManager.value;
  // 如果到下限了, 直接返回
  if (curIndex === 0) return;
  recordManager.value.curIndex--;
  canvasBox.value = cloneDeep(
    recordManager.value.snapshots[recordManager.value.curIndex]
  );
};

const redo = () => {
  // 重做
  const { snapshots, maxLimit, curIndex } = recordManager.value;
  // 超过最大记录长度, 直接返回
  if (curIndex >= snapshots.length - 1) {
    return;
  }
  recordManager.value.curIndex++;
  canvasBox.value = recordManager.value.snapshots[recordManager.value.curIndex];
};

接下来看看我们实现的效果:

2022-11-06 14.02.52.gif

扩展

对于撤销重做, 我们可以进一步扩展, 比如可以利用快照里记录, 实现快照动画(可以参考鼻笔者之前开发的 Gif生成器), 实现批量重复操作, 类似ps里的功能, 我们还可以对快照进行埋点, 实现页面级别的监控等。

后期规划

后面会继续完善画板, 实现图层管理, 导入导出, 下载等可视化编辑器常用的功能, 如果大家感兴趣, 可以参考我的github: gitee.com/lowcode-chi…

如果文章对你有帮助, 欢迎点赞评论, 让我们一起探索真正的前端技术。

更多推荐

(10月最新) 前端图形学实战: 从零开发几何画板(vue3 + vite版)

几何学在前端边界计算中的应用和原理分析(一)

Dooring无代码搭建平台技术演进之路

从零开发一款可视化大屏制作平台

从零开发一款图片编辑器Mitu