Edit This Page

實作 Undo 歷史

建構 Undo 和 Redo 的功能到應用程式裡面以往需要開發者特意的努力。使用傳統的 MVC 框架時這不是一個簡單的問題,因為你需要藉由複製所有相關的 models 來持續追蹤每一個過去的 state。此外,你需要留心在 undo 的堆疊上,因為使用者造成的變動必須是可以被 undo 的。

這代表在一個 MVC 應用程式中實作 Undo 和 Redo 時常迫使你必須改寫你的應用程式的一部份以使用像是 Command 之類特定的資料變動模式。

但是用 Redux 的話,實作 undo 歷史是一件輕而易舉的事。這有三個理由:

  • 沒有許多個 model—只是一個你想要持續追蹤的 state subtree。
  • state 已經是 immutable 的,而變動已經被描述為獨立的 action,它很接近 undo 堆疊的心智模型。
  • reducer 的 (state, action) => state signature 讓它可以很自然的實作一般的「reducer enhancer」或「higher order reducer」。它們是接收你的 reducer 並用一些額外的功能來增強它且維持一樣 signature 的 function。Undo 歷史正是一個這樣的範例。

在開始之前,請確保你已經完成了基礎教學並對 reducer composition 有很好的了解。這份 recipe 將會基於描述在基礎教學中的範例上去建構。

在這份 recipe 的第一部份,我們將會解釋使 Undo 和 Redo 可以用一般通用的方式來實作的背後概念。

在這份 recipe 的第二部份,我們將會展示如何使用 Redux Undo 套件來提供這個功能讓它立即可用。

demo of todos-with-undo

了解 Undo 歷史

設計 State 形狀

Undo 歷史也是你應用程式的一部份 state,我們沒有理由用不同的方式處理它。無論隨著時間改變的 state 是什麼類型,當你在實做 Undo 和 Redo 時,你會想要在不同的時間點持續追蹤這個 state 的歷史

例如,一個 counter 的 state 形狀看起來可能像這樣:

{
  counter: 10
}

如果我們想要在這樣一個應用程式中實作 Undo 和 Redo,我們會需要儲存更多的 state,這樣我們才能回答下列問題:

  • 有什麼東西可以 undo 或 redo?
  • 現在的 state 是什麼?
  • 在 undo 的堆疊中,過去 (以及未來) 的 states 是什麼?

合理建議,我們應該改變 state 的形狀來回答這些問題:

{
  counter: {
    past: [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 ],
    present: 10,
    future: []
  }
}

現在,如果使用者按下「Undo」,我們想要它變更往 past 移動:

{
  counter: {
    past: [ 0, 1, 2, 3, 4, 5, 6, 7, 8 ],
    present: 9,
    future: [ 10 ]
  }
}

而進一步還可以:

{
  counter: {
    past: [ 0, 1, 2, 3, 4, 5, 6, 7 ],
    present: 8,
    future: [ 9, 10 ]
  }
}

當使用者按下「Redo」,我們想要它移動一步回到 future:

{
  counter: {
    past: [ 0, 1, 2, 3, 4, 5, 6, 7, 8 ],
    present: 9,
    future: [ 10 ]
  }
}

最後,如果使用者執行一個 action (舉例來說,對 counter 遞減),而剛好我們在 undo 堆疊的中間時,我們必須捨棄掉已存在的 future:

{
  counter: {
    past: [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 ],
    present: 8,
    future: []
  }
}

這裡有趣的是我們想要保存數字、字串、陣列、或是物件的 undo 堆疊並不重要。它們的結構總是會一樣:

{
  counter: {
    past: [ 0, 1, 2 ],
    present: 3,
    future: [ 4 ]
  }
}
{
  todos: {
    past: [
      [],
      [ { text: 'Use Redux' } ],
      [ { text: 'Use Redux', complete: true } ]
    ],
    present: [ { text: 'Use Redux', complete: true }, { text: 'Implement Undo' } ],
    future: [
      [ { text: 'Use Redux', complete: true }, { text: 'Implement Undo', complete: true } ]
    ]
  }
}

一般來說,它看起來像這樣:

{
  past: Array<T>,
  present: T,
  future: Array<T>
}

我們也可以自行決定是否要保存單一一個頂層的歷史:

{
  past: [
    { counterA: 1, counterB: 1 },
    { counterA: 1, counterB: 0 },
    { counterA: 0, counterB: 0 }
  ],
  present: { counterA: 2, counterB: 1 },
  future: []
}

或是保存許多不同部分的歷史讓使用者可以獨立的在它們上使用 undo 或 redo action:

{
  counterA: {
    past: [ 1, 0 ],
    present: 2,
    future: []
  },
  counterB: {
    past: [ 0 ],
    present: 1,
    future: []
  }
}

我們將會在之後看到我們採取的方式如何讓我們選擇 Undo 和 Redo 會影響多大的部分。

設計演算法

不論具體的資料型別,undo 歷史的 state 形狀都一樣:

