前不久,我參與開發了團隊中的一個 web 應用,其中的一個頁面操做以下圖所示:javascript
這個製做間頁面有着相似 PPT 的交互:從左側的工具欄中選擇元素放入中間的畫布、在畫布中能夠刪除、操做(拖動、縮放、旋轉等)這些元素。前端
在這個編輯過程當中,讓用戶可以進行操做的撤銷、重作會提升編輯效率,大大提升用戶體驗,而本文要講的正是在這個功能實現中的探索與總結。java
用戶的一系列操做會改變頁面的狀態:react
在進行了某個操做後,用戶有能力回到以前的某個狀態,即撤銷:git
在撤銷某個操做後,用戶有能力再次恢復這個操做,即重作:github
當頁面處於某個歷史狀態時,這時用戶進行了某個操做後,這個狀態後面的狀態會被拋棄,此時產生一個新的狀態分支:
web
下面,開始實現這些邏輯。redux
基於以上的分析,實現撤銷重作功能須要實現:設計模式
操做形成的狀態改變能夠用語言來描述,以下圖,頁面上有一個絕對定位的 div
和 一個 button
,每次點擊 button
會讓 div
向右移動 10px
。這個點擊操做能夠被描述爲:div
的樣式屬性 left
增長 10px
。api
顯然,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
、執行函數、撤銷函數做爲一個總體封裝成一個命令對象:
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
個數據快照;在實際項目中,一般會使用 combineReducers 來模塊化 reducer
,這種狀況下,在每一個 reducer
中都要重複處理以上的邏輯。這時候就可使用高階 reducer
函數來抽取公用邏輯:
const highOrderReducer = (reducer) => { return (state, action) => { state = { ...state }; const { timeline, current, limit } = state; // 執行真實的業務reducer const 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
對撤銷重作邏輯無感知,實現功能可拔插。
撤銷重作時也應該遵循 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 和 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]; }
本文圍繞撤銷重作功能實現的講解到此結束,在實現該功能後引入了命令模式來使得代碼結構更加健壯,最後改進成數據快照式,從而讓整個應用架構更加優雅。
本文發佈自 網易雲音樂前端團隊,歡迎自由轉載,轉載請保留出處。咱們對人才飢渴難耐,快來 加入咱們!