在應(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, 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)撤消和重做。
撤消歷史也是你的應(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 以解決下面幾個問題:
這是一個對于 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ù)據(jù)類型,重做歷史記錄的 state 結(jié)構(gòu)始終一致:
{
past: Array<T>,
present: T,
future: Array<T>
}
讓我們討論一下如何通過算法來操作上文所述的 state 結(jié)構(gòu)。我們可以定義兩個 action 來操作該 state:UNDO
和 REDO
。在 reducer 中,我們希望以如下步驟處理這兩個 action:
past
中的最后一個元素。present
。present
插入到 future
的最前面。future
中的第一個元素。present
。present
追加到 past
的最后面。present
追加到 past
的最后面。present
。future
。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ù)先知道它。present
保存到 past
的工作?present
狀態(tài)的控制委托給一個自定義的 reducer?看起來 reducer 并不是正確的抽象方式,但是我們已經(jīng)非常接近了。
你可能已經(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;
}, {});
};
}
現(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)行封裝,從而讓它們可以處理 UNDO
和 REDO
這兩個 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 也不會太令人吃驚。
以上這些都非常有用,但是有沒有一個庫能幫助我們實現(xiàn)可撤銷
功能,而不是由我們自己編寫呢?當(dāng)然有!來看看 Redux Undo,它可以為你的 Redux 狀態(tài)樹中的任何部分提供撤銷和重做功能。
在這個部分中,你會學(xué)到如何讓 示例:Todo List 擁有可撤銷的功能。你可以在 todos-with-undo
找到完整的源碼。
首先,你必須先執(zhí)行
npm install --save redux-undo
這一步會安裝一個提供可撤銷
功能的 reducer enhancer 的庫。
你需要通過 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。
現(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)撤銷和重做按鈕是否可用,你必須檢查 past
和 future
數(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 install
和 npm start
試試看吧!
更多建議: