日韩性视频-久久久蜜桃-www中文字幕-在线中文字幕av-亚洲欧美一区二区三区四区-撸久久-香蕉视频一区-久久无码精品丰满人妻-国产高潮av-激情福利社-日韩av网址大全-国产精品久久999-日本五十路在线-性欧美在线-久久99精品波多结衣一区-男女午夜免费视频-黑人极品ⅴideos精品欧美棵-人人妻人人澡人人爽精品欧美一区-日韩一区在线看-欧美a级在线免费观看

歡迎訪問 默认站点!

默认站点

當前位置: 首頁 >

实现编辑功能有哪几个action_Web 应用的撤销重做实现

發(fā)布時間:2023/12/1 48 豆豆
默认站点 收集整理的這篇文章主要介紹了 实现编辑功能有哪几个action_Web 应用的撤销重做实现 小編覺得挺不錯的,現在分享給大家,幫大家做個參考.

背景

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

GIF

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

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

功能分析

用戶的一系列操作會改變頁面的狀態(tài):

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

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

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

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

功能初實現

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

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

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

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

顯然,JavaScript 并不認識這樣的描述,需要將這份描述翻譯成 JavaScript 認識的語言:

const action = {name: 'changePosition',params: {target: 'left',value: 10,}, };

上面代碼中使用變量 name 表示操作具體的名稱,params 存儲了該操作的具體數據。不過 JavaScript 目前仍然不知道如何使用這個它,還需要一個執(zhí)行函數來指定如何使用上面的數據:

function changePosition(data, params) {const { property, distance } = params;data = { ...data };data[property] += distance;return data; }

其中,data 為應用的狀態(tài)數據,params 為 action.params。

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

撤銷函數中結構與執(zhí)行函數類似,也應該能獲取到 data 和 action:

function changePositionUndo(data, params) {const { property, distance } = params;data = { ...data };data[property] -= distance;return data; }

所以,action 的設計應當同時滿足執(zhí)行函數和撤銷函數的邏輯。

第三步:撤銷、重做處理

上述的 action、執(zhí)行函數、撤銷函數三者作為一個整體共同描述了一個操作,所以存儲時三者都要保存下來。

這里基于約定進行綁定:執(zhí)行函數名等于操作的 name ,撤銷函數名等于 name + 'Undo',這樣就只需要存儲 action,隱式地也存儲了執(zhí)行函數和撤銷函數。

編寫一個全局模塊存放函數、狀態(tài)等:src/manager.js:

const functions = {changePosition(state, params) {...},changePositionUndo(state, params) {...} };export default {data: {},actions: [],undoActions: [],getFunction(name) {return functions[name];} };

那么,點擊按鈕會產生一個新的操作,我們需要做的事情有三個:

  • 存儲操作的 action;
  • 執(zhí)行該操作;
  • 如果處于歷史節(jié)點,需要產生新的操作分支;
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,這里清空表示拋棄當前節(jié)點以后的操作。將 action 存進 manager.actions ,這樣需要撤銷操作的時候,直接取出 manager.actions 中最后一個 action,找到對應撤銷函數并執(zhí)行即可。

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,找到對應執(zhí)行函數并執(zhí)行。

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

模式優(yōu)化:命令模式

以上代碼可以說已經基本滿足了功能需求,但是在我看來仍然存在一些問題:

  • 管理分散:某個操作的 action、執(zhí)行函數、撤銷函數分開管理。當項目越來越大時將會維護困難;
  • 職責不清:并沒有明確規(guī)定執(zhí)行函數、撤銷函數、狀態(tài)的改變該交給業(yè)務組件執(zhí)行還是給全局管理者執(zhí)行,這不利于組件和操作的復用;

想有效地解決以上問題,需要找到一個合適的新模式來組織代碼,我選擇了命令模式。

命令模式簡介

簡單來說,命令模式將方法、數據都封裝到單一的對象中,對調用方與執(zhí)行方進行解耦,達到職責分離的目的。

以顧客在餐廳吃飯為例子:

  • 顧客點餐時,選擇想吃的菜,提交一份點餐單
  • 廚師收到這份點餐單后根據內容做菜

期間,顧客和廚師之間并沒有見面交談,而是通過一份點餐單來形成聯系,這份點餐單就是一個命令對象,這樣的交互模式就是命令模式。

