前言
经过一个多月的重构,我成功打造了一款功能强大的多端趣味画板。这个画板集成了多种创意画笔,可以让用户体验到全新的绘画效果。无论是在移动端还是PC端,都能享受到较好的交互体验和效果展示。并且此项目拥有许多网上流行的画板功能,包括但不限于前进后退、复制删除、上传下载、多画板和多图层等等。详细功能我就不一一罗列了,期待你的探索。
Link: https://songlh.top/paint-board/
Github: https://github.com/LHRUN/paint-board
在完成重构后,我计划撰写一系列文章,一方面是为了记录技术细节,这是我一直以来的习惯。另一方面则是为了推广一下,期望得到你的使用和反馈,当然如果能点个 Star 就是对我最大的支持。
此篇文章是画板探秘系列的第一篇,我将会详细介绍关于撤销与反撤销的方案,示例皆以 Fabric.js 语法做示例,但思路是想通的。
方案一:画板级缓存
第一个方案是画板级缓存,这个是最简单的。在这种方案下,无需关心具体修改了哪些元素,也无需对整个画布数据进行差异化处理。简单来说,每当需要改变效果时(比如新增、删除、修改元素),只需将当前的画板数据 push 到历史操作栈上,然后撤销与反撤销重新加载相应的数据即可。
这种方案的优势在于简单直接,无需考虑细节,历史操作栈记录了每一步的变更。然而,需要注意的是,由于是整个画布数据的无差别缓存,内存占用会比较大。
一般维护这种历史栈有两种比较流行的方案
- 单一操作栈:
- 使用一个操作栈来记录每一步的操作
- 通过下标指定当前的状态,实现撤销与反撤销操作
- 当进行新的操作时,将该操作推入栈中,并更新当前状态的下标
- 双栈维护:
- 维护两个栈,一个是撤销栈,另一个是重做栈
- 当用户执行新的操作时,将该操作推入撤销栈,并清空重做栈
- 撤销操作时,从撤销栈中弹出最新的状态,并将其保存到重做栈
- 重做操作时,从重做栈中弹出状态,并推入撤销栈
以下是一个单一操作栈的简单示例:
class History {
constructor() {
this.stack = []; // 用来保存历史状态的数组
this.currentIndex = -1; // 指向当前状态的下标
}
// 添加当前状态到历史记录中
saveState(state) {
if (this.currentIndex < this.stack.length - 1) {
this.stack = this.stack.slice(0, this.currentIndex + 1);
}
this.stack.push(state);
this.currentIndex++;
}
// 撤销,返回上一个状态
undo() {
if (this.currentIndex > 0) {
this.currentIndex--;
return this.stack[this.currentIndex];
}
return null;
}
// 重做,返回下一个状态
redo() {
if (this.currentIndex < this.stack.length - 1) {
this.currentIndex++;
return this.stack[this.currentIndex];
}
return null;
}
// 检查是否可以撤销
canUndo() {
return this.currentIndex > 0;
}
// 检查是否可以重做
canRedo() {
return this.currentIndex < this.stack.length - 1;
}
// 重置历史记录
clear() {
this.stack = [];
this.currentIndex = -1;
}
}
方案二:缓存当前操作命令
当采用画板级缓存时,由于占用较大的内存且存在大量重复数据,就引出了第二个方案,即只缓存当前操作命令。这种策略更为灵活和节省内存,通过记录每一步具体的操作,避免了存储整个画布状态的冗余数据。在复杂项目中,合理使用这个方案可以带来很棒的性能提升。
比如
- 新增元素时,存储一个 “add” 命令以及当前绘制的对象。撤销时,只需对当前元素进行删除。
- 删除元素时,存储一个 “delete” 命令和被删除元素的标识。撤销时,恢复该被删除元素即可。
- 修改元素时,存储一个 “modify” 命令和当前变化的元素数据。对于位置移动等简单操作,只需存储位置坐标的变化。对于缩放等复杂操作,存储当前的缩放比例等变化数据。撤销时,根据命令类型进行相应的操作恢复
此方案在复杂项目中不仅减少了内存占用,还提高了灵活性,合理控制每一步操作。不过就是实现过程比较复杂,需要费较大的精力在 diff 算法上来判断状态的变化,确保数据的准确记录。
以下只是一个简单的示例,与具体项目中的绘制逻辑关联性比较强。
class Command {
constructor(execute, undo, value) {
this.execute = execute; // 执行命令
this.undo = undo; // 撤销命令
this.value = value; // 命令的参数,可以是当前绘制对象
}
// 执行
exec() {
this.execute(this.value);
}
// 撤销
unexec() {
this.undo(this.value);
}
}
class History {
constructor() {
this.commands = []; // 保存命令的堆栈
this.index = -1; // 操作命令在堆栈中的位置指针
}
// 执行新命令并入栈
execute(command) {
this.commands.slice(0, this.index)
this.commands.push(command);
command.exec();
this.index++;
}
// 撤销
undo() {
if (this.index < 0) return;
const command = this.commands[this.index];
if (command) {
command.unexec();
this.index--;
}
}
// 重做
redo() {
const command = this.commands[this.index + 1];
if (command) {
command.exec();
this.index++;
}
}
}
画板级缓存优化
但是在处理复杂的绘制效果时,命令级缓存的计算就会变得很复杂,很难准确计算多个元素之间的差异。在这种情况下,我建议采用一个优化方案,对方案一的画板级缓存进行改进。画板级缓存的主要弊端在于其占用的内存较大,但通过对每次操作的画板缓存状态进行 diff 操作,只存储当前差异数据,这样内存占用会大大减少。至于差异比较操作,我推荐使用 jsondiffpatch,它能较的好处理两个对象的差异。
jsondiffpatch
有几个常用的 API
jsondiffpatch.diff(left, right[, delta])
- 比较两个对象
left
和right
的差异。可选参数delta
是一个已知的差异,用于提高性能
- 比较两个对象
jsondiffpatch.patch(obj, delta)
- 将给定的差异
delta
应用到对象obj
上,返回更新后的对象
- 将给定的差异
jsondiffpatch.unpatch(obj, delta)
- 从对象
obj
上移除给定的差异delta
,返回还原前的对象
- 从对象
以下是将 jsondiffpatch
应用到 History
中,我目前画板是采用的这个方案。
import { diff, unpatch, patch, Delta } from 'jsondiffpatch'
export class History {
diffs: Array<Delta> = []
canvasData: Partial<IBoardData> = {}
index = 0
constructor() {
const canvasJson = canvas.toDatalessJSON()
this.canvasData = canvasJson
}
saveState() {
this.diffs = this.diffs.slice(0, this.index)
const canvasJson = canvas.toDatalessJSON()
const delta = diff(canvasJson, this.canvasData)
this.diffs.push(delta)
this.index++
this.canvasData = canvasJson
}
undo() {
if (canvas && this.index > 0) {
const delta = this.diffs[this.index - 1]
this.index--
const canvasJson = patch(this.canvasData, delta)
canvas.loadFromJSON(canvasJson, () => {
canvas.requestRenderAll()
this.canvasData = canvasJson
})
}
}
redo() {
if (this.index < this.diffs.length && canvas) {
const delta = this.diffs[this.index]
this.index++
const canvasJson = unpatch(this.canvasData, delta)
canvas.loadFromJSON(canvasJson, () => {
canvas.requestRenderAll()
this.canvasData = canvasJson
})
}
}
clean() {
canvas.clear()
this.index = 0
this.diffs = []
this.canvasData = {}
}
}
总结
感谢你的阅读。以上就是本文的全部内容,希望这篇文章对你有所帮助,欢迎点赞和收藏。如果有任何问题,欢迎在评论区进行讨论