實作復原記錄
傳統上,在應用程式中建置「復原」和「重做」功能需要開發人員有意識地付出努力。這在傳統 MVC 架構中並非易事,因為您需要透過複製所有相關模型來追蹤每個過去的狀態。此外,您需要留意「復原」堆疊,因為使用者發起的變更應可復原。
這表示在 MVC 應用程式中實作「復原」和「重做」通常會強迫您改寫應用程式的部分內容,以使用特定的資料變異模式,例如指令。
然而,使用 Redux,實作復原記錄會變得輕而易舉。原因有三個
- 沒有多個模型,只有一個您想要追蹤的狀態子樹。
- 狀態已經是不可變的,而且變異已經描述為離散動作,這接近於復原堆疊心智模型。
- reducer
(state, action) => state
簽章讓實作通用「reducer 增強器」或「高階 reducer」變得自然。它們是函式,會採用您的 reducer 並透過保留其簽章來增強一些額外功能。復原記錄正是這樣的案例。
在本食譜的第一部分,我們將說明讓「復原」和「重做」能夠以通用方式實作的底層概念。
在本食譜的第二部分,我們將示範如何使用Redux Undo 套件,它提供此開箱即用的功能。
了解復原記錄
設計狀態形狀
復原記錄也是應用程式狀態的一部分,我們沒有理由以不同的方式來處理它。不論狀態類型如何隨著時間而改變,當您實作「復原」和「重做」時,您都想要追蹤此狀態在不同時間點的記錄。
例如,計數器應用程式的狀態形狀可能如下所示
{
counter: 10
}
如果我們想要在這樣的應用程式中實作「復原」和「重做」,我們需要儲存更多狀態,以便回答下列問題
- 還有任何內容可以復原或重做嗎?
- 目前的狀態是什麼?
- 復原堆疊中的過去(和未來)狀態是什麼?
建議我們可以改變狀態的形狀來回答這些問題
{
counter: {
past: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
present: 10,
future: []
}
}
現在,如果使用者按下「復原」,我們希望它可以改變成回到過去
{
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]
}
}
當使用者按下「重做」,我們希望可以向前移動一步到未來
{
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, 8, 9],
present: 8,
future: []
}
}
這裡有趣的地方在於,無論我們是要保留數字、字串、陣列或物件的復原堆疊,結構都將永遠相同
{
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: []
}
或許多個細微的歷史記錄,讓使用者可以獨立地復原和重做這些動作
{
counterA: {
past: [1, 0],
present: 2,
future: []
},
counterB: {
past: [0],
present: 1,
future: []
}
}
我們稍後會看到,我們採取的方法讓我們可以選擇復原和重做的細微程度。
設計演算法
無論具體的資料類型為何,復原歷史狀態的形狀都是相同的
{
past: Array<T>,
present: T,
future: Array<T>
}
讓我們討論一下用來處理上述狀態形狀的演算法。我們可以定義兩個動作來操作這個狀態:復原
和重做
。在我們的 reducer 中,我們將執行以下步驟來處理這些動作
處理復原
- 從
過去
中移除最後一個元素。 - 將
現在
設定為我們在上一步驟中移除的元素。 - 將舊的
現在
狀態插入到未來
的開頭。
處理重做
- 從
未來
中移除第一個元素。 - 將
現在
設定為我們在上一步驟中移除的元素。 - 將舊的
現在
狀態插入到過去
的結尾。
處理其他動作
- 將
現在
插入到過去
的結尾。 - 將
現在
設定為處理動作後的新的狀態。 - 清除
未來
。
第一次嘗試:撰寫 reducer
const initialState = {
past: [],
present: null, // (?) How do we initialize the 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:
// (?) How do we handle other actions?
return state
}
}
這個實作無法使用,因為它遺漏了三個重要的問題
- 我們從哪裡取得初始的
present
狀態?我們似乎事先不知道。 - 我們在哪裡對外部動作做出反應,將
present
儲存到past
中? - 我們實際上如何將
present
狀態的控制權委派給自訂的 reducer?
reducer 似乎不是正確的抽象,但我們已經非常接近了。
認識 Reducer 增強器
你可能熟悉 高階函式。如果你使用 React,你可能熟悉 高階元件。以下是應用於 reducer 的相同模式的變體。
reducer 增強器(或高階 reducer)是一個函式,它接收一個 reducer,並傳回一個新的 reducer,這個新的 reducer 能夠處理新的動作,或持有更多狀態,並將它不理解的動作的控制權委派給內部 reducer。這不是一個新的模式,技術上來說,combineReducers()
也是一個 reducer 增強器,因為它接收 reducer 並傳回一個新的 reducer。
一個沒有做任何事的 reducer 增強器看起來像這樣
function doNothingWith(reducer) {
return function (state, action) {
// Just call the passed reducer
return reducer(state, action)
}
}
一個組合其他 reducer 的 reducer 增強器可能看起來像這樣
function combineReducers(reducers) {
return function (state = {}, action) {
return Object.keys(reducers).reduce((nextState, key) => {
// Call every reducer with the part of the state it manages
nextState[key] = reducers[key](state[key], action)
return nextState
}, {})
}
}
第二次嘗試:撰寫一個 Reducer 增強器
現在我們對 reducer 增強器有更好的理解,我們可以看到這正是 undoable
應該有的樣子
function undoable(reducer) {
// Call the reducer with empty action to populate the initial state
const initialState = {
past: [],
present: reducer(undefined, {}),
future: []
}
// Return a reducer that handles undo and redo
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:
// Delegate handling the action to the passed reducer
const newPresent = reducer(present, action)
if (present === newPresent) {
return state
}
return {
past: [...past, present],
present: newPresent,
future: []
}
}
}
}
我們現在可以將任何 reducer 包裝到 undoable
reducer 增強器中,教導它對 UNDO
和 REDO
動作做出反應。
// This is a reducer
function todos(state = [], action) {
/* ... */
}
// This is also a 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
。你也可以檢查 .past.length
和 .future.length
來確定是否分別啟用或停用復原和重做按鈕。
你可能聽說 Redux 受到 Elm 架構 的影響。這個範例與 elm-undo-redo 套件 非常相似,這並不令人意外。
使用 Redux Undo
這一切都非常有幫助,但我們不能只放一個函式庫,然後使用它,而不是自己實作 `undoable` 嗎?當然可以!認識 Redux Undo,一個函式庫,它為 Redux 樹的任何部分提供簡單的復原和重做功能。
在本食譜的這部分,您將學習如何讓一個小的「待辦事項清單」應用程式邏輯可復原。您可以在 Redux 附帶的 todos-with-undo
範例 中找到此食譜的完整原始碼。
安裝
首先,您需要執行
npm install redux-undo
這會安裝提供 `undoable` reducer 增強器的套件。
包裝 Reducer
您需要使用 `undoable` 函式包裝您希望增強的 reducer。例如,如果您從一個專用檔案匯出一個 `todos` reducer,您會希望將它變更為匯出呼叫 `undoable()` 並帶有您撰寫的 reducer 的結果
reducers/todos.js
import undoable from 'redux-undo'
/* ... */
const todos = (state = [], action) => {
/* ... */
}
const undoableTodos = undoable(todos)
export default undoableTodos
有 許多其他選項 可以設定您的 undoable reducer,例如設定復原和重做動作的動作類型。
請注意,您的 `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 組合階層的任何層級包裝一個或多個 reducer。我們選擇包裝 `todos`,而不是頂層的組合 reducer,這樣對 `visibilityFilter` 的變更就不會反映在復原記錄中。
更新 Selector
現在狀態的 `todos` 部分看起來像這樣
{
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.todos` 來存取您的狀態
containers/VisibleTodoList.js
const mapStateToProps = state => {
return {
todos: getVisibleTodos(state.todos.present, state.visibilityFilter)
}
}
新增按鈕
現在您需要做的就是新增復原和重做動作的按鈕。
首先,為這些按鈕建立一個新的容器元件,稱為 `UndoRedo`。我們不會費心將呈現部分拆分到一個獨立的檔案,因為它很小
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()` 來產生一個容器元件。若要判斷是否啟用復原和重做按鈕,您可以檢查 `state.todos.past.length` 和 `state.todos.future.length`。您不需要為執行復原和重做撰寫動作建立器,因為 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
現在您可以將 UndoRedo
元件新增至 App
元件
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 install
和 npm start
,並試用看看!