Внедрение отмены/повтора в Redux
Фон
Некоторое время назад я размахивал своим мозгом о том, как вы могли бы использовать undo/redo в Redux с помощью взаимодействия с сервером (через ajax).
Я придумал решение, используя шаблон команды, где действия регистрируются с помощью методов execute
и undo
в качестве команд, а вместо отправки действия, которые вы отправляете командам. Затем команды сохраняются в стеке и при необходимости подготавливают новые действия.
В моей текущей реализации используется промежуточное программное обеспечение для перехвата рассылок, проверка команд и методов вызова команды и выглядит примерно так:
Middleware
let commands = [];
function undoMiddleware({ dispatch, getState }) {
return function (next) {
return function (action) {
if (action instanceof Command) {
// Execute the command
const promise = action.execute(action.value);
commands.push(action);
return promise(dispatch, getState);
} else {
if (action.type === UNDO) {
// Call the previous commands undo method
const command = commands.pop();
const promise = command.undo(command.value);
return promise(dispatch, getState);
} else {
return next(action);
}
}
};
};
}
Действия
const UNDO = 'UNDO';
function undo() {
return {
type: UNDO
}
}
function add(value) {
return (dispatch, getState) => {
const { counter } = getState();
const newValue = counter + value;
return new Promise((resolve, reject) => {
resolve(newValue); // Ajax call goes here
}).then((data) => {
dispatch(receiveUpdate(data));
});
}
}
function sub(value) {
return (dispatch, getState) => {
const { counter } = getState();
const newValue = counter - value;
return new Promise((resolve, reject) => {
resolve(newValue); // Ajax call goes here
}).then((data) => {
dispatch(receiveUpdate(data));
});
}
}
Команды
class Command {
execute() {
throw new Error('Not Implemented');
}
undo() {
throw new Error('Not Implemented');
}
}
class AddCommand extends Command {
constructor(value) {
super();
this.value = value;
}
execute() {
return add(this.value);
}
undo() {
return sub(this.value);
}
}
приложения
const store = createStoreWithMiddleware(appReducer);
store.dispatch(new AddCommand(10)); // counter = 10
store.dispatch(new AddCommand(5)); // counter = 15
// Some time later
store.dispatch(undo()); // counter = 10
(более полный пример здесь)
Есть несколько проблем, которые я нашел с моим текущим подходом:
- Благодаря внедрению через промежуточное программное обеспечение для всего приложения может существовать только один стек.
- Невозможно настроить тип команды
undo
.
- Создание команды для вызова действий, которые в свою очередь возвращают promises, кажется очень запутанным.
- Команды добавляются в стек до завершения действия. Что происходит с ошибками?
- Поскольку команды не находятся в состоянии, нельзя добавлять функции is_undoable.
- Как реализовать оптимистичные обновления?
Справка
Мой вопрос, может ли кто-нибудь предложить лучший способ реализовать эту функциональность в Redux?
Самые большие недостатки, которые я вижу сейчас, - это команды, добавляемые до завершения действий, и как было бы сложно добавить оптимистические обновления в микс.
Любое понимание понимается.
Ответы
Ответ 1
Дальнейшая дискуссия о внедрении на основе неизменяемости, предложенном @vladimir-rovensky...
Неизменяемый работает очень хорошо для управления отменой клиентской стороны. Вы можете просто сохранить последние "N" экземпляры неизменяемого состояния либо самостоятельно, либо используя библиотеку, например immstruct, которая делает это для вас. Это не приводит к нехватке памяти из-за совместного использования экземпляра, встроенного в неизменяемый.
Однако синхронизация модели каждый раз с сервером может оказаться дорогостоящей, если вы хотите сохранить ее просто, потому что вам нужно будет отправлять все состояние на сервер каждый раз, когда он изменяется на клиенте. В зависимости от размера штата это не будет хорошо масштабироваться.
Лучшим подходом будет отправка только изменений на сервер. Вам нужно заголовок "ревизии" в вашем состоянии, когда вы сначала отправляете его клиенту. Каждая другая модификация состояния, выполняемого на клиенте, должна записывать только различия и отправлять их на сервер с ревизией. Сервер может выполнять операции diff и отправлять обратно новую ревизию и контрольную сумму состояния после различий. Клиент может проверить это на текущей контрольной сумме состояния и сохранить новую ревизию. Различия также могут быть сохранены сервером, помеченным ревизией и контрольной суммой в своей собственной истории отмены. Если на сервере требуется отменить отмену, разности могут быть отменены, чтобы получить состояние, и проверки контрольной суммы могут быть выполнены.
Разная библиотека для неизменяемости, с которой я столкнулся, https://github.com/intelie/immutable-js-diff. Он создает исправления стиля RFC-6902, которые вы можете выполнить с помощью http://hackersome.com/p/zaim/immpatch в состоянии сервера.
<сильные > Преимущества -
- Упрощенная клиентская архитектура. Синхронизация сервера не разбросана по всему клиентскому коду. Он может быть инициирован из ваших магазинов всякий раз, когда изменяется состояние клиента.
- Простая отмена/повтор синхронизации с сервером. Нет необходимости обрабатывать разные изменения состояния клиента отдельно, иначе нет командных стеков. Патч diff отслеживает практически любые изменения состояния согласованным образом.
- Отменить историю сервера на сервере без серьезных сбоев транзакций.
- Проверки проверки обеспечивают согласованность данных.
- Заголовок версии позволяет одновременное обновление нескольких клиентов.
Ответ 2
Вы придумали наилучшее возможное решение, да Command Pattern - это путь для async undo/redo.
Месяц назад я понял, что генераторы ES6 весьма недооценены и могут принести нам несколько лучше варианты использования, чем вычислять последовательность фибоначчи. Async undo/redo - отличный пример.
По моему мнению, основная проблема с вашим подходом - использование классов и игнорирование провальных действий (оптимистическое обновление слишком оптимистично в вашем примере). Я попытался решить проблему, используя генераторы async. Идея довольно проста: AsyncIterator
, возвращаемый асинхронным генератором, может быть возобновлен, когда требуется отменить, это в основном означает, что вам нужно dispatch
все промежуточные действия, yield
окончательное оптимистическое действие и return
окончательное действие отмены, После запроса отмены вы можете просто возобновить функцию и выполнить все, что необходимо для отмены (мутации состояния приложения/вызовы api/побочные эффекты). Другой yield
означает, что действие не было успешно отменено, и пользователь может попробовать снова.
Хорошая вещь в этом подходе заключается в том, что то, что вы моделировали экземпляром класса, фактически решается с более функциональным подходом и закрытием функции.
export const addTodo = todo => async function*(dispatch) {
let serverId = null;
const transientId = `transient-${new Date().getTime()}`;
// We can simply dispatch action as using standard redux-thunk
dispatch({
type: 'ADD_TODO',
payload: {
id: transientId,
todo
}
});
try {
// This is potentially an unreliable action which may fail
serverId = await api(`Create todo ${todo}`);
// Here comes the magic:
// First time the `next` is called
// this action is paused exactly here.
yield {
type: 'TODO_ADDED',
payload: {
transientId,
serverId
}
};
} catch (ex) {
console.error(`Adding ${todo} failed`);
// When the action fails, it does make sense to
// allow UNDO so we just rollback the UI state
// and ignore the Command anymore
return {
type: 'ADD_TODO_FAILED',
payload: {
id: transientId
}
};
}
// See the while loop? We can try it over and over again
// in case ADD_TODO_UNDO_FAILED is yielded,
// otherwise final action (ADD_TODO_UNDO_UNDONE) is returned
// and command is popped from command log.
while (true) {
dispatch({
type: 'ADD_TODO_UNDO',
payload: {
id: serverId
}
});
try {
await api(`Undo created todo with id ${serverId}`);
return {
type: 'ADD_TODO_UNDO_UNDONE',
payload: {
id: serverId
}
};
} catch (ex) {
yield {
type: 'ADD_TODO_UNDO_FAILED',
payload: {
id: serverId
}
};
}
}
};
Конечно, это потребует промежуточного программного обеспечения, которое может обрабатывать генераторы async:
export default ({dispatch, getState}) => next => action => {
if (typeof action === 'function') {
const command = action(dispatch);
if (isAsyncIterable(command)) {
command
.next()
.then(value => {
// Instead of using function closure for middleware factory
// we will sned the command to app state, so that isUndoable
// can be implemented
if (!value.done) {
dispatch({type: 'PUSH_COMMAND', payload: command});
}
dispatch(value.value);
});
return action;
}
} else if (action.type === 'UNDO') {
const commandLog = getState().commandLog;
if (commandLog.length > 0 && !getState().undoing) {
const command = last(commandLog);
command
.next()
.then(value => {
if (value.done) {
dispatch({type: 'POP_COMMAND'});
}
dispatch(value.value);
dispatch({type: 'UNDONE'});
});
}
}
return next(action);
};
Код довольно сложно выполнить, поэтому я решил предоставить полностью рабочий пример
UPDATE:
В настоящее время я работаю над версией redux-саги rxjs, и реализация также возможна с помощью наблюдаемых https://github.com/tomkis1/redux-saga-rxjs/blob/master/examples/undo-redo-optimistic/src/sagas/commandSaga.js
Ответ 3
Не уверен, что я полностью понимаю ваш вариант использования, но, на мой взгляд, лучший способ реализовать отмену/повтор в ReactJS - это immutable модель. Когда ваша модель неизменна, вы можете легко поддерживать список состояний по мере их изменения. В частности, вам нужен список отмены и список повторов. В вашем примере это будет что-то вроде:
- Начальное значение счетчика = 0 → [0], []
- Добавить 5 → [0, 5], []
- Добавить 10 → [0, 5, 15], []
- Отменить → [0, 5], [15]
- Повторить → [0, 5, 15], []
Последнее значение в первом списке - текущее состояние (которое переходит в состояние компонента).
Это гораздо более простой подход, чем Commands, поскольку вам не нужно отдельно определять логику отмены/повтора для каждого действия, которое вы хотите выполнить.
Если вам нужно синхронизировать состояние с сервером, вы также можете это сделать, просто отправьте ваши запросы AJAX как часть операции отмены/повтора.
Оптимистичные обновления также должны быть возможны, вы можете немедленно обновить свое состояние, затем отправить свой запрос и обработчик ошибок, вернуться к состоянию до изменения. Что-то вроде:
var newState = ...;
var previousState = undoList[undoList.length - 1]
undoList.push(newState);
post('server.com', buildServerRequestFrom(newState), onSuccess, err => { while(undoList[undoList.length-1] !== previousState) undoList.pop() };
На самом деле я считаю, что вы должны иметь возможность достичь всех целей, перечисленных вами при таком подходе. Если вы чувствуете обратное, можете ли вы более конкретно о том, что вам нужно сделать?