人生虽无后悔药,前端教你来撤销(Undo/Redo)

简历填到手指抽筋,好不容易填好了一堆信息,结果一不小心点击了重置或者删除。如果我们想还原回去,此时只能全部重新写过,浪费我们宝贵的生命。又比如,当我刷到心动小姐姐视频,刚准备点击关注,却不小心点到了"不感兴趣",结果这一辈子都再也见不到她了...

有没有一种设计,能够像 Word 和 Excel 那样,点击撤回按钮或者Ctrl+Z,直接还原我们上一步的操作呢?

由于某些不可描述的原因,于是我设计了一个能撤回操作的,撤回(undo)按钮。效果堪比哆啦A梦的时光机,错过的她也能再找回


体验一下这个功能:撤销重做 功能演示:

核心设计思路

我们可以利用栈的特性,每次执行操作时,将操作记录推入栈中,点击撤回按钮时,弹出,并执行栈顶的操作。

在上方演示的todolist功能中,假设我们删除了一个todo,撤回的时候就应该把这个todo再加回来。

graph TD A[删除todo] --> B[点击undo按钮] --> c[添加todo]

当我们删除todo:

js 复制代码
function deleteTodo(deleteId) {
    let deleteUndoItem; 
    // 删除一条todo
    todoData.forEach((item, index) => {
        if(item.id === deleteId) {
            deleteUndoItem = item;
            todoData.splice(index, 1)
        }
    })
    // 创建历史操作对象
    const command = {
        undo: () => {
            // 添加todo
            todoData.push(deleteUndoItem)
        }
    }
    // 添加历史操作对象到栈中
    addCommand(command);
}

const undoCommands = [];
function addCommand(command) {
    undoCommands.push(command)
}

当我们点击撤回按钮:

js 复制代码
function undo() {
    let lastCommand = undoCommands.pop(); // 取出最后一个命令
    lastCommand.undo(); //执行撤销
}

核心功能就是这么简单,利用了栈和命令模式的设计:

  1. 点击撤回按钮,发布undo命令
  2. command对象调用undo函数
  3. undo函数内部处理对应的逻辑

优化和完善

1.封装Reducer类

将上面提到的 undoCommandsundo 函数,我们可以封装到calss Reducer{}中更好的维护

js 复制代码
class Reducer {
  constructor() {
    this.undoCommands = []; // 存储 undo 操作的命令
  }
  addCommand(command) {
    this.undoCommands.push(command);
  }
  undo() {
    let lastCommand = this.undoCommands.pop(); // 取出最后一个命令
    lastCommand.undo(); //执行撤销
  }
}
// 创建实例
const reducer = new Reducer();

当我们删除todo:

js 复制代码
function deleteTodo(deleteId) {
    // ........
    const command = {
        undo: () => {
            todoData.push(deleteUndoItem)
        }
    }
    reducer.addCommand(command);
}

2.添加恢复(redo)

类似于 Excel,执行了 Ctrl+Z 后,我们还可以Ctrl+Y进行恢复

为此,我们需要维护两个栈(undoCommands和redoCommands):

  1. 删除todo的时候,把命令对象存入undoCommands栈中。
  2. 点击撤销时,需要把undoCommands栈中的数据转移到redoCommands
js 复制代码
class Reducer {
  constructor() {
    this.undoCommands = []; // 存储 undo 操作的命令
    this.redoCommands = []; // 存储 redo 操作的命令
  }
  undo() {
    let lastCommand = this.undoCommands.pop(); // 取出 undo 栈顶的命令
    lastCommand.undo(); //执行撤销
    this.redoCommands.push(lastCommand); // 放入 redo 栈中
  }
  redo() {
    let lastCommand = this.redoCommands.pop(); // 取出 redo 栈顶的命令
    lastCommand.redo(); //执行redo
    this.undoCommands.push(lastCommand); // 放入 uedo 栈中
  }
  addCommand(command) {
    this.undoCommands.push(command);
    // 清空 redo 栈,因为当我们添加了新的操作,我们的Ctrl+Y就失效了
    this.redoCommands = []; 
  }
}

