简历填到手指抽筋,好不容易填好了一堆信息,结果一不小心点击了重置或者删除。如果我们想还原回去,此时只能全部重新写过,浪费我们宝贵的生命。又比如,当我刷到心动小姐姐视频,刚准备点击关注,却不小心点到了"不感兴趣",结果这一辈子都再也见不到她了...
有没有一种设计,能够像 Word 和 Excel 那样,点击撤回按钮或者Ctrl+Z,直接还原我们上一步的操作呢?
由于某些不可描述的原因,于是我设计了一个能撤回操作的,撤回(undo)按钮。效果堪比哆啦A梦的时光机,错过的她也能再找回
体验一下这个功能:撤销重做 功能演示:
核心设计思路
我们可以利用栈的特性,每次执行操作时,将操作记录推入栈中,点击撤回按钮时,弹出,并执行栈顶的操作。
在上方演示的todolist
功能中,假设我们删除了一个todo
,撤回的时候就应该把这个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(); //执行撤销
}
核心功能就是这么简单,利用了栈和命令模式
的设计:
- 点击撤回按钮,发布
undo
命令 command
对象调用undo
函数undo
函数内部处理对应的逻辑
优化和完善
1.封装Reducer类
将上面提到的 undoCommands
和 undo
函数,我们可以封装到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):
- 删除
todo
的时候,把命令对象存入undoCommands
栈中。 - 点击撤销时,需要把
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);
}
总结
到此为止我们的撤销设计已经基本完成了,不过还有一些地方可以进一步优化和完善
isExecuting
,groupId
可以调整为私有变量- 设置最大的历史记录栈(
undoCommands
)容量 - 点击
undo/redo
时,执行注册的回调(比如判断undo按钮是否需要高亮) - 清空所有的历史记录
聪明的你可以试着设计这些功能,优化我们的撤销/重做系统
更多源码和优化思路可以在我的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);
}