
背景
前不久,我參與開發了團隊中的一個 web 應用,其中的一個頁面操作如下圖所示:
GIF
這個制作間頁面有著類似 PPT 的交互:從左側的工具欄中選擇元素放入中間的畫布、在畫布中可以刪除、操作(拖動、縮放、旋轉等)這些元素。
在這個編輯過程中,讓用戶能夠進行操作的撤銷、重做會提高編輯效率,大大提高用戶體驗,而本文要講的正是在這個功能實現中的探索與總結。
功能分析
用戶的一系列操作會改變頁面的狀態:

在進行了某個操作后,用戶有能力回到之前的某個狀態,即撤銷:

在撤銷某個操作后,用戶有能力再次恢復這個操作,即重做:

當頁面處于某個歷史狀態時,這時用戶進行了某個操作后,這個狀態后面的狀態會被拋棄,此時產生一個新的狀態分支:

下面,開始實現這些邏輯。
功能初實現
基于以上的分析,實現撤銷重做功能需要實現:
- 保存用戶的每個操作;
- 針對每個操作設計與之對應的一個撤銷邏輯;
- 實現撤銷重做的邏輯;
第一步:數據化每一個操作
操作造成的狀態改變可以用語言來描述,如下圖,頁面上有一個絕對定位的 div
和 一個 button
,每次點擊 button
會讓 div
向右移動 10px
。這個點擊操作可以被描述為:div
的樣式屬性 left
增加 10px
。

