Redux 實現(xiàn)撤銷重做

2021-09-16 10:43 更新

實現(xiàn)撤銷歷史

在應(yīng)用中內(nèi)建撤消和重做功能往往需要開發(fā)者有意識的做出一些努力。對于經(jīng)典的 MVC 框架來說這不是一個簡單的問題,因為你需要通過克隆所有相關(guān)的 model 來追蹤每一個歷史狀態(tài)。此外,你需要關(guān)心整個撤消堆棧,因為用戶初始化的更改也應(yīng)該是可撤消。

這意味著在一個 MVC 應(yīng)用中實現(xiàn)撤消和重做,通常迫使你用一些類似于 Command 的特殊的數(shù)據(jù)修改模式來重寫應(yīng)用中的部分代碼。

而在 Redux 中,實現(xiàn)撤銷歷史卻是輕而易舉的。有以下三個原因:

  • 你想要跟蹤的 state 子樹不會包含多個模型(models—just)。
  • state 是不可變的,所有修改已經(jīng)被描述成分離的 action,而這些 action 與預(yù)期的撤銷堆棧模型很接近了。
  • reducer 的簽名 (state, action) => state 讓它可以自然的實現(xiàn) “reducer enhancers” 或者 “higher order reducers”。它們可以讓你在為 reducer 添加額外的功能時保持這個簽名。撤消歷史就是一個典型的應(yīng)用場景。

在動手之前,確認(rèn)你已經(jīng)閱讀過基礎(chǔ)教程并且良好掌握了 reducer 合成。本文中的代碼會構(gòu)建于 基礎(chǔ)教程 的示例之上。

文章的第一部分,我們將會解釋實現(xiàn)撤消和重做功能所用到的基礎(chǔ)概念。

在第二部分中,我們會展示如何使用 Redux Undo 庫來無縫地實現(xiàn)撤消和重做。

demo of todos-with-undo

理解撤消歷史

設(shè)計狀態(tài)結(jié)構(gòu)

撤消歷史也是你的應(yīng)用 state 的一部分,我們沒有任何原因通過不同的方式實現(xiàn)它。無論 state 如何隨著時間不斷變化,當(dāng)你實現(xiàn)撤消和重做這個功能時,你就必須追蹤 state 在不同時刻的歷史記錄。

例如,一個計數(shù)器應(yīng)用的 state 結(jié)構(gòu)看起來可能是這樣:

{
  counter: 10
}

如果我們希望在這樣一個應(yīng)用中實現(xiàn)撤消和重做的話,我們必須保存更多的 state 以解決下面幾個問題:

  • 撤消或重做留下了哪些信息?
  • 當(dāng)前的狀態(tài)是什么?
  • 撤銷堆棧中過去(和未來)的狀態(tài)是什么?

這是一個對于 state 結(jié)構(gòu)的修改建議,可以回答上述問題的:

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

現(xiàn)在,如果我們按下“撤消”,我們希望恢復(fù)到過去的狀態(tài):

{
  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]
  }
}

當(dāng)我們按下“重做”,我們希望往未來的狀態(tài)移動一步:

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

最終,如果處于撤銷堆棧中,用戶發(fā)起了一個操作(例如,減少計數(shù)),我們將會丟棄所有未來的信息:

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

有趣的一點(diǎn)是,我們在撤銷堆棧中保存數(shù)字,字符串,數(shù)組或是對象都沒有關(guān)系。整個結(jié)構(gòu)始終完全一致:

{
  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: []
}

也可以分離的歷史記錄,用戶可以獨(dú)立地執(zhí)行撤消和重做操作:

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

接下來我們將會看到如何選擇合適的撤消和重做的顆粒度。

設(shè)計算法

無論何種特定的數(shù)據(jù)類型,重做歷史記錄的 state 結(jié)構(gòu)始終一致:

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

讓我們討論一下如何通過算法來操作上文所述的 state 結(jié)構(gòu)。我們可以定義兩個 action 來操作該 state:UNDOREDO。在 reducer 中,我們希望以如下步驟處理這兩個 action:

處理 Undo

  • 移除 past 中的最后一個元素。
  • 將上一步移除的元素賦予 present。
  • 將原來的 present 插入到 future最前面。

處理 Redo

  • 移除 future 中的第一個元素。
  • 將上一步移除的元素賦予 present。
  • 將原來的 present 追加到 past最后面。

處理其他 Action

  • 將當(dāng)前的 present 追加到 past最后面
  • 將處理完 action 所產(chǎn)生的新的 state 賦予 present。
  • 清空 future。

第一次嘗試: 編寫 Reducer

const initialState = {
  past: [],
  present: null, // (?) 我們?nèi)绾纬跏蓟?dāng)前狀態(tài)?
  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:
    // (?) 我們?nèi)绾翁幚砥渌?action?
    return state;
  }
}

這個實現(xiàn)是無法使用的,因為它忽略了下面三個重要的問題:

  • 我們從何處獲取初始的 present 狀態(tài)?我們無法預(yù)先知道它。
  • 當(dāng)外部 action 被處理完畢后,我們在哪里完成將 present 保存到 past 的工作?
  • 我們?nèi)绾螌?present 狀態(tài)的控制委托給一個自定義的 reducer?

看起來 reducer 并不是正確的抽象方式,但是我們已經(jīng)非常接近了。

遇見 Reducer Enhancers

你可能已經(jīng)熟悉 higher order function 了。如果你使用過 React,也應(yīng)該熟悉 higher order component。對于 reducer 來說,也有一種對應(yīng)的實現(xiàn)模式。

一個 reducer enhancer(或者一個 higher order reducer)作為一個函數(shù),接收 reducer 作為參數(shù),并返回一個新的 reducer,這個新的 reducer 可以處理新的 action,或者維護(hù)更多的 state,亦或者將它無法處理的 action 委托給原始的 reducer 處理。這不是什么新的模式技術(shù)(pattern—technically),combineReducers()就是一個 reducer enhancer,因為它同樣接收多個 reducer 并返回一個新的 reducer。

這是一個沒有任何額外功能的 reducer enhancer 的示例:

function doNothingWith(reducer) {
  return function (state, action) {
    // 僅僅是調(diào)用被傳入的 reducer
    return reducer(state, action);
  };
}

一個可以組合 reducer 的 reducer enhancer 看起來應(yīng)該像這樣:

function combineReducers(reducers) {
  return function (state = {}, action) {
    return Object.keys(reducers).reduce((nextState, key) => {
      // 調(diào)用每一個 reducer,并將由它管理的部分 state 傳給它
      nextState[key] = reducers[key](state[key], action);
      return nextState;
    }, {});
  };
}

第二次嘗試: 編寫 Reducer Enhancer

現(xiàn)在我們對 reducer enhancer 有了更深的了解,我們可以明確所謂的可撤銷到底是什么:

function undoable(reducer) {
  // 以一個空的 action 調(diào)用 reducer 來產(chǎn)生初始的 state
  const initialState = {
    past: [],
    present: reducer(undefined, {}),
    future: []
  };

  // 返回一個可以執(zhí)行撤銷和重做的新的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:
      // 將其他 action 委托給原始的 reducer 處理
      const newPresent = reducer(present, action);
      if (present === newPresent) {
        return state;
      }
      return {
        past: [...past, present],
        present: newPresent,
        future: []
      };
    }
  };
}

我們現(xiàn)在可以將任意的 reducer 通過這個擁有可撤銷能力的 reducer enhancer 進(jìn)行封裝,從而讓它們可以處理 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'
});

還有一個重要注意點(diǎn):你需要記住當(dāng)你恢復(fù)一個 state 時,必須把 .present 追加到它上面。你也不能忘了需要通過檢查 .past.length.future.length 來決定撤銷和重做按鈕是否可用。

