1. 程式人生 > 其它 >【譯文】如何在js中實現一個撤銷/重做系統

【譯文】如何在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