action + 執(zhí)行函數 + 撤銷函數 = 操作命令對象

為了解決管理分散的問題,可以把一個操作的 action、執(zhí)行函數、撤銷函數作為一個整體封裝成一個命令對象:

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;} }

業(yè)務組件只關心命令對象的生成和發(fā)送

在狀態(tài)數據處理過程中往往伴隨著一些副作用,這些與數據耦合的邏輯會大大降低組件的復用性。因此,業(yè)務組件不用關心數據的修改過程,而是專注自己的職責:生成操作命令對象并發(fā)送給狀態(tài)管理者。

import manager from 'src/manager'; import { ChangePositionCommand } from 'src/commands';buttonElem.addEventListener('click', () => {const command = new ChangePositionCommand('left', 10);manager.addCommand(command); });

狀態(tài)管理者只關心數據變更和操作命令對象治理

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

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

模式進階:數據快照式

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

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

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

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

這些要求不難理解,既然要產生數據快照,集中管理才會更加便利。基于這些要求,我選擇了市面上較為流行的 Redux 來作為狀態(tài)管理器。

狀態(tài)數據結構設計

按照上面的模型圖,Redux 的 state 可以設計成:

const state = {timeline: [],current: -1,limit: 1000, };

代碼中,各個屬性的含義為:

  • timeline:存儲數據快照的數組;
  • current:當前數據快照的指針,為 timeline 的索引;
  • limit:規(guī)定了 timeline 的最大長度,防止存儲的數據量過大;

數據快照生成的方式

假設應用初始的狀態(tài)數據為:

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 共享一份數據,所以歷史數據和當前數據都會發(fā)生變化。

方式一:使用深拷貝

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

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

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

const newData = lodash.cloneDeep(data);

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

方式二:構建不可變數據

假設有個對象如下,需要修改第一個 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

這樣的方式只修改了目標屬性節(jié)點的路徑上的引用類型值,其他分支上的值是不變的,這樣節(jié)省了不少內存。為了避免每次都一層一層去修改,可以將這個處理封裝成一個工具函數:

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):這個操作是前文提到的,進行新操作時,應該拋棄當前節(jié)點后的操作,產生一個新的操作分支;
  • timline.slice(-limit):表示只保留最近的 limit 個數據快照;

使用高階 reducer

在實際項目中,通常會使用 combineReducers 來模塊化 reducer,這種情況下,在每個 reducer 中都要重復處理以上的邏輯。這時候就可以使用高階 reducer 函數來抽取公用邏輯:

const highOrderReducer = (reducer) => {return (state, action) => {state = { ...state };const { timeline, current, limit } = state;// 執(zhí)行真實的業(yè)務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;}; }// 真實的業(yè)務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) 來對業(yè)務 reducer 隱藏數據快照隊列的數據結構,使得業(yè)務 reducer 對撤銷重做邏輯無感知,實現功能可拔插。

增強高階 reducer,加入撤銷重做邏輯

撤銷重做時也應該遵循 Redux 的數據修改方式使用 store.dispatch,為:

  • store.dispatch({ type: 'undo' }) ;
  • store.dispatch({ type: 'redo' });

這兩種 action 不應該進入到業(yè)務 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 在組件中獲取狀態(tài)

我在項目中使用的是 React 和 react-redux,由于 state 的數據結構發(fā)生了變化,所以在組件中獲取狀態(tài)的寫法也要相應作出調整:

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]; }

總結

本文圍繞撤銷重做功能實現的講解到此結束,在實現該功能后引入了命令模式來使得代碼結構更加健壯,最后改進成數據快照式,從而讓整個應用架構更加優(yōu)雅。

參考資料

  • 《JavaScript設計模式》Addy Osmani著
  • Redux Documentation
本文發(fā)布自 網易云音樂前端團隊,文章未經授權禁止任何形式的轉載。我們對人才饑渴難耐,快來 加入我們!

總結

以上是默认站点為你收集整理的实现编辑功能有哪几个action_Web 应用的撤销重做实现的全部內容,希望文章能夠幫你解決所遇到的問題。

如果覺得默认站点網站內容還不錯,歡迎將默认站点推薦給好友。