你可能聽說過 Redux 受 Elm 架構(gòu) 影響頗深。所以這個示例與 elm-undo-redo package 也不會太令人吃驚。

使用 Redux Undo

以上這些都非常有用,但是有沒有一個庫能幫助我們實現(xiàn)可撤銷功能,而不是由我們自己編寫呢?當(dāng)然有!來看看 Redux Undo,它可以為你的 Redux 狀態(tài)樹中的任何部分提供撤銷和重做功能。

在這個部分中,你會學(xué)到如何讓 示例:Todo List 擁有可撤銷的功能。你可以在 todos-with-undo找到完整的源碼。

安裝

首先,你必須先執(zhí)行

npm install --save redux-undo

這一步會安裝一個提供可撤銷功能的 reducer enhancer 的庫。

封裝 Reducer

你需要通過 undoable 函數(shù)強(qiáng)化你的 reducer。例如,如果使用了 combineReducers(),你的代碼看起來應(yīng)該像這樣:

reducers.js

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

/* ... */

const todoApp = combineReducers({
  visibilityFilter,
  todos: undoable(todos, { filter: distinctState() })
});

distinctState() 過濾器將會忽略那些沒有引起 state 變化的 action。還有一些其他選項來配置你可撤銷的 reducer,例如為撤銷和重做動作指定 action 的類型。

你可以在 reducer 合并層次中的任何級別對一個或多個 reducer 執(zhí)行 undoable。由于 visibilityFilter 的變化并不會影響撤銷歷史,我們選擇只對 todos reducer 進(jìn)行封裝,而不是整個頂層的 reducer。

更新 Selector

現(xiàn)在 todos 相關(guān)的 state 看起來應(yīng)該像這樣:

{
  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/App.js

function select(state) {
  const presentTodos = state.todos.present;
  return {
    visibleTodos: selectTodos(presentTodos, state.visibilityFilter),
    visibilityFilter: state.visibilityFilter
  };
}

為了確認(rèn)撤銷和重做按鈕是否可用,你必須檢查 pastfuture 數(shù)組是否為空:

containers/App.js

function select(state) {
  return {
    undoDisabled: state.todos.past.length === 0,
    redoDisabled: state.todos.future.length === 0,
    visibleTodos: selectTodos(state.todos.present, state.visibilityFilter),
    visibilityFilter: state.visibilityFilter
  };
}

添加按鈕

現(xiàn)在,你需要做的全部事情就只是為撤銷和重做操作添加按鈕了。

首先,你需要從 redux-undo 中導(dǎo)入 ActionCreators,并將他們傳遞給 Footer 組件:

containers/App.js

import { ActionCreators } from 'redux-undo';

/* ... */

class App extends Component {
  render() {
    const { dispatch, visibleTodos, visibilityFilter } = this.props;
    return (
      <div>
        {/* ... */}
        <Footer
          filter={visibilityFilter}
          onFilterChange={nextFilter => dispatch(setVisibilityFilter(nextFilter))}
          onUndo={() => dispatch(ActionCreators.undo())}
          onRedo={() => dispatch(ActionCreators.redo())}
          undoDisabled={this.props.undoDisabled}
          redoDisabled={this.props.redoDisabled} />
      </div>
    );
  }
}

在 footer 中渲染它們:

components/Footer.js

export default class Footer extends Component {

  /* ... */

  renderUndo() {
    return (
      <p>
        <button onClick={this.props.onUndo} disabled={this.props.undoDisabled}>Undo</button>
        <button onClick={this.props.onRedo} disabled={this.props.redoDisabled}>Redo</button>
      </p>
    );
  }

  render() {
    return (
      <div>
        {this.renderFilters()}
        {this.renderUndo()}
      </div>
    );
  }
}

就是這樣!在示例文件夾下執(zhí)行 npm installnpm start 試試看吧!

以上內(nèi)容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號