当我们删除todo:

js 复制代码
function deleteTodo(deleteId) {
  // .......
  const command = {
    undo: () => {
      todoData.push(deleteUndoItem);
    },
    redo: () => {
      deleteTodo(deleteId);
    },
  };
  reducer.addCommand(command);
}

3. 判断isExecuting

细心的同学可能发现,当我们上面删除todo。redo: () => { deleteTodo(deleteId); },会导致command命令重复添加。

因此,我们需要判断是否已经在执行撤销或恢复操作,来避免混乱。

js 复制代码
class Reducer {
  constructor() {
    // ......
    this.isExecuting = false; // 标识是否正在执行 undo 或 redo

  }
  addCommand(command) {
    if (this.isExecuting) return ; // 防止在 undo/redo 操作时添加新命令,导致混乱
    // ......
  }
  undo() {
    this.isExecuting = true; // 标记正在执行
    // ......
    this.isExecuting = false; // 结束后重置状态
  }
  redo() {
    this.isExecuting = true; // 标记正在执行
    // ......
    this.isExecuting = false; // 结束后重置状态
  }
  // ......
}

4.设计组合撤销

这个时候可能你的老板会来一句,这几个todo都是我23点59分,同一分钟内创建或者删除,撤销的时候应该把它们一起撤销。额.......

我们可以通过添加一个groupId,判断是不是同一个groupId来判断是否属于同一组。点击撤销按钮,弹出当前栈顶对象后,会继续去判断栈顶的command对象是不是同一个groupId

js 复制代码
class Reducer {
  constructor() {
    ......
    this.groupId = null;
  }
  
  addCommand(command) {
    command.groupId = this.groupId; // 给命令绑定当前 groupId
    ......
  }
  
  undo() {
    let lastCommand = this.undoCommands.pop(); // 取出 undo 栈顶的命令
    lastCommand.undo(); //执行撤销
    this.redoCommands.push(lastCommand); // 放入 redo 栈中
    // 继续向前查找相同 groupId 的命令
    while (
      lastCommand.groupId &&
      this.undoCommands[this.undoCommands.length - 1].groupId ===
        lastCommand.groupId
    ) {
      let newLastCommand = this.undoCommands.pop(); // 取出 undo 栈顶的命令
      newLastCommand.undo(); //执行撤销
      this.redoCommands.push(newLastCommand); // 放入 redo 栈中
    }
  }
  
  // 设置当前 #groupId,便于操作归属分组
  setGroupId() {
    this.groupId = Date.now(); // 用当前时间戳作为唯一的 groupId
  }
  
  // 清除当前的 groupId
  clearGroupId() {
    this.groupId = null; // 清除 groupId
  }
}

当我们删除或添加todo:

js 复制代码
function deleteTodo(deleteId) {
  // ......
  if(特定情况) {
    reducer.setGroupId(); 
  }
  reducer.addCommand(command);
}

总结

到此为止我们的撤销设计已经基本完成了,不过还有一些地方可以进一步优化和完善

  1. isExecutinggroupId可以调整为私有变量
  2. 设置最大的历史记录栈(undoCommands)容量
  3. 点击undo/redo时,执行注册的回调(比如判断undo按钮是否需要高亮)
  4. 清空所有的历史记录

聪明的你可以试着设计这些功能,优化我们的撤销/重做系统

更多源码和优化思路可以在我的Github中找到undo/redo源码

核心代码

