画板探秘系列:画板中的时光倒流术

前言

经过一个多月的重构,我成功打造了一款功能强大的多端趣味画板。这个画板集成了多种创意画笔,可以让用户体验到全新的绘画效果。无论是在移动端还是PC端,都能享受到较好的交互体验和效果展示。并且此项目拥有许多网上流行的画板功能,包括但不限于前进后退、复制删除、上传下载、多画板和多图层等等。详细功能我就不一一罗列了,期待你的探索。

Link: songlh.top/paint-board...

Github: github.com/LHRUN/paint...

在完成重构后,我计划撰写一系列文章,一方面是为了记录技术细节,这是我一直以来的习惯。另一方面则是为了推广一下,期望得到你的使用和反馈,当然如果能点个 Star 就是对我最大的支持。

此篇文章是画板探秘系列的第一篇,我将会详细介绍关于撤销与反撤销的方案,示例皆以 Fabric.js 语法做示例,但思路是想通的。

方案一:画板级缓存

第一个方案是画板级缓存,这个是最简单的。在这种方案下,无需关心具体修改了哪些元素,也无需对整个画布数据进行差异化处理。简单来说,每当需要改变效果时(比如新增、删除、修改元素),只需将当前的画板数据 push 到历史操作栈上,然后撤销与反撤销重新加载相应的数据即可。

这种方案的优势在于简单直接,无需考虑细节,历史操作栈记录了每一步的变更。然而,需要注意的是,由于是整个画布数据的无差别缓存,内存占用会比较大。

一般维护这种历史栈有两种比较流行的方案

  1. 单一操作栈:
  • 使用一个操作栈来记录每一步的操作
  • 通过下标指定当前的状态,实现撤销与反撤销操作
  • 当进行新的操作时,将该操作推入栈中,并更新当前状态的下标
  1. 双栈维护:
  • 维护两个栈,一个是撤销栈,另一个是重做栈
  • 当用户执行新的操作时,将该操作推入撤销栈,并清空重做栈
  • 撤销操作时,从撤销栈中弹出最新的状态,并将其保存到重做栈
  • 重做操作时,从重做栈中弹出状态,并推入撤销栈

以下是一个单一操作栈的简单示例:

js 复制代码
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 算法上来判断状态的变化,确保数据的准确记录。

以下只是一个简单的示例,与具体项目中的绘制逻辑关联性比较强。

js 复制代码
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])
    • 比较两个对象 leftright 的差异。可选参数 delta 是一个已知的差异,用于提高性能
  • jsondiffpatch.patch(obj, delta)
    • 将给定的差异 delta 应用到对象 obj 上,返回更新后的对象
  • jsondiffpatch.unpatch(obj, delta)
    • 从对象 obj 上移除给定的差异 delta,返回还原前的对象

以下是将 jsondiffpatch 应用到 History 中,我目前画板是采用的这个方案。

ts 复制代码
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 = {}
  }
}

总结

感谢你的阅读。以上就是本文的全部内容,希望这篇文章对你有所帮助,欢迎点赞和收藏。如果有任何问题,欢迎在评论区进行讨论

相关推荐
腾讯TNTWeb前端团队6 小时前
helux v5 发布了,像pinia一样优雅地管理你的react状态吧
前端·javascript·react.js
范文杰9 小时前
AI 时代如何更高效开发前端组件?21st.dev 给了一种答案
前端·ai编程
拉不动的猪9 小时前
刷刷题50(常见的js数据通信与渲染问题)
前端·javascript·面试
拉不动的猪9 小时前
JS多线程Webworks中的几种实战场景演示
前端·javascript·面试
FreeCultureBoy10 小时前
macOS 命令行 原生挂载 webdav 方法
前端
uhakadotcom11 小时前
快速开始使用 n8n
后端·面试·github
uhakadotcom11 小时前
Astro 框架:快速构建内容驱动型网站的利器
前端·javascript·面试
uhakadotcom11 小时前
了解Nest.js和Next.js:如何选择合适的框架
前端·javascript·面试
uhakadotcom11 小时前
React与Next.js:基础知识及应用场景
前端·面试·github
uhakadotcom11 小时前
Remix 框架:性能与易用性的完美结合
前端·javascript·面试