评论

前端页面上实现undo-redo功能

本文将用原生JS构造一个undo/redo以至于在前端操作中也能达到撤销重做的效果,还会涉及在vue中如何使用undo/redo以及Immutable能给undo/redo带来怎样的好处。

一、引语

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 中实践

点赞 13
收藏
评论
登录 后发表内容