實現編輯功能有哪幾個action_Web 應用的撤銷重做實現

899a1b5d1d2799bc9d6e71f5559b790c.png

背景

前不久,我參與開發了團隊中的一個 web 應用,其中的一個頁面操作如下圖所示:

GIF

這個制作間頁面有著類似 PPT 的交互:從左側的工具欄中選擇元素放入中間的畫布、在畫布中可以刪除、操作(拖動、縮放、旋轉等)這些元素。

在這個編輯過程中,讓用戶能夠進行操作的撤銷、重做會提高編輯效率,大大提高用戶體驗,而本文要講的正是在這個功能實現中的探索與總結。

功能分析

用戶的一系列操作會改變頁面的狀態:

1f484deca15dabdf118c12e766f884b8.png

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

1d00ef3445cb939eb7d2325343d40a6d.png

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

088399dda87b0145da5389c88ce9db63.png

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

372aa5be0850acb7b22c25d0104472cc.png

下面,開始實現這些邏輯。

功能初實現

基于以上的分析,實現撤銷重做功能需要實現:

  • 保存用戶的每個操作;
  • 針對每個操作設計與之對應的一個撤銷邏輯;
  • 實現撤銷重做的邏輯;

第一步:數據化每一個操作

操作造成的狀態改變可以用語言來描述,如下圖,頁面上有一個絕對定位的 div 和 一個 button,每次點擊 button 會讓 div 向右移動 10px。這個點擊操作可以被描述為:div 的樣式屬性 left 增加 10px

41dac272bd5b34c1fa81b3c8b4f9c6e7.png

顯然,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 為應用的狀態數據,paramsaction.params

第二步:編寫操作對應的撤銷邏輯

撤銷函數中結構與執行函數類似,也應該能獲取到 dataaction

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({});

這樣的模式已經可以讓項目的代碼變得健壯,看起來已經很不錯了,但是能不能更好呢?

模式進階:數據快照式

命令模式要求開發者針對每一個操作都要額外開發一個撤銷函數,這無疑是麻煩的。接下來要介紹的數據快照式就是要改進這個缺點。

數據快照式通過保存每次操作后的數據快照,然后在撤銷重做的時候通過歷史快照恢復頁面,模式模型如下:

2791cd0d2501f838c0f56bb94c9f75fa.png

要使用這種模式是有要求的:

  • 應用的狀態數據需要集中管理,不應該分散在各個組件;
  • 數據更改流程中有統一的地方可以做數據快照存儲;

這些要求不難理解,既然要產生數據快照,集中管理才會更加便利。基于這些要求,我選擇了市面上較為流行的 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 的對象是引用類型,變量名只是保存了它們的引用,真正的數據存放在堆內存中,所以 datanewData 共享一份數據,所以歷史數據和當前數據都會發生變化。

方式一:使用深拷貝

深拷貝的實現最簡單的方法就是使用 JSON 對象的原生方法:

const newData = JSON.parse(JSON.stringify(data));

或者,借助一些工具比如 lodash:

const newData = lodash.cloneDeep(data);

不過,深拷貝可能出現循環引用而引起的死循環問題,而且,深拷貝會拷貝每一個節點,這樣的方式帶來了無謂的性能損耗。

方式二:構建不可變數據

假設有個對象如下,需要修改第一個 componentwidth200

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
本文發布自 網易云音樂前端團隊,文章未經授權禁止任何形式的轉載。我們對人才饑渴難耐,快來 加入我們!

本文來自互聯網用戶投稿,該文觀點僅代表作者本人,不代表本站立場。本站僅提供信息存儲空間服務,不擁有所有權,不承擔相關法律責任。
如若轉載,請注明出處:http://www.pswp.cn/news/370131.shtml
繁體地址,請注明出處:http://hk.pswp.cn/news/370131.shtml
英文地址,請注明出處:http://en.pswp.cn/news/370131.shtml

如若內容造成侵權/違法違規/事實不符,請聯系多彩編程網進行投訴反饋email:809451989@qq.com,一經查實,立即刪除!

相關文章

為什么我們要做三份 Webpack 配置文件

時至今日,Webpack 已經成為前端工程必備的基礎工具之一,不僅被廣泛用于前端工程發布前的打包,還在開發中擔當本地前端資源服務器(assets server)、模塊熱更新(hot module replacement)、API Pro…

使用maven插件構建docker鏡像

為什么要用插件 主要還是自動化的考慮,如果額外使用Dockerfile進行鏡像生成,可能會需要自己手動指定jar/war位置,并且打包和生成鏡像間不同步,帶來很多瑣碎的工作。 插件選擇 使用比較多的是spotify的插件:https://github.com/spo…

windows下如何安裝pip以及如何查看pip是否已經安裝成功?

最近剛學習python,發現很多關于安裝以及查看pip是否安裝成的例子都比較老,不太適合于現在(python 3.6 )因此,下一個入門級別的教程。 0:首先如何安裝python我就不做介紹了。 1:如果安裝的是pyth…

檢查用戶顯示器的分辨率

檢查用戶顯示器的分辨率 轉載于:https://www.cnblogs.com/Renyi-Fan/p/8088012.html

android 字體 dpi,詳解Android開發中常用的 DPI / DP / SP

