【译文】如何在js中实现一个撤销/重做系统

当你在设计专注于数据创建或者修改的应用(比如文本或图像编辑器)时,终端用户的一个共同需求就是能够撤销或重做他们的一些操作。这是一个很重要的考虑因素,因为知道操作步骤可以安全、轻松的撤销,可以让用户增加对你们应用的信心。

因此,你已经决定尝试讲一个撤销系统集成到你们的工程中去,但是再此之前你从未编写过类似的功能。它们是如何工作的?甚至是如何开始的?这篇文章旨在通过向你介绍撤销系统是如何工作和如何去实现一个撤销系统,为你提供一个方向。

一个可撤销的计数器

让我们从一个简单(且非常常见)的例子开始:一个计数器

const createCounter = () => {
  return {
    value: 0,
  
    increment() {
      this.value += 1;
    },
  
    decrement() {
      this.value -= 1;
    }
  }
}

有点小激动,是不是?这段代码定义了一个通过调用 incrementdecrement方法来实现递增和递减的计数器生成函数。是的,没有啥可说的。并且它的行为同样是可预测的:

const counter = createCounter();
console.log(counter.value); // 0

counter.increment();
console.log(counter.value); // 1

counter.decrement();
console.log(counter.value); // 0

虽然没啥可多说的,但是这段代码提供了一个开始探索撤销系统的极佳场所。很容易理解它在做什么,它的状态数据可能会发生变化。让我们看看我们是否可以为其添加一个撤销功能。

我们可实现撤销操作的最简单的方法就是在计数器 value 变化的时候追踪它,将它添加到一个 "history" 数组中去:我们可以重新访问之前的状态。 我们还将在"pisition"变量中记录一个指向数组特定索引(表示当前计数器的value值)的指针。有了“history”和“指针”数组,我们不在需要维持一个value 变量,我们将用一个返回在当前'positin'位置返回"history"的函数来代替:

实现如下:

const createUndoableCounter = () => {
  let history = [0];
  let position = 0;

  return {
    value() {
      return history[position];
    },

    // rest of implementation here...

  }
}

有了这个,我们需要做的就是在状态改变时启用撤销和重做,就是增加或者减小 “history” 的位置。知道这一点,我们可以继续实现。

undo() {
  if (position > 0) {
    position -= 1;
  }
},

redo() {
  if (position < history.length - 1) {
    position += 1;
  }
},

是不是很简单?剩下的就是修改 incrementdecrement 方法,将一个新的 ‘value’添加到 history 数组中并且相应的更新 position 变量。我们将添加一个新方法 setValue 来帮助我们:

setValue(value) {
  // if position is not last in history array, clear all future states
  if (position < history.length - 1) {
    history = history.slice(0, position + 1);
  }

  history.push(value);
  position += 1;
},

increment() {
  this.setValue(this.value() + 1);
},

decrement() {
  this.setValue(this.value() - 1);
},

setValue 函数接收一个新的值,删除 'history' 中当前‘position’ 之前的状态(当一系列撤销操作之后进行更改应该清空所有现有的‘未来’状态),将新值添加到‘history’中,并将‘postion’指向新的状态。我们现在使用increment decrement 方法和新的value方法,来实现之前的功能。

我们新的可撤销计数器实现如下:

const createUndoableCounter = () => {
  let history = [0];
  let position = 0;

  return {

    value() {
      return history[position];
    },

    setValue(value) {
      if (position < history.length - 1) {
        history = history.slice(0, position + 1);
      }
      history.push(value);
      position += 1;
    },

    increment() {
      this.setValue(this.value() + 1);
    },

    decrement() {
      this.setValue(this.value() - 1);
    },

    undo() {
      if (position > 0) {
        position -= 1;
      }
    },

    redo() {
      if (position < history.length - 1) {
        position += 1;
      }
    },

    // toString function to aid in illustrating
    toString() {
      return `Value: ${this.value()}, History: [${history}], Position: ${position}`; 
    }
  }
}

我们可以用一些简单的驱动程序来测试我们的代码:

const undoableCounter = createUndoableCounter();
console.log(undoableCounter.toString()); // => Value: 0, History: [0], Position: 0

undoableCounter.increment();
console.log(undoableCounter.toString()); // => Value: 1, History: [0,1], Position: 1

undoableCounter.decrement();
console.log(undoableCounter.toString()); // => Value: 0, History: [0,1,0], Position: 2

undoableCounter.undo();
console.log(undoableCounter.toString()); // => Value: 1, History: [0,1,0], Position: 1

undoableCounter.increment();
console.log(undoableCounter.toString()); // => Value: 2, History: [0,1,2], Position: 2

更稳健的方法

前面的方法是对历史进行遍历的一个很好的介绍,并且对于单个变量简单的变化的追踪非常有效。然而,尝试追踪跟多复杂的数据包漏出了一些缺点。如果你想通过这种方式去撤销对对象所做的更改?你会深拷贝整个对象到‘history’中保存吗?如果对象中包含大量的数据,对其进行许多很小的改变的话,会很快消耗完你的可用内存!

为了防止这种情况出现,我们将转向一种常见的编程设计模式:命令模式。深入解释这种模式超出了这篇文章的范围,但是你可以将其视为对数据执行你你希望执行的操作,并将他们转换为可以在任意时间传递、存储和执行的对象的控制器。