js 复制代码
class Reducer {
  constructor() {
    this.undoCommands = []; // 存储 undo 操作的命令
    this.redoCommands = []; // 存储 redo 操作的命令
    this.groupId = null;
    this.isExecuting = false; // 标识是否正在执行 undo 或 redo
  }
  addCommand(command) {
    if (this.isExecuting) return this; // 防止在 undo/redo 操作时添加新命令,导致混乱
    command.groupId = this.groupId; // 给命令绑定当前 groupId
    this.undoCommands.push(command);
    this.redoCommands = []; // 清空 redo 栈
  }
  undo() {
    this.isExecuting = true; // 标记正在执行
    let lastCommand = this.undoCommands.pop(); // 取出 undo 栈顶的命令
    lastCommand.undo(); //执行撤销
    this.redoCommands.push(lastCommand); // 放入 redo 栈中
    // 继续向前查找相同 groupId 的命令
    while (
      lastCommand.groupId &&
      this.undoCommands[this.undoCommands.length - 1].groupId ===
        lastCommand.groupId
    ) {
      let newLastCommand = this.undoCommands.pop(); // 取出 undo 栈顶的命令
      newLastCommand.undo(); //执行撤销
      this.redoCommands.push(newLastCommand); // 放入 redo 栈中
    }
    this.isExecuting = false; // 结束后重置状态
  }
  redo() {
    this.isExecuting = true; // 标记正在执行
    let lastCommand = this.redoCommands.pop(); // 取出 redo 栈顶的命令
    lastCommand.redo(); //执行redo
    this.undoCommands.push(lastCommand); // 放入 uedo 栈中
    // 继续向前查找相同 groupId 的命令
    while (
      lastCommand.groupId &&
      this.undoCommands[this.undoCommands.length - 1].groupId ===
        lastCommand.groupId
    ) {
      let newLastCommand = this.redoCommands.pop(); // 取出 redo 栈顶的命令
      newLastCommand.undo(); //执行撤销
      this.undoCommands.push(newLastCommand); // 放入 redo 栈中
    }
    this.isExecuting = false; // 结束后重置状态
  }
  // 设置当前 #groupId,便于操作归属分组
  setGroupId() {
    this.groupId = Date.now(); // 用当前时间戳作为唯一的 groupId
  }

  // 清除当前的 groupId
  clearGroupId() {
    this.groupId = null; // 清除 groupId
  }
}

const reducer = new Reducer();

function deleteTodo(deleteId) {
  let deleteUndoItem;
  // 删除一条todo
  todoData.forEach((item, index) => {
    if (item.id === deleteId) {
      deleteUndoItem = item;
      todoData.splice(index, 1);
    }
  });
  if (在特定情况) {
    reducer.setGroupId();
  }
  const command = {
    undo: () => {
      // 添加todo
      todoData.push(deleteUndoItem);
    },
    redo: () => {
      // 添加todo
      deleteTodo(deleteId);
    },
  };
  reducer.addCommand(command);
}
相关推荐
星河耀银海1 分钟前
框架结合:Vue+HTML5+AI实现智能前端应用开发
前端·vue.js·html5
等咸鱼的狸猫25 分钟前
# JS 核心六大主题:闭包内存模型、原型链查找、事件流机制与 Promise / Event Loop 实现视角复盘
前端·javascript
袋鱼不重1 小时前
乐观锁与悲观锁:原理、选型与前后端实践
前端·后端
Charonmomo1 小时前
react/antd - 不能正确打开标签页问题记录
前端·react.js·前端框架
gis开发之家2 小时前
vue3直击架构痛点 依赖倒置与接口隔离——打造坚如磐石的插件与高阶组件(HOC)模式
javascript·架构·typescript·前端框架·vue3
柒和远方2 小时前
LeetCode 139. 单词拆分 —— 从暴力回溯到 DP 完全背包
javascript·python·算法
先吃饱再说2 小时前
流式输出完全指南:让 AI 对话像打字机一样流畅
javascript·vue.js
冬枳2 小时前
基于OpenSeagragon封装的前端病理切片展示组件
前端·typescript
爱勇宝3 小时前
《道德经》第6章:别把团队的创造力用到枯竭
前端·后端
无双_Joney3 小时前
[四期 - 4] NestJS专栏 - 征召一下大家的意见,非常想知道大家都想看看Nest的哪些方面
前端·后端·nestjs