Android的碎片化已經被噴了好多年,隨著國內手機廠商的崛起,碎片化也越來越嚴重,根據OpenSignal的最新調查,2014年市面上有18796種不同的Android設備,作為開發者,一個無法回避的難題就是需要適配各種各樣奇奇…

android studio閃退代碼不報錯_代碼不報錯,不代表真的沒錯

今天是生信星球陪你的第695天大神一句話,菜鳥跑半年。我不是大神,但我可以縮短你走彎路的半年~就像歌兒唱的那樣,如果你不知道該往哪兒走,就留在這學點生信好不好~這里有豆豆和花花的學習歷程,從新手到進階&#xff0c…

Centos7操作系統部署指南

一、硬件環境: Dell R620 二、軟件環境: Centos6.4 X86_64 KVM Windows7vnc 三、安裝說明 操作系統更新之迅速,讓作為新手的系統運維人員有點措手不及,相對于老手就胸有成竹。怎么講?由于老手對技術方向把握的非常好&…

Eclipse插件中的SLF4J登錄

一直都在使用Maven和純Java庫進行開發,我從沒想過在開發Eclipse插件時發出一些日志語句可能會成為問題。 但是,在Eclipse開發人員的想象中,一切似乎總是在Eclipse環境中,而Eclipse宇宙之外則什么都沒有。 如果您使用Google搜索上…

CSS(四)

css元素溢出 當子元素的尺寸超過父元素的尺寸時,需要設置父元素顯示溢出的子元素的方式,設置的方法是通過overflow屬性來設置。 overflow的設置項: 1、visible 默認值。內容不會被修剪,會呈現在元素框之外。2、hidden 內容會被修…

mysql排名

轉載自思心思危http://www.cnblogs.com/zengguowang/p/5541431.html 一、sql1{不管數據相同與否,排名依次排序(1,2,3,4,5,6,7.....)} SELECTobj.user_id,   obj.score,  rownum : rownum 1 AS rownum FROM(SELECT…

python中變量名后的逗號_深入淺析python變量加逗號,的含義

逗號,用于生成一個長度為1的元組>>> (1)1>>> (1,)(1,)>>> 1,(1,)因此需要將長度為1的元組中元素提取出來可以用,簡化賦值操作>>> a(1,)>>> ba>>> b(1,)>>> b,a>>> b1最后print打印變量加,實現連續打印…

廣告的顯示和關閉

app或游戲的主頁顯示廣告頁面,實現方式: public class MainActivity extends Activity implements View.OnClickListener{private Button btnShowAd;private RelativeLayout layoutAd;Overrideprotected void onCreate(Bundle savedInstanceState) {supe…

android簽到功能模塊,基于android的課堂簽到系統.doc

基于android的課堂簽到系統本科畢業論文(設計)題 目 基于Android的課堂簽到系統學生姓名 XXX指導教師 XX學 院 信息科學與工程學院專業班級 計算機科學與技術0908班完成時間 2013年5月 摘 要在大學課堂中,簽到問題一直困擾著老師和同學們。傳統課堂簽到的手段大多是…

Java EE 7社區調查結果!

在JSR 342下可以繼續進行Java EE 7的工作。一切進展順利,Java EE 7現在處于“初稿審查”階段。 在11月初, Oracle發布了一個有關即將推出的Java EE 7功能的小型社區調查 。 昨天結果公布了。 超過1,100名開發人員參加了調查,并且幾乎對每個問…

CSS(三)

CSS盒子模型 盒子模型解釋 元素在頁面中顯示成一個方塊,類似一個盒子,CSS盒子模型就是使用現實中盒子來做比喻,幫助我們設置元素對應的樣式。盒子模型示意圖如下: 把元素叫做盒子,設置對應的樣式分別為:盒…

一道關于運行順序題

function foo(){   getName function(){console.log(1)}   return this } foo.getName function(){console.log(2)} foo.prototype.getName function(){console.log(3)} var getName function(){console.log(4)} function getName(){console.log(5)} foo.getName()//2 …

android+小米文件管理器源碼,小米開源文件管理器MiCodeFileExplorer-源碼研究(2)-2個單實例工具類...

從本篇開始,講解net.micode.fileexplorer.util工具包中的類。這個包下的類,功能也比較單一和獨立。很多代碼的思想和實現,可以用于JavaWeb和Android等多種環境中。一、單實例活動管理器ActivitiesManager一個單實例的活動管理器,從…

移動優先的響應式布局

前面的話 隨著移動互聯網的興起,不同設備的分辨率相差較大,如果在不同的設置上顯示同一個頁面,則用戶體驗差。響應式網頁設計是一種方法,使得一個網站能夠兼容多個終端,而不用為每個終端制作特定的版本。它使得一個網站…

python中英文字符和中文字符存儲長度不同_Django如何正確截取中英混合字符串及表單中限制中文字符中長度...

中文字符和英文字符所占的字節長度是不一樣,一個是2個字節,一個是1個字節,這給我們用英文的web框架開發中文app帶來了麻煩。比如Django自帶過濾器truncatewords并不支持截取中文,另外模型中CharField中的max_length選項用于限制中…

使用RESTful客戶端API進行GET / POST

互聯網上有很多如何使用RESTful Client API的東西。 這些是基礎。 但是,盡管該主題看起來微不足道,但仍然存在一些障礙,尤其是對于初學者而言。 在這篇文章中,我將嘗試總結我的專業知識,以及我如何在實際項目中做到這…