background

Not long ago, I was involved in the development of a Web application on my team. One of the page operations is shown below:

The interproduction page has a PpT-like interaction: select elements from the left toolbar and place them on the middle canvas, where you can delete and manipulate them (drag, scale, rotate, etc.).

In the process of editing, allowing users to undo and redo operations will improve editing efficiency and greatly improve user experience, and this article is to talk about the exploration and summary of the implementation of this function.

Functional analysis

A series of user actions will change the state of the page:

After performing an action, the user has the ability to return to a previous state, namely undo:

After an action is undone, the user has the ability to redo the action again:

When a page is in a historical state, after the user performs an action, the state behind the state is discarded and a new state branch is generated:

Now, start implementing the logic.

Initial implementation of function

Based on the above analysis, the undo redo function needs to be implemented:

  • Save each action of the user;
  • Design an undo logic for each operation.
  • Implement undo redo logic;

Step 1: Datalize each operation

The state change caused by the operation can be described in language, as shown in the picture below. On the page, there is an absolutely positioned DIV and a button. Each click on the button moves the DIV 10px to the right. This click can be described as adding 10px to the div style attribute left.

Obviously, JavaScript does not recognize this description and needs to translate it into a language that JavaScript knows:

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

The above code uses the variable name to indicate the specific name of the operation, and Params stores the specific data for the operation. JavaScript still doesn’t know how to use this, but it needs an execution function to specify how to use the data above:

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

Data is the application status data, and params is action.params.

Step 2: Write the undo logic corresponding to the operation

The undo function has a similar structure to the execute function, and should also get data and action:

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

Therefore, actions should be designed to satisfy the logic of both executing and destroying functions.

Step 3: Undo, redo processing

The action, execute, and undo functions as a whole describe an operation, so they should all be stored.

The binding is based on convention: the name of the execute function is equal to the name of the action, and the name of the Undo function is equal to name + ‘Undo’, so you only need to store the action, and implicitly store the execute and Undo functions as well.

Write a global module that holds functions, states, etc. : SRC /manager.js

constfunctions = { changePosition(state, params) {... }, changePositionUndo(state, params) {... }};export default {
    data: {},
    actions: [].undoActions: [],
    getFunction(name) {
        returnfunctions[name]; }};Copy the code

So, clicking the button produces a new action, and we need to do three things:

  • storage-operatedaction;
  • Perform this operation.
  • If the node is in history, a new operation branch needs to be generated.
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 = []; }});Copy the code

Where undoActions is the action for the action that was undone, clear this action to indicate the action after the current node is abandoned. Store the action in manager.actions so that when you need to undo an action, you can simply retrieve the last action in manager.actions, find the corresponding undo function and execute it.

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);
}
Copy the code

When you need to do it again, take the last action in manager.undoActions, find the corresponding action and execute it.

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);
}
Copy the code

Pattern optimization: Command mode

The above code can be said to have basically satisfied the functional requirements, but in my opinion there are still some problems:

  • Decentralized management: of an operationaction, execute function, undo function separate management. Maintenance will be difficult as the project becomes larger and larger;
  • Unclear responsibilities: It is not clear whether execution functions, undo functions, and state changes should be assigned to business components or to global managers, which is not good for reuse of components and operations;

To effectively solve the above problem, I needed to find a suitable new mode to organize the code, and I chose command mode.

Introduction to Command Mode

Simply put, the command mode encapsulates methods and data into a single object, decouples the caller and the executor, and achieves the purpose of responsibility separation.

Take the example of a customer eating in a restaurant:

  • When ordering, customers choose what they want to eat and submit an order
  • The chef receives the order and cooks according to it

During this period, customers and cooks do not meet and talk to each other, but form a connection through an order, which is a command object, such interaction mode is the command mode.

Action + execute function + undo function = manipulate command object

To solve the problem of decentralized management, the action, execute function and undo function of an operation can be encapsulated as a command object as a whole:

class ChangePositionCommand {
    constructor(property, distance) {
        this.property = property; / / such as: 'left'
        this.distance = distance; / / such as: 10
    }

    execute(state) {
        constnewState = { ... state } newState[this.property] += this.distance;
        return newState;
    }

    undo(state) {
        constnewState = { ... state } newState[this.property] -= this.distance;
        returnnewState; }}Copy the code

Business components are only interested in generating and sending command objects

There are often side effects associated with state data processing. This logic coupled to the data can greatly reduce component reusability. As a result, the business component does not care about the modification process of the data and instead focuses on its responsibility to generate action command objects and send them to the state manager.

import manager from 'src/manager';
import { ChangePositionCommand } from 'src/commands';

buttonElem.addEventListener('click', () = > {const command = new ChangePositionCommand('left'.10);
    manager.addCommand(command);
});
Copy the code

The state manager only cares about data changes and operation command object governance

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 = []; // Create a new branch
    }

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

This pattern already makes the project code robust, which looks good, but is it better?

Advanced mode: Data snapshot mode

The command pattern requires developers to develop an additional undo function for every operation, which can be troublesome. The next data snapshot is designed to address this shortcoming.

Data snapshot Saves the data snapshot after each operation and then restores the page using the historical snapshot when the undo redo is performed. The mode is as follows:

There are requirements to use this pattern:

  • Application state data needs to be centrally managed, not scattered among components.
  • There is a unified place for data snapshot storage in the data change process;

These requirements are easy to understand, since centralized management is more convenient if snapshots of the data are to be generated. Based on these requirements, I chose Redux, the popular state manager on the market.

State data structure design

According to the model figure above, the state of Redux can be designed as:

const state = {
    timeline: [].current: - 1.limit: 1000};Copy the code

In the code, the meanings of each attribute are:

  • timeline: an array that stores data snapshots;
  • current: Indicates the pointer of the current data snapshottimelineThe indexes;
  • limit: specifiestimelineTo prevent the storage of too much data;

Data snapshot generation mode

Assume that the initial application state data is:

const data = { left: 100 };
const state = {
    timeline: [data],
    current: 0.limit: 1000};Copy the code

After performing an operation, left increases by 100. Some novices might do this directly:

cont newData = data;
newData.left += 100;
state.timeline.push(newData);
state.current += 1;
Copy the code

This is obviously wrong because JavaScript objects are reference types, variable names just hold their references, and the real data is stored in heap memory, so data and newData share a copy of data, so both historical and current data change.

Method 1: Use deep copy

The simplest way to implement deep copy is to use the JSON object’s native method:

const newData = JSON.parse(JSON.stringify(data));
Copy the code

Or, use tools like LoDash:

const newData = lodash.cloneDeep(data);
Copy the code

However, deep copy can have the problem of endless loops caused by circular references, and deep copy copies every node, which incurs unnecessary performance costs.

Method 2: Build immutable data

Suppose we have an object like this and need to change the width of the first Component to 200:

const state = {
    components: [{type: 'rect'.width: 100.height: 100 },
        { type: 'triangle': width: 100.height: 50}}]Copy the code

The path of the target property in the object tree is: [‘components’, 0, ‘width’]. Some of the data in this path is a reference type. In order not to change the shared data, this reference type should be changed to a new reference type, as follows:

constnewState = { ... state }; newState.components = [...state.components]; newState.components[0] = { ...state.components[0]};Copy the code

At this point, you can change the target value with confidence:

newState.components[0].width = 200;
console.log(newState.components[0].width, state.components[0].width); / / 200, 100
Copy the code

In this way, only the reference type values on the path of the target attribute node are modified, and the values on the other branches are unchanged, which saves a lot of memory. To avoid changing it layer by layer each time, encapsulate the processing as a utility function:

const newState = setIn(state, ['components'.0.'width'].200)
Copy the code

SetIn source: github.com/cwajs/cwa-i…

Data snapshot processing logic

The reducer code is:

function operationReducer(state, action) { state = { ... state };const { current, limit } = state;
    constnewData = ... ;// omit the process
    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;
}
Copy the code

There are two places to explain:

  • timline.slice(0, current + 1): This operation is mentioned above. When performing a new operation, the operation after the current node should be discarded and a new operation branch should be created.
  • timline.slice(-limit): Indicates that only the latest ones are reservedlimitData snapshot;

Use the high order Reducer

In actual projects, combineReducers are usually used to modularize the Reducer. In this case, the above logic needs to be repeated in each Reducer. In this case, the high order Reducer function can be used to extract common logic:

const highOrderReducer = (reducer) = > {
  return (state, action) = >{ state = { ... state };const { timeline, current, limit } = state;
    // Perform real services 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;
  };
}

// Real business reducer
function reducer(state, action) {
    switch (action.type) {
        case 'xxx': newState = ... ;returnnewState; }}const store = createStore(highOrderReducer(reducer), initialState);
Copy the code

This high order Reducer uses const newState = reducer(Timeline [current], action) to hide the data structure of the data snapshot queue from the business reducer, so that the business reducer is not aware of the undo redo logic. The function can be pluggable.

Enhance high-level Reducer and add undo redo logic

Undoing the redo should also follow Redux’s data modification method using store.dispatch, as follows:

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

These two actions should not go to the reducer and need to be intercepted:

const highOrderReducer = (reducer) = > {
  return (state, action) = > {
    // Block undo and redo files
    if (action.type === 'undo') {
        return {
            ...state,
            current: Math.max(0, state.current - 1),}; }// Block undo and redo files
    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;
  };
}
Copy the code

Use react-redux to get state in the component

I used React and react-redux in the project. Since the data structure of state has changed, the way to obtain state in the component has to be adjusted accordingly:

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);
Copy the code

However, writing this way makes the component aware of the undo redo data structure, which clearly contradicts the pluggability mentioned above. I resolved this by overwriting the store.getState method:

const store = createStore(reducer, initialState);

const originGetState = store.getState.bind(store);

store.getState = (. args) = > {
    conststate = originGetState(... args);return state.timeline[state.current];
}
Copy the code

conclusion

This concludes the article on the implementation of undo redo, which introduced command mode to make the code structure more robust, and finally improved to data snapshot to make the entire application architecture more elegant.

The resources

  • JavaScript Design Patterns. By Addy Osmani
  • Redux Documentation

This article is published from netease Cloud music front end team, the article is prohibited to be reproduced in any form without authorization. We are hungry for talent. Join us!