{
  past: Array<T>,
  present: T,
  future: Array<T>
}

讓我們來探討演算法以操作在上面描述過的 state 形狀。我們可以定義兩個 action 來在這個 state 上操作:UNDOREDO。在我們的 reducer 中,我們將會做以下的步驟來處理這些 action:

處理 Undo

  • past 移除最後一個元素。
  • present 設成我們在前一個步驟移除的元素。
  • future 開頭插入原本的 present state。

處理 Redo

  • future 移除第一個元素。
  • present 設成我們在前一個步驟移除的元素。
  • past 尾端插入原本的 present state。

處理其他 Actions

  • present 插入在 past 尾端。
  • present 設成處理完 action 後的新 state。
  • 清除 future

第一個嘗試:寫一個 Reducer

const initialState = {
  past: [],
  present: null, // (?) 我們要如何初始化 present?
  future: []
}

function undoable(state = initialState, action) {
  const { past, present, future } = state

  switch (action.type) {
    case 'UNDO':
      const previous = past[past.length - 1]
      const newPast = past.slice(0, past.length - 1)
      return {
        past: newPast,
        present: previous,
        future: [ present, ...future ]
      }
    case 'REDO':
      const next = future[0]
      const newFuture = future.slice(1)
      return {
        past: [ ...past, present ],
        present: next,
        future: newFuture
      }
    default:
      // (?) 我們要如何處理其他 action?
      return state
  }
}

這份實作並不能使用,因為它忽略了三個重要的問題:

  • 我們要從哪裡拿到初始的 present state?我們似乎無法事先知道它。
  • 我們要在哪裡對外部的 action 做出反應來把 present 存到 past
  • 我們要如何實際的把對 present state 的控制委託給一個自定的 reducer?

似乎 reducer 不是正確的抽象,不過我們非常接近了。

認識 Reducer Enhancer

你可能已經熟悉了 higher order functions。如果你使用 React,你可能對 higher order component 也很熟悉。這是同一種模式的變化,適用於 reducer。

reducer enhancer (或稱作 higher order reducer) 是一個接收 reducer,並回傳一個新的 reducer 的 function,新的 reducer 可以處理新的 actions、或保存更多的 state,並把它不了解的 actions 委託給裡面的 reducer 來控制。這不是一個新模式—技術上來說,combineReducers() 也是一個 reducer enhancer,因為它接受數個 reducer 並回傳一個新的 reducer。

一個什麼事都不做的 reducer enhancer 看起來像這樣:

function doNothingWith(reducer) {
  return function (state, action) {
    // 只是呼叫傳遞進去的 reducer
    return reducer(state, action)
  }
}

一個用來合併其他 reducer 的 reducer enhancer 看起來可能像這樣:

function combineReducers(reducers) {
  return function (state = {}, action) {
    return Object.keys(reducers).reduce((nextState, key) => {
      // 用每個 reducer 管理的一部份 state 來呼叫它們
      nextState[key] = reducers[key](state[key], action)
      return nextState
    }, {})
  }
}

第二個嘗試:寫一個 Reducer Enhancer

現在我們對 reducer enhancer 有比較好的了解了,我們可以看到 undoable 確實應該要是個 reducer enhancer:

function undoable(reducer) {
  // 用空的 action 呼叫這個 reducer 來放入初始的 state
  const initialState = {
    past: [],
    present: reducer(undefined, {}),
    future: []
  }

  // 回傳一個處理 undo 跟 redo 的 reducer
  return function (state = initialState, action) {
    const { past, present, future } = state

    switch (action.type) {
      case 'UNDO':
        const previous = past[past.length - 1]
        const newPast = past.slice(0, past.length - 1)
        return {
          past: newPast,
          present: previous,
          future: [ present, ...future ]
        }
      case 'REDO':
        const next = future[0]
        const newFuture = future.slice(1)
        return {
          past: [ ...past, present ],
          present: next,
          future: newFuture
        }
      default:
        // 委託傳進來的 reducer 來處理 action
        const newPresent = reducer(present, action)
        if (present === newPresent) {
          return state
        }
        return {
          past: [ ...past, present ],
          present: newPresent,
          future: []
        }
    }
  }
}

現在我們可以把任何的 reducer 包進 undoable reducer enhancer 來教它對 UNDOREDO action 做出反應。

// 這是一個 reducer
function todos(state = [], action) {
  /* ... */
}

// 這也是一個 reducer!
const undoableTodos = undoable(todos)

import { createStore } from 'redux'
const store = createStore(undoableTodos)

store.dispatch({
  type: 'ADD_TODO',
  text: 'Use Redux'
})

store.dispatch({
  type: 'ADD_TODO',
  text: 'Implement Undo'
})

store.dispatch({
  type: 'UNDO'
})

有一個需要注意的地方:在你要取用他的時候,你必須記得添加 .present 到當下的 state。你也可以分別檢查 .past.length.future.length 來決定是否要啟用或禁用 Undo 和 Redo 按鈕。

