задний план
Не так давно я участвовал в разработке веб-приложения в команде, и одна из операций со страницей показана на следующем рисунке:
Эта производственная страница имеет взаимодействие, аналогичное PPT: выбрав элемент на панели инструментов слева, чтобы поместить его в середину холста, можно удалить, управлять (перетаскивать, масштабировать, вращать и т. д.) этими элементами на холсте.
В процессе редактирования, позволяя пользователям работатьотменить повторитьЭто повысит эффективность редактирования и значительно улучшит взаимодействие с пользователем, и эта статья посвящена исследованию и обобщению реализации этой функции.
Функциональный анализ
Серия действий пользователя изменит состояние страницы:
После выполнения операции пользователь имеет возможность вернуться в предыдущее состояние, т.е.отозвать:
После отмены операции пользователь имеет возможность снова возобновить операцию, т.е.переделывать:
Когда страница находится в определенном историческом состоянии, после того, как пользователь выполнит определенную операцию, состояние, стоящее за этим состоянием, будет отброшено, и в это время будет сгенерирована новая ветвь состояния:
Затем приступайте к реализации этой логики.
Начальная реализация функции
Исходя из приведенного выше анализа, для реализации функции отмены-повтора необходимо реализовать:
- Сохраняйте каждое действие пользователя;
- Разработайте соответствующую логику отмены для каждой операции;
- Реализовать логику отмены повторов;
Шаг 1: Датаизируйте каждую операцию
Изменение состояния, вызванное операцией, может быть описано на языке, как показано на следующем рисунке, на странице есть абсолютное позиционирование.div
с однимbutton
, каждый кликbutton
позволитdiv
двигаться вправо10px
. Это действие щелчка можно описать как:div
атрибут стиляleft
Увеличивать10px
.
Очевидно, 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({});
Такой паттерн уже может сделать код проекта надежным, и выглядит он хорошо, но может ли он быть лучше?
Расширенный режим: моментальный снимок данных
Командный режим требует от разработчиков разработки дополнительной функции отмены для каждой операции, что, несомненно, хлопотно. Метод моментальных снимков данных, который будет представлен далее, призван устранить этот недостаток.
Тип моментального снимка данных сохраняет моментальный снимок данных после каждой операции, а затем восстанавливает страницу с помощью исторического моментального снимка при отмене повтора.Модель режима выглядит следующим образом:
Для использования этого режима существуют требования:
- Данные о состоянии приложения должны управляться централизованно и не должны быть разбросаны по различным компонентам;
- В процессе изменения данных существует единое место для хранения моментальных снимков данных;
Эти требования несложно понять, поскольку необходимо создавать моментальные снимки данных, централизованное управление будет более удобным. Исходя из этих требований, я выбрал наиболее популярные на рынке.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];
}
Суммировать
На этом объяснение реализации функции отмены-повтора в этой статье заканчивается.После реализации этой функции вводится командный режим, чтобы сделать структуру кода более надежной, и, наконец, он улучшается до типа моментального снимка данных, который делает всю архитектуру приложения более элегантной.
использованная литература
- Шаблоны дизайна JavaScript от Эдди Османи
- Redux Documentation
Эта статья была опубликована сКоманда внешнего интерфейса NetEase Cloud Music, Любое несанкционированное воспроизведение статьи запрещено. Мы жаждем талантов, давайПрисоединяйтесь к нам!