Реализация отмены повтора для веб-приложений

Redux
Реализация отмены повтора для веб-приложений

задний план

Не так давно я участвовал в разработке веб-приложения в команде, и одна из операций со страницей показана на следующем рисунке:

demo.gif

Эта производственная страница имеет взаимодействие, аналогичное PPT: выбрав элемент на панели инструментов слева, чтобы поместить его в середину холста, можно удалить, управлять (перетаскивать, масштабировать, вращать и т. д.) этими элементами на холсте.

В процессе редактирования, позволяя пользователям работатьотменить повторитьЭто повысит эффективность редактирования и значительно улучшит взаимодействие с пользователем, и эта статья посвящена исследованию и обобщению реализации этой функции.

Функциональный анализ

Серия действий пользователя изменит состояние страницы:

state.png

После выполнения операции пользователь имеет возможность вернуться в предыдущее состояние, т.е.отозвать:

undo.png

После отмены операции пользователь имеет возможность снова возобновить операцию, т.е.переделывать:

redo.png

Когда страница находится в определенном историческом состоянии, после того, как пользователь выполнит определенную операцию, состояние, стоящее за этим состоянием, будет отброшено, и в это время будет сгенерирована новая ветвь состояния:

branch.png

Затем приступайте к реализации этой логики.

Начальная реализация функции

Исходя из приведенного выше анализа, для реализации функции отмены-повтора необходимо реализовать:

  • Сохраняйте каждое действие пользователя;
  • Разработайте соответствующую логику отмены для каждой операции;
  • Реализовать логику отмены повторов;

Шаг 1: Датаизируйте каждую операцию

Изменение состояния, вызванное операцией, может быть описано на языке, как показано на следующем рисунке, на странице есть абсолютное позиционирование.divс однимbutton, каждый кликbuttonпозволитdivдвигаться вправо10px. Это действие щелчка можно описать как:divатрибут стиляleftУвеличивать10px.

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данные о состоянии приложения,paramsдляaction.params.

Шаг 2: Напишите логику отмены, соответствующую операции

Структура функции отмены аналогична функции выполнения, и она также должна иметь возможность получитьdataа такжеaction:

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

так,actionДизайн должен удовлетворять логике как функции выполнения, так и функции отмены.

Шаг 3: отменить и повторить обработку

вышеупомянутый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 + Execute Function + Undo Function = Action Command Object

чтобы решитьДецентрализованное управлениепроблема, вы можете поставить операцию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действовать в качестве государственного управляющего.

Проект структуры данных состояния

Согласно приведенной выше схеме модели, ReduxstateМожет быть оформлен как:

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Поделитесь частью данных, чтобы изменились как исторические, так и текущие данные.

Способ 1: используйте глубокую копию

Самый простой способ реализовать глубокую копию — использовать нативные методы объектов JSON:

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

Или с помощью некоторых инструментов, таких как lodash:

const newData = lodash.cloneDeep(data);

Однако глубокое копирование может вызвать проблемы с бесконечным циклом, вызванные циклическими ссылками, а глубокое копирование будет копировать каждый узел, что приводит к ненужной потере производительности.

Способ 2: создание неизменяемых данных

Предположим, есть объект следующим образом, вам нужно изменить первый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Исходный код:GitHub.com/становление персонажем/чулок-я…

Логика обработки снимков данных

выполнить операцию,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-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];
}

Суммировать

На этом объяснение реализации функции отмены-повтора в этой статье заканчивается.После реализации этой функции вводится командный режим, чтобы сделать структуру кода более надежной, и, наконец, он улучшается до типа моментального снимка данных, который делает всю архитектуру приложения более элегантной.

использованная литература

Эта статья была опубликована сКоманда внешнего интерфейса NetEase Cloud Music, Любое несанкционированное воспроизведение статьи запрещено. Мы жаждем талантов, давайПрисоединяйтесь к нам!