Web 应用中的撤销与重做

Web 应用中的撤销与重做

撤销(Undo)与重做(Redo)是绝大部分应用中都有的功能,它给用户提供了后悔药,这样用户即便误操作也能使损失达到最小,或者没有损失,给用户提供更好的使用体验。本文介绍 Web 应用中常用的两种撤销与重做实现思路,并提供一个在线案例。

Web 应用中的撤销与重做,很容易想到富文本编辑器,但本文不特指富文本编辑器,而是更具有普遍意义的 Web 交互应用。只要有用户操作发生的地方,就有可能出错,需要提供挽救的方法。在富文本编辑器里,用户的操作就是编辑文本内容,document.execCommand 能实现最基本的撤销重做,不过受限制也非常多,真正用起来也是捉襟见肘。

在其他类型的交互应用中,用户的操作多种多样,可以是移动一个组件的位置,也可以从小组中删除一个成员等等。面对这些丰富的场景,我们需要抽象出一些实现撤销重做的基本思路。

不管什么类型的交互应用,终究是用户对某个对象进行操作,每一步操作都会对操作对象产生副作用,使其状态发生改变。如下图中,系统初始化后,操作对象的初始状态是 S1,用户进行 A、B、C 等操作后,现在操作对象的状态是 S4。 如果没有撤销重做功能,这条操作链只会一直往前延伸,我们无法回到以前的状态。

用户操作路径


首先需要对撤销重做功能做出功能定义:


a. 能记录用户的一系列操作过程,并能根据记录恢复到当时的状态;

b. 用户在进行某个操作过后,能通过撤销(Undo)回到操作之前的状态;

撤销

c. 用户在撤销之后,也能通过重做(Redo)恢复前一次撤销的操作;

重做

d. 用户在任意状态下,如果有新的操作(如 S7) ,原来保存在该状态之后的操作记录(如 S5、S6)要废弃掉;

废除分叉路径

e. 锦上添花的功能:可以跳转到已保存的任意一次操作记录。

根据以怎样的形式记录用户的操作过程,以及实际的项目实践,我们总结出两种不同实现思路:命令式和快照式。

命令式

命令式实现的撤销与重做,在历史记录中保存的是操作命令(或者说方法),而且是每次保存两个命令。

针对用户的每一次操作,都写一个正向操作方法和逆操作方法,统一提交到一个命令执行器中。命令执行器会保存这样一对方法,并立即执行正向操作方法。当需要撤销这步操作时,执行对应的逆操作方法,回到当次操作之前的状态;重做时,再执行正向操作方法。

命令式

命令执行器不关心当前应用的数据状态,只关注执行了什么命令。在实现时,需要有两个数组,一个用来保存可撤销的历史,一个用来保存可重做的历史。

// Command Manager
class CommandManager {
  constructor() {
    this.undoStack = []
    this.redoStach = []
  }
}

初始状态下,两个数组都是空的。接下来需要实现 execute 方法来执行并记录用户操作。

type Record = {
  do: Function,
  undo: Function
}
// Command Manager
class CommandManager {
  //
  execute(payload: Record) {
    this.undoStack.push(payload)
    payload.do()

    this.redoStack = []
  }
  //
}

需要注意的是,execute 方法是在有新操作时执行,这时候要把 redoStack 数组清空,因为要确保历史记录不分叉。然后是 undo 和 redo 方法。

class CommandManager {
  undo() {
    const record = this.undoStack.pop()
    record.undo()
    this.redoStack.push(cmd)
  }

  redo() {
    const record = this.redoStack.pop()
    record.do()
    this.undoStack.push(record)
  }
}

如下图所示,如果当前应用状态是 S3,undoStack 中保存左侧的命令对,redoStack 保存右侧而对命令对。

undoStack 和 redoStack

以上逻辑完成后,就可以调用了。设想我们业务有一个操作是往小组中添加一个成员,代码如下:

// User Mutation

const commandManager = new CommandManager()
const member = 'z'
const group = ['a', 'b', 'c']
const addAction = {
  // 本次动作的正向操作方法
  do: () => {
    group.push(member)
  },
  // 逆操作方法
  undo: () => {
    const index = group.indexOf(member)
    if (index !== -1) {
      group.splice(index, 1)
    }
  }
}

// 提交给命令执行器去执行
commandManager.execute(addAction)
// group: ['a', 'b', 'c', 'z']

commandManager.undo()
// group: ['a', 'b', 'c']

commandManager.redo()
// group: ['a', 'b', 'c', 'z']

