Web 應用的撤銷重作實現

header.png

背景

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

demo.gif

這個製做間頁面有着相似 PPT 的交互:從左側的工具欄中選擇元素放入中間的畫布、在畫布中能夠刪除、操做(拖動、縮放、旋轉等)這些元素。前端

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

功能分析

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

state.png

在進行了某個操做後,用戶有能力回到以前的某個狀態,即撤銷git

undo.png

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

redo.png

當頁面處於某個歷史狀態時,這時用戶進行了某個操做後,這個狀態後面的狀態會被拋棄,此時產生一個新的狀態分支:
branch.pngweb

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

功能初實現

基於以上的分析,實現撤銷重作功能須要實現:設計模式

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

第一步:數據化每個操做

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

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

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

模式進階:數據快照式

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

數據快照式經過保存每次操做後的數據快照,而後在撤銷重作的時候經過歷史快照恢復頁面,模式模型以下:

1.jpeg

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

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

這些要求不難理解,既然要產生數據快照,集中管理纔會更加便利。基於這些要求,我選擇了市面上較爲流行的 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;
    // 執行真實的業務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 對撤銷重作邏輯無感知,實現功能可拔插。

加強高階 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 在組件中獲取狀態

我在項目中使用的是 Reactreact-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];
}

總結

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

參考資料

本文發佈自 網易雲音樂前端團隊,歡迎自由轉載,轉載請保留出處。咱們對人才飢渴難耐,快來 加入咱們
相關文章
相關標籤/搜索