你可能已經聽過 Redux 受到 Elm 架構的影響。所以這個範例與 elm-undo-redo 套件非常相似並不令人感到訝異。

使用 Redux Undo

前面提供了許多有用的資訊,但是我們不能只是下載一個 library 使用它而不要自己實作 undoable 嗎?我們當然可以!認識 Redux Undo,一個提供簡單的 Undo 和 Redo 功能給你的 Redux tree 任何部分的 library。

在這部分的 recipe 中,你將會學到如何讓 Todo List 範例 變成是 undoable 的。你可以在 Redux 附帶的 todos-with-undo 範例 中找到這份 recipe 的完整原始碼。

安裝

首先,你需要執行

npm install --save redux-undo

這會安裝提供了 undoable reducer enhancer 的套件。

包裝 Reducer

你需要把你想要增強的 reducer 用 undoable function 包起來。例如,如果你從一個專用檔案中 export 一個 todos reducer,你得改為 export 呼叫 undoable() 與你寫的 reducer 後的結果:

reducers/todos.js

import undoable, { distinctState } from 'redux-undo'

/* ... */

const todos = (state = [], action) => {
  /* ... */
}

const undoableTodos = undoable(todos, {
  filter: distinctState()
})

export default undoableTodos

distinctState() filter 用來忽略沒有導致 state 改變的 action。有許多其他的選項可以用來設定你的 undoable reducer,例如設定 Undo 和 Redo action 的 action type。

值得一提的是,你的 combineReducers() 呼叫將會依然維持它原本的運作,但 todos reducer 現在已參考至 Redux Undo 加強過後的 reducer:

reducers/index.js

import { combineReducers } from 'redux'
import todos from './todos'
import visibilityFilter from './visibilityFilter'

const todoApp = combineReducers({
  todos,
  visibilityFilter
})

export default todoApp

你可以在任何的 reducer composition 層級把一個或更多個 reducer 包進 undoable 中。我們選擇包裝 todos 而不是頂層 combined reducer,這樣的話對 visibilityFilter 的變更不會進到 undo 歷史中。

更新 Selectors

現在 todos 那部份的 state 看起來像這樣:

{
  visibilityFilter: 'SHOW_ALL',
  todos: {
    past: [
      [],
      [ { text: 'Use Redux' } ],
      [ { text: 'Use Redux', complete: true } ]
    ],
    present: [ { text: 'Use Redux', complete: true }, { text: 'Implement Undo' } ],
    future: [
      [ { text: 'Use Redux', complete: true }, { text: 'Implement Undo', complete: true } ]
    ]
  }
}

這意味著你需要用 state.todos.present 來存取你的 state 而不是 state.todos

containers/VisibleTodoList.js

const mapStateToProps = (state) => {
  return {
    todos: getVisibleTodos(state.todos.present, state.visibilityFilter)
  }
}

添加按鈕

現在你只需要添加按鈕給 Undo 和 Redo action。

首先,為這些按鈕建立一個名為 UndoRedo 的新 container component。因為 presentational 的部份很小,所以我們將不拆分它到別的檔案:

containers/UndoRedo.js

import React from 'react'

/* ... */

let UndoRedo = ({ canUndo, canRedo, onUndo, onRedo }) => (
  <p>
    <button onClick={onUndo} disabled={!canUndo}>
      Undo
    </button>
    <button onClick={onRedo} disabled={!canRedo}>
      Redo
    </button>
  </p>
)

你將從 React Redux 使用 connect() 來建立一個 container component。你可以檢查 state.todos.past.lengthstate.todos.future.length 來決定是否打開 Undo 和 Redo 按鈕。你將不需要為了執行 undo 和 redo 寫 action creator 因為 Redux Undo 已有提供:

containers/UndoRedo.js

/* ... */

import { ActionCreators as UndoActionCreators } from 'redux-undo'
import { connect } from 'react-redux'

/* ... */

const mapStateToProps = (state) => {
  return {
    canUndo: state.todos.past.length > 0,
    canRedo: state.todos.future.length > 0
  }
}

const mapDispatchToProps = (dispatch) => {
  return {
    onUndo: () => dispatch(UndoActionCreators.undo()),
    onRedo: () => dispatch(UndoActionCreators.redo())
  }
}

UndoRedo = connect(
  mapStateToProps,
  mapDispatchToProps
)(UndoRedo)

export default UndoRedo

現在你可以在 App component 加入 UndoRedo component:

components/App.js

import React from 'react'
import Footer from './Footer'
import AddTodo from '../containers/AddTodo'
import VisibleTodoList from '../containers/VisibleTodoList'
import UndoRedo from '../containers/UndoRedo'

const App = () => (
  <div>
    <AddTodo />
    <VisibleTodoList />
    <Footer />
    <UndoRedo />
  </div>
)

export default App

就這樣了!在範例資料夾中執行 npm installnpm start 並嘗試一下!