将动作抽象为命令对象允许我们存储所有我们对象修改的历史,而不是所有对象状态的历史。

让我们带着这个概念,通过上一个示例来熟悉下:

const createNamedCounter = (name) => {
  return {
    name,
    count: 0
  }
}

所以在这种情况下,我们还有另一个计数器。然而,这一次,它的状态是一个有多个属性的对象:name count。它还没有内置任何的递增和递减方法。我们将编写命令来负责这块。

在撤销/重做系统中,一个命令应该是一个至少包含2个方法的对象:executeundo。也就是说,execute在数据上执行一个动作,undo恢复到以前的状态。

让我们看看 increment 当做命令时会怎么来实现:

const createIncrementCommand = (counter) => {
  const previousCount = counter.count;

  return {
    execute() {
      counter.count += 1;
    },
    undo() {
      counter.count = previousCount;
    }
  }
}

当一个新的递增命令被添加时,它接收正在修改的计数器对象作为参数,并用一个叫做 previousCount 的变量存储下当前的计数器值。它的 execute 方法通过+1来增加计数器的值,它的undo方法将其恢复到存储在previousCount中状态。

鉴于的计数器递增函数非常简单,undo方法可以实现为更简单的递减它。但是,存储任何改变之前的值是一个更灵活的方案:可以应用于更复杂的操作,所以我选择在这里演示该技术。

递减本质上是相同的,但是在其execute 方法中使用减法而不是加法。

const createDecrementCommand = (counter) => {
  const previousCount = counter.count;

  return {
    execute() {
      counter.count -= 1;
    },
    undo() {
      counter.count = previousCount;
    }
  }
}

但是这只是故事的一半。如果我们正在制定命令,我们还必须有方法去存储它,应用它,并且去管理一个命令历史,以启用一系列撤销和重做。让我们定义一个命令管理器来我们处理这个问题。

const INCREMENT = "INCREMENT"
const DECREMENT = "DECREMENT"

const commands = {
  [INCREMENT]: createIncrementCommand,
  [DECREMENT]: createDecrementCommand
}

const createCommandManager = (target) => {
  let history = [null];
  let position = 0;

  return {
    doCommand(commandType) {
      if (position < history.length - 1) {
        history = history.slice(0, position + 1);
      }

      if (commands[commandType]) {
        const concreteCommand = commands[commandType](target);
        history.push(concreteCommand);
        position += 1;

        concreteCommand.execute();
      }
    },

    undo() {
      if (position > 0) {
        history[position].undo();
        position -= 1;
      }
    },

    redo() {
      if (position < history.length - 1) {
        position += 1;
        history[position].execute();
      }
    }
  }
}

我靠!这又是一些新东西!不过,其中一些应该很熟悉。管理 history position,和在 undo redo中遍历 ‘history’,和我们上个例子几乎相同。所以我们将重点关注这里的新东西。

INCREMENT, DECREMENT commands 都是用于在简化选择特定命令过程产生的常量。字符串常量可以防止输入错误,commands 对象的存在是取代switch语句,允许你通过相关联的字符串去访问特定的命令生成器方法。

createCommandManager 在创建时将对象作为参数用做调用它的命令的目标(对于这个编程模式不是必须的,但是它和剩余的实现非常契合)。它返回一个用于3个方法的的对象:doCommand undo redo

doCommand 是魔法发生的地方。首先,它清除撤销留下的任何未来命令,将像上个例子中setValue一样。然后他会检查它传递的命令字符串是否存在于'commands'对象中,如果存在,它会从中创建一个新的命令对象,目标是target。它将新命令添加到history数组中,然后执行它,从而将其更改应用于target

像我们之前提到的,undo redo和之前的例子都是类似的,但是现在我们正在处理的的命令的历史记录,undo执行undo命令(redo执行execute)去更新对象的状态。

现在我们已经把所有东西拼凑起来了,让我们弄一些测试代码来看效果:

const quinnCounter = createNamedCounter('Quinn');
console.log(quinnCounter); // => { name: 'Quinn', count: 0 }

const quinnCountManager = createCommandManager(quinnCounter);

quinnCountManager.doCommand(INCREMENT);
console.log(quinnCounter); // => { name: 'Quinn', count: 1 }

quinnCountManager.doCommand(INCREMENT);
console.log(quinnCounter); // => { name: 'Quinn', count: 2 }

quinnCountManager.doCommand(DECREMENT);
console.log(quinnCounter); // => { name: 'Quinn', count: 1 }

quinnCountManager.undo();
console.log(quinnCounter); // => { name: 'Quinn', count: 2 }

quinnCountManager.redo();
console.log(quinnCounter); // => { name: 'Quinn', count: 1 }

很棒!和预期一样!我们能递增和递减我们的计数器,同时通过命令管理器撤销变化,无需接触或不用必须追踪变量名称。

如果你一直跟着这篇文章在学习,请随意拍拍你的后背,因为你已经学会了时间旅行!至少在您的应用的上下文中:)

如果你想学习更多关于命令模式和撤销系统,请查看下我在撰写这篇文章参考的文章:

【原文】

Intro to Writing Undo/Redo Systems in JavaScript

posted @ 2021-08-22 11:41  韩帅  阅读(2331)  评论(0编辑  收藏  举报