一、引语
undo/redo的操作相信大家都不陌生,例子有很多,比如:编辑器的undo/redo、Word的undo/redo、数据库的undo log/redo log 等等。
本文将用原生JS构造一个undo/redo以至于在前端操作中也能达到撤销重做的效果,还会涉及在vue中如何使用undo/redo以及Immutable能给undo/redo带来怎样的好处。
二、undo/redo简单版本
首先分解undo/redo的功能,我们至少要需要三个东西:
一个操作,最好是有逆操作的,比如删除的逆操作就是插入
能够存放操作方法和逆操作的方法的数据结构
提供操作undo/redo的接口
一个简单的例子:
图中是一个“隐藏元素”的操作,这个操作的逆操作就是“显示元素”。
接下来考虑使用什么数据结构存放操作, 因为不仅有一个操作,还有移动元素的位置、改变元素的颜色等等操作,但最后执行的操作肯定是在最前面被undo的,也就很容易联想到存放操作方法采用的数据结构是一个栈。
class Stack {
constructor () {
this.stack = [];
}
push (item) {
return this.stack.push(item);
}
pop () {
return this.stack.pop();
}
isEmpty () {
return this.stack.length === 0;
}
}
我们还需要定义一个undoable的方法,代表这个操作是可以被undo的。
undoable方法传入主要传入几个参数:执行undo的function,执行redo的function,undo function的参数,redo function的参数,这几个参数保证每次操作的时候把执行过程保留下来(保护现场),并且保存对应逆操作的方法,使得undo按钮的操作只是把undo stack弹出最新的方法并且执行即可,redo按钮的操作同理。
const preview = document.getElementById('preview');
const btn = document.getElementById('btn');
const undoBtn = document.getElementById('undo');
const redoBtn = document.getElementById('redo');
const undoRedo = new UndoRedo();
undoBtn.addEventListener('click', function () {
undoRedo.undo();
})
redoBtn.addEventListener('click', function () {
undoRedo.redo();
})
const hideElCommand = HideElCommand.of(preview)(undoRedo);
btn.addEventListener('click', function () {
hideElCommand.execute().undoable();
});
万事俱备,只差一个UndoRedo的类了,简单的实现如下:
class UndoRedo {
constructor () {
this.undoStack = new Stack();
this.redoStack = new Stack();
}
undoable (options) {
const {
thisArg = null,
undoFn = noop,
redoFn = noop,
undoArgs = [],
redoArgs = [],
} = options;
this.undoStack.push(
{
undoFn: undoFn.bind(thisArg, undoArgs),
redoFn: redoFn.bind(thisArg, redoArgs),
}
);
}
undo () {
if (this.undoStack.isEmpty()) {
return;
}
const obj = this.undoStack.pop();
typeof obj.undoFn === 'function' && obj.undoFn();
this._addRedo(obj);
}
redo () {
if (this.redoStack.isEmpty()) {
return;
}
const obj = this.redoStack.pop();
typeof obj.redoFn === 'function' && obj.redoFn();
this._addUndo(obj);
}
_addRedo (redoItem) {
this.redoStack.push(redoItem);
}
_addUndo (undoItem) {
this.undoStack.push(undoItem);
}
}
三、带命令模式的undo/redo
上面的例子实现了一个简单的undo/redo的操作,但是写起来较麻烦,如果有好几个地方都有用到隐藏元素的操作,代码并不能很好地复用,也不够清晰,更胜一筹的做法是定义一个隐藏元素的命令,按钮的操作只需要知道执行隐藏元素的命令,并且可以定义这个命令是否可撤销的。
首先来看命令模式的作用:既不用知道调用者也不需要知道接收者,但这个命令的过程是通用的,这就很好地解耦了调用者和接收者,同时这个命令过程是可以被复用的。
回到代码中来,给命令定义一个名为undoable的方法,代表这个命令是可以被撤销的,与undoRedo对象相结合,当命令执行的时候,同时在undo/redo栈里存放着命令的过程和命令逆操作的过程。
可以抽象出一个Command父类,提供undoable, execute方法,所有定义的命令都继承于这个类:
class Command {
constructor (receiver, data) {
this.receiver = receiver;
this.data = data;
this.undoRedo = null;
this.undoRedoOptions = {};
}
execute () {
throw Error("please implement execute function!");
}
undo () {
throw Error("please implement undo function!");
}
initUndoRedo (undoRedo, options) {
this.undoRedo = undoRedo;
this.undoRedoOptions = options;
return this;
}
undoable () {
const command = this;
this.undoRedo.undoable({
undoFn: command.undo,
redoFn: command.execute,
thisArg: command,
...this.undoRedoOptions,
});
return command;
}
}
给隐藏元素操作定义一个HideElCommand命令对象, 使用的时候很简单,直接new 一个HideElCommand.execute().undoable()
class HideElCommand extends Command {
constructor (receiver, data) {
super(receiver, data);
}
execute () {
this.receiver.style.display = 'none';
return this;
}
undo () {
const showElCommand = ShowElCommand.of(this.receiver, this.data)(this.undoRedo, this.undoRedoOptions);
showElCommand.execute();
return this;
}
static of (receiver, data) {
return function (undoRedo, options) {
return new HideElCommand(receiver, data).initUndoRedo(undoRedo, options);
}
}
}
const hideElCommand = HideElCommand.of(preview)(undoRedo);
btn.addEventListener('click', function () {
hideElCommand.execute().undoable();
});
ps. 图中把new HideElCommand的过程封装成一个of方法
四、vue中的undo/redo
vue中最重要的思想:数据驱动视图,改变数据即可自动更新视图,所以只需要在UndoRedo栈中存放数据的快照即可。
实现过程也比较简单, 创建一个UndoPlugin, mixin的data中存放undoRedo对象, 同时绑定Vue原型上$undoable, $undo, $redo方法,思路和用原生js写的差不多, 具体代码就不列出来了,使用的方式如下图:
export default {
name: 'undo',
data () {
return {
show: true
}
},
methods: {
hideEl () {
this.$undoable({
undo: { show: this.show },
redo: { show: !this.show }
})
this.show = false
}
},
undo () {
this.$undo()
},
redo () {
this.$redo()
}
}
但是数据的来源也可能是vuex中的state, 那么vuex存放的state怎么做undo?之前谈到一个操作最好有对应的逆操作,这样好实现undo/redo,那不可逆的操作怎么办?
以vuex实现undo/redo来举例,如果外部都是通过mutation来修改vuex的state的,就可以维护一个mutation历史的数组,undo的时候删除最后一个mutation, 重新按顺序执行muation数组的每一项即可。
ps. vuex的subscribe方法可以订阅mutation
五、Immutable
例子中的数据都是值类型的,如果数据是引用类型的,在undo/redo栈里也是会被修改的,我们需要的是一个能够存在栈里的数据快照,是不变的,由于数据是不可变的,并且我们的视图遵循UI = F(data),所以一份数据映射一个视图,改变了数据就相当于是另一份数据,只要我们把上一份数据保存起来,就能很好地回溯到上一份视图,这就涉及一个概念: Immutable(不可变的)。
以Immutable.js为例
function execute () {
let data = Immutable.formJS({ a: { b: 1}})
run(data)
console.log(data.getInt(['a', 'b'])) //能够清晰知道是1
}
但是这也是有弊端的,Immutable.js使用起来对代码的侵入性还是挺大的,思维上也需要转变。
Immutable.js每次都是生成新的对象,性能问题怎么解决?它的底层用的是可持久化的数据结构和structural sharing的技术,简单来说就是不变的数据节点用的都是同一份,变化的才会重新生成节点重新进行组装。
具体可以浏览这篇文章: Immutable.js, persistent data structures and structural sharing
六、总结
一开始我们讲了undo/redo的场景,和实现的三个必要条件:
操作的定义/逆操作的定义
存放操作的数据结构
提供undo/redo接口
并且实现了一个简单版本的undo/redo。
但是这个简单版本的实现不够清晰通用,于是结合了命令模式实现了第二个版本,这个版本调用方只需要知道存在一个叫做隐藏元素命令,并且这个命令是可以被undo的,只需要执行execute方法即可,有效地提高了清晰度和易用性。
最后我们粗略地讲了三点:
vue、vuex的undo/redo,与简单版本最大的不同是栈中存放的是数据
不可逆的操作是如何实现undo/redo的(保存操作的历史)
介绍了Immutable的数据对于undo/redo的实现提供了极大的便利。
使用Immutable的数据确实很容易实现Undo/Redo的功能,但是Immutable对代码的侵入型较强,新项目可以尝试,大多数情况还是推荐带命令模式的undo/redo。
七、参考资料
《JavaScript设计模式与开发实践》,曾探,2015-5
Immutable.js, persistent data structures and structural sharing
Immutable 详解及 React 中实践