人生虽无后悔药,前端教你来撤销(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);
}
相关推荐
避坑记录8 分钟前
Cesium@1.126.0,创建3D瓦片,修改样式
前端·javascript·3d
思茂信息9 分钟前
CST直角反射器 --- A求解器, 远场源, 距离像, 逆ChirpZ变换(ICZT)
开发语言·javascript·人工智能·算法·ai·软件工程·软件构建
二川bro13 分钟前
面试题——简述Vue 3的服务器端渲染(SSR)是如何工作的?
前端·面试
zhangxingchao44 分钟前
关于Android 构建流程解析的一些问题
前端
zheshiyangyang1 小时前
Vue+ElementPlus的一些问题修复汇总
前端·javascript·vue.js
怣疯knight1 小时前
CryptoJS库中WordArray对象支持哪些输出格式?除了toString() 方法还有什么方法可以输出吗?WordArray对象的作用是什么?
前端·javascript
患得患失9492 小时前
【前端】【面试】【树】JavaScript 树形结构与列表结构的灵活转换:`listToTree` 与 `treeToList` 函数详解
开发语言·前端·javascript·tree·listtotree·treetolist
i建模2 小时前
Windows前端开发IDE选型全攻略
前端·ide·windows·node.js·编辑器·visual studio code
hamburgerDaddy12 小时前
从零开始用react + tailwindcs + express + mongodb实现一个聊天程序(三) 实现注册 登录接口
前端·javascript·mongodb·react.js·前端框架·express
用户51017613438683 小时前
Node.js接入DeepSeek实现流式对话
前端·后端