在 Redux 中实现撤销/重做

IT技术 javascript reactjs redux undo-redo
2021-05-10 09:50:48

背景

一段时间以来,我一直在思考如何在Redux 中通过服务器交互(通过 ajax)实现撤消/重做

我提出了一个使用命令模式的解决方案,其中操作使用executeundo方法注册为命令,而不是调度操作,而是调度命令。然后将命令存储在堆栈中并在需要时引发新操作。

我当前的实现使用中间件来拦截调度、测试命令和调用命令的方法,看起来像这样:

中间件

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命令类型。
  • 创建一个命令来调用动作,而动作又返回Promise似乎非常复杂。
  • 命令会在操作完成之前添加到堆栈中。错误会发生什么?
  • 由于命令未处于状态,因此无法添加 is_undoable 功能。
  • 您将如何实现乐观更新?

帮助

那么我的问题是,有人可以提出在 Redux 中实现此功能的更好方法吗?

我现在看到的最大缺陷是在操作完成之前添加的命令,以及向组合添加乐观更新是多么困难。

任何见解表示赞赏。

3个回答

进一步讨论@vladimir-rovensky 建议的基于不可变的实现......

不可变对于客户端撤消重做管理非常有效。您可以自己存储不可变状态的最后“N”个实例,也可以使用像immstruct这样的库为您存储。由于内置于不可变中的实例共享,它不会导致内存开销。

但是,如果您希望保持简单,每次与服务器同步模型可能会很昂贵,因为每次在客户端修改时,您都需要将整个状态发送到服务器。根据状态大小,这不会很好地扩展。

更好的方法是仅将修改发送到服务器。当您最初将其发送给客户端时,您的状态中需要一个“修订”标头。在客户端上对状态所做的每一次其他修改都应该只记录差异并将它们与修订版一起发送到服务器。服务器可以执行差异操作并在差异之后发回新的修订版本和状态校验和。客户端可以根据当前状态校验和验证这一点并存储新修订。差异也可以由服务器存储在其自己的撤消历史中,并标记为修订和校验和。如果需要在服务器上撤消,则可以反转差异以获取状态并可以执行校验和检查。我遇到的不可变的差异库是https://github.com/intelie/immutable-js-diff. 它创建了 RFC-6902 样式的补丁,您可以在服务器状态上使用http://hackersome.com/p/zaim/immpatch执行这些补丁

好处-

  • 简化的客户端架构。服务器同步并没有分散在客户端代码中。每当客户端状态发生变化时,它都可以从您的商店启动。
  • 简单的撤消/重做与服务器同步。无需单独处理不同的客户端状态更改,也就是无需命令堆栈。diff 补丁以一致的方式跟踪几乎任何类型的状态变化。
  • 没有重大事务命中的服务器端撤消历史记录。
  • 验证检查可确保数据一致性。
  • 修订标头允许多客户端同时更新。

您已经提出了最好的解决方案,是的,命令模式是异步撤消/重做的方法。

一个月前,我意识到 ES6 生成器被严重低估了,它可能会给我们带来一些比计算斐波那契数列更好的用例异步撤消/重做就是一个很好的例子。

在我看来,您的方法的主要问题是使用类并忽略失败的操作(在您的示例中乐观更新过于乐观)。我尝试使用async generators解决这个问题 这个想法很简单,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
        }
      };
    }
  }
};

这当然需要能够处理异步生成器的中间件:

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);
};

代码很难理解,所以我决定提供完整的示例

更新:我目前正在研究 redux-saga 的 rxjs 版本,也可以通过使用 observables https://github.com/tomkis1/redux-saga-rxjs/blob/master/examples/undo-redo-optimistic/src 来实现/sagas/commandSaga.js

不确定我是否完全理解您的用例,但在我看来,在 ReactJS 中实现撤消/重做的最佳方法是通过不可变模型。一旦您的模型是不可变的,您就可以轻松维护状态列表,因为它们会发生变化。具体来说,您需要一个撤消列表和一个重做列表。在您的示例中,它将类似于:

  1. 起始计数器值 = 0 -> [0], []
  2. 添加 5 -> [0, 5], []
  3. 添加 10 -> [0, 5, 15], []
  4. 撤消 -> [0, 5], [15]
  5. 重做 -> [0, 5, 15], []

第一个列表中的最后一个值是当前状态(进入组件状态)。

这是一种比命令简单得多的方法,因为您不需要为要执行的每个操作单独定义撤消/重做逻辑。

如果您需要与服务器同步状态,您也可以这样做,只需将您的 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() };

事实上,我相信您应该能够通过这种方法实现您列出的所有目标。如果您有不同的看法,您能否更具体地说明您需要做什么?