顯然,JavaScript 并不認識這樣的描述,需要將這份描述翻譯成 JavaScript 認識的語言:
const action = {name: 'changePosition',params: {target: 'left',value: 10,},
};
上面代碼中使用變量 name
表示操作具體的名稱,params
存儲了該操作的具體數據。不過 JavaScript 目前仍然不知道如何使用這個它,還需要一個執行函數來指定如何使用上面的數據:
function changePosition(data, params) {const { property, distance } = params;data = { ...data };data[property] += distance;return data;
}
其中,data
為應用的狀態數據,params
為 action.params
。
第二步:編寫操作對應的撤銷邏輯
撤銷函數中結構與執行函數類似,也應該能獲取到 data
和 action
:
function changePositionUndo(data, params) {const { property, distance } = params;data = { ...data };data[property] -= distance;return data;
}
所以,action
的設計應當同時滿足執行函數和撤銷函數的邏輯。
第三步:撤銷、重做處理
上述的 action
、執行函數、撤銷函數三者作為一個整體共同描述了一個操作,所以存儲時三者都要保存下來。
這里基于約定進行綁定:執行函數名等于操作的 name
,撤銷函數名等于 name + 'Undo'
,這樣就只需要存儲 action
,隱式地也存儲了執行函數和撤銷函數。
編寫一個全局模塊存放函數、狀態等:src/manager.js
:
const functions = {changePosition(state, params) {...},changePositionUndo(state, params) {...}
};export default {data: {},actions: [],undoActions: [],getFunction(name) {return functions[name];}
};
那么,點擊按鈕會產生一個新的操作,我們需要做的事情有三個:
- 存儲操作的
action
; - 執行該操作;
- 如果處于歷史節點,需要產生新的操作分支;
import manager from 'src/manager.js';buttonElem.addEventListener('click', () => {manager.actions.push({name: 'changePosition',params: { target: 'left', value: 10 }});const execFn = manager.getFunction(action.name);manager.data = execFn(manager.data, action.params);if (manager.undoActions.length) {manager.undoActions = [];}
});
其中,undoActions
存放的是撤銷的操作的 action
,這里清空表示拋棄當前節點以后的操作。將 action
存進 manager.actions
,這樣需要撤銷操作的時候,直接取出 manager.actions
中最后一個 action
,找到對應撤銷函數并執行即可。
import manager from 'src/manager.js';function undo() {const action = manager.actions.pop();const undoFn = manager.getFunction(`${action.name}Undo`);manager.data = undoFn(manager.data, action.params);manager.undoActions.push(action);
}
需要重做的時候,取出 manager.undoActions
中最后的 action
,找到對應執行函數并執行。
import manager from 'src/manager.js';function redo() {const action = manager.undoActions.pop();const execFn = manager.getFunction(action.name);manager.data = execFn(manager.data, action.params);
}
模式優化:命令模式
以上代碼可以說已經基本滿足了功能需求,但是在我看來仍然存在一些問題:
- 管理分散:某個操作的
action
、執行函數、撤銷函數分開管理。當項目越來越大時將會維護困難; - 職責不清:并沒有明確規定執行函數、撤銷函數、狀態的改變該交給業務組件執行還是給全局管理者執行,這不利于組件和操作的復用;
想有效地解決以上問題,需要找到一個合適的新模式來組織代碼,我選擇了命令模式。
命令模式簡介
簡單來說,命令模式將方法、數據都封裝到單一的對象中,對調用方與執行方進行解耦,達到職責分離的目的。
以顧客在餐廳吃飯為例子:
- 顧客點餐時,選擇想吃的菜,提交一份點餐單
- 廚師收到這份點餐單后根據內容做菜
期間,顧客和廚師之間并沒有見面交談,而是通過一份點餐單來形成聯系,這份點餐單就是一個命令對象,這樣的交互模式就是命令模式。
action + 執行函數 + 撤銷函數 = 操作命令對象
為了解決管理分散的問題,可以把一個操作的 action
、執行函數、撤銷函數作為一個整體封裝成一個命令對象:
class ChangePositionCommand {constructor(property, distance) {this.property = property; // 如:'left'this.distance = distance; // 如: 10}execute(state) {const newState = { ...state }newState[this.property] += this.distance;return newState;}undo(state) {const newState = { ...state }newState[this.property] -= this.distance;return newState;}
}
業務組件只關心命令對象的生成和發送
在狀態數據處理過程中往往伴隨著一些副作用,這些與數據耦合的邏輯會大大降低組件的復用性。因此,業務組件不用關心數據的修改過程,而是專注自己的職責:生成操作命令對象并發送給狀態管理者。
import manager from 'src/manager';
import { ChangePositionCommand } from 'src/commands';buttonElem.addEventListener('click', () => {const command = new ChangePositionCommand('left', 10);manager.addCommand(command);
});
狀態管理者只關心數據變更和操作命令對象治理
class Manager {constructor(initialState) {this.state = initialState;this.commands = [];this.undoCommands = [];}addCommand(command) {this.state = command.execute(this.state);this.commands.push(command);this.undoCommands = []; // 產生新分支}undo() {const command = this.commands.pop();this.state = command.undo(this.state);this.undoCommands.push(command);}redo() {const command = this.undoCommands.pop();this.state = command.execute(this.state);this.commands.push(command);}
}export default new Manger({});
這樣的模式已經可以讓項目的代碼變得健壯,看起來已經很不錯了,但是能不能更好呢?
模式進階:數據快照式
命令模式要求開發者針對每一個操作都要額外開發一個撤銷函數,這無疑是麻煩的。接下來要介紹的數據快照式就是要改進這個缺點。
數據快照式通過保存每次操作后的數據快照,然后在撤銷重做的時候通過歷史快照恢復頁面,模式模型如下:

要使用這種模式是有要求的:
- 應用的狀態數據需要集中管理,不應該分散在各個組件;
- 數據更改流程中有統一的地方可以做數據快照存儲;
這些要求不難理解,既然要產生數據快照,集中管理才會更加便利。基于這些要求,我選擇了市面上較為流行的 Redux 來作為狀態管理器。
狀態數據結構設計
按照上面的模型圖,Redux 的 state
可以設計成:
const state = {timeline: [],current: -1,limit: 1000,
};
代碼中,各個屬性的含義為:
timeline
:存儲數據快照的數組;current
:當前數據快照的指針,為timeline
的索引;limit
:規定了timeline
的最大長度,防止存儲的數據量過大;
數據快照生成的方式
假設應用初始的狀態數據為:
const data = { left: 100 };
const state = {timeline: [data],current: 0,limit: 1000,
};
進行了某個操作后,left
加 100,有些新手可能會直接這么做:
cont newData = data;
newData.left += 100;
state.timeline.push(newData);
state.current += 1;
這顯然是錯誤的,因為 JavaScript 的對象是引用類型,變量名只是保存了它們的引用,真正的數據存放在堆內存中,所以 data
和 newData
共享一份數據,所以歷史數據和當前數據都會發生變化。
方式一:使用深拷貝
深拷貝的實現最簡單的方法就是使用 JSON 對象的原生方法:
const newData = JSON.parse(JSON.stringify(data));
或者,借助一些工具比如 lodash:
const newData = lodash.cloneDeep(data);
不過,深拷貝可能出現循環引用而引起的死循環問題,而且,深拷貝會拷貝每一個節點,這樣的方式帶來了無謂的性能損耗。
方式二:構建不可變數據
假設有個對象如下,需要修改第一個 component
的 width
為 200
:
const state = {components: [{ type: 'rect', width: 100, height: 100 },{ type: 'triangle': width: 100, height: 50}]
}
目標屬性的在對象樹中的路徑為:['components', 0, 'width']
,這個路徑上有些數據是引用類型,為了不造成共享數據的變化,這個引用類型要先變成一個新的引用類型,如下:
const newState = { ...state };
newState.components = [...state.components];
newState.components[0] = { ...state.components[0] };
這時你就可以放心修改目標值了:
newState.components[0].width = 200;
console.log(newState.components[0].width, state.components[0].width); // 200, 100
這樣的方式只修改了目標屬性節點的路徑上的引用類型值,其他分支上的值是不變的,這樣節省了不少內存。為了避免每次都一層一層去修改,可以將這個處理封裝成一個工具函數:
const newState = setIn(state, ['components', 0, 'width'], 200)
setIn
源碼:https://github.com/cwajs/cwa-immutable/blob/master/src/setIn.js
數據快照處理邏輯
進行某個操作,reducer
代碼為:
function operationReducer(state, action) {state = { ...state };const { current, limit } = state;const newData = ...; // 省略過程state.timeline = state.timeline.slice(0, current + 1);state.timeline.push(newData);state.timeline = state.timeline.slice(-limit);state.current = state.timeline.length - 1;return state;
}
有兩個地方需要解釋:
timline.slice(0, current + 1)
:這個操作是前文提到的,進行新操作時,應該拋棄當前節點后的操作,產生一個新的操作分支;timline.slice(-limit)
:表示只保留最近的limit
個數據快照;
使用高階 reducer
在實際項目中,通常會使用 combineReducers 來模塊化 reducer
,這種情況下,在每個 reducer
中都要重復處理以上的邏輯。這時候就可以使用高階 reducer
函數來抽取公用邏輯:
const highOrderReducer = (reducer) => {return (state, action) => {state = { ...state };const { timeline, current, limit } = state;// 執行真實的業務reducerconst newState = reducer(timeline[current], action);// timeline處理state.timeline = timeline.slice(0, current + 1);state.timeline.push(newState);state.timeline = state.timeline.slice(-limit);state.current = state.timeline.length - 1;return state;};
}// 真實的業務reducer
function reducer(state, action) {switch (action.type) {case 'xxx':newState = ...;return newState;}
}const store = createStore(highOrderReducer(reducer), initialState);
這個高階 reducer
使用 const newState = reducer(timeline[current], action)
來對業務 reducer
隱藏數據快照隊列的數據結構,使得業務 reducer
對撤銷重做邏輯無感知,實現功能可拔插。
增強高階 reducer,加入撤銷重做邏輯
撤銷重做時也應該遵循 Redux 的數據修改方式使用 store.dispatch
,為:
store.dispatch({ type: 'undo' })
;store.dispatch({ type: 'redo' })
;
這兩種 action
不應該進入到業務 reducer
,需要進行攔截:
const highOrderReducer = (reducer) => {return (state, action) => {// 進行 undo、redo 的攔截if (action.type === 'undo') {return {...state,current: Math.max(0, state.current - 1),};}// 進行 undo、redo 的攔截if (action.type === 'redo') {return {...state,current: Math.min(state.timeline.length - 1, state.current + 1),};}state = { ...state };const { timeline, current, limit } = state;const newState = reducer(timeline[current], action);state.timeline = timeline.slice(0, current + 1);state.timeline.push(newState);state.timeline = state.timeline.slice(-limit);state.current = state.timeline.length - 1;return state;};
}
使用 react-redux 在組件中獲取狀態
我在項目中使用的是 React 和 react-redux,由于 state
的數據結構發生了變化,所以在組件中獲取狀態的寫法也要相應作出調整:
import React from 'react';
import { connect } from 'react-redux';function mapStateToProps(state) {const currentState = state.timeline[state.current];return {};
}class SomeComponent extends React.Component {}export default connect(mapStateToProps)(SomeComponent);
然而,這樣的寫法讓組件感知到了撤銷重做的數據結構,與上面所說的功能可拔插明顯相悖,我通過重寫 store.getState
方法來解決:
const store = createStore(reducer, initialState);const originGetState = store.getState.bind(store);store.getState = (...args) => {const state = originGetState(...args);return state.timeline[state.current];
}
總結
本文圍繞撤銷重做功能實現的講解到此結束,在實現該功能后引入了命令模式來使得代碼結構更加健壯,最后改進成數據快照式,從而讓整個應用架構更加優雅。
參考資料
- 《JavaScript設計模式》Addy Osmani著
- Redux Documentation
本文發布自 網易云音樂前端團隊,文章未經授權禁止任何形式的轉載。我們對人才饑渴難耐,快來 加入我們!