以上只是将主要逻辑展示出来,在实际使用时,还要考虑到操作栈的大小限制。

由于命令式实现的操作记录管理方法只需要在每一次操作发生时接收两个命令,不关注命令内部实现细节,也不关心应用的数据状态,所以可以很好得抽出封装成第三方库。GitHub 上大多数实现也是这种命令式。

不过,这种做法还是有一些问题的:

一、在应用开发过程中,必须持续关注撤销重做功能;因为每一个操作都要写逆操作方法。当你对数据有不同的变更方式时,随即也要写出相应的逆操作方法;

二、有些操作的逆操作可能写起来会比正向操作复杂而且容易出错;

三、撤销、重做必须在相邻的操作记录中进行。你如果要从 S4 状态撤销到 S1 状态,中间必须经历 S3、S2 状态。

快照式

快照式实现的撤销与重做,在历史记录中保存的是应用数据的快照。

在用户每一步操作之后,都对应用数据中需要保存的部分保存到历史记录里(使用深拷贝,或者不可变数据)。在撤销或者重做时,直接取出相应的快照,恢复到应用中。按照这种思路,只要取出相应的数据快照,可以恢复到任意一次状态。

在实现时,可以用一个数组来保存所有的数据快照作为历史记录。额外的还要提供一个索引变量,来指示应用当前位于历史记录中的位置。

class History {
  constructor(){
    this.snapshots = []
    this.cursor = -1
  }
}

如下图:

快照列表

提供一个 record 方法,当用户有操作时,会对应用数据造成变更,此时调用 record 方法记录当前状态。注意记录的位置:在这次操作之前应用的状态保存在数组的 cursor 索引处,有新的操作时,cursor 索引处后面的历史记录都应当清空。然后快照应该保存在索引 cursor 后面(由于清空了 cursor 后面的数据,所以直接 push 即可)。

class History {
  // ...
  record(snapshot) {
    while (this.cursor < this.snapshots.length - 1) {
      this.snapshots.pop()
    }
    this.snapshots.push(snapshot)
  }
  // ...
}
废除分叉路径

最后是实现关键的 redo 和 undo 方法。既然 cursor 表示当前状态的快照索引,那么撤销、重做就是取出前后相邻的快照,将其返回,交给应用自身去根据快照恢复应用状态。额外地,可以指定要取出快照的索引在历史记录中自由穿梭,实现时光机功能。

class History {
  undo() {
    this.cursor -= 1
    return this.snapshots[this.cursor]
  }

  redo() {
    this.cursor += 1
    return this.snapshots[this.cursor]
  }

  travel(cursor) {
    this.cursor = cursor
    return this.snapshots[this.cursor]
  }
}

然后使用的时候就需要应用自己去控制:如何生成快照,如何根据快照恢复应用状态。在文末,提供了一个在线的示例来演示根据快照式思路实现的撤销重做。

以上的代码同样只是主流程,真正实现使用还需要注意以下几点:

  1. cursor 索引的边界控制;
  2. 快照列表的大小控制;
  3. 记录快照之前最好跟前一个快照比对(深度比较),不然在操作记录中可能会出现连续的几个快照,恢复出来都是一模一样的应用状态。

这里暴露快照式实现的一个致命缺陷:内存占用问题。每条历史记录都是应用数据的深拷贝,如果应用需要记录的数据比较庞大,或者操作记录保存数量过大,都容易占用大量内存。可以使用一些库来计算每个快照大小,如 object-sizeof。 所以使用这种方式千万注意历史记录的条数控制。

在 Vue 应用中,如果接入 Vuex,可以使用 Vuex Plugin 通过监听 Mutations 在应用数据有变更时记录快照。不过正如前文所说,要格外关注快照尺寸;Vuex 官网也指出:Plugins that take state snapshots should be used only during development.

如果没有接入 Vuex,也可以 watch 需要保存的数据,在变更时 record 数据的快照。这种情况要注意节流和快照的差异比较,避免在快照列表中存入连续相同的内容。

点击访问在线案例ioslh.github.io

编辑于 2018-09-17

文章被以下专栏收录

    只看代码的话,上 https://github.com/ElemeFe 。这一群人,关心的不是「如何写前端」而是「如何很好地运行一个 ( web ) APP」;这一群人,会在监控屏上加上弹幕,会让实习生自主招聘,会设计、编写、监控整个 APP 的生命周期;这一群人,玩的时候... 更卖力,就像从来没来过那般卖力,卖力地热爱生活。所以这些创作大多基于 ❤️