【问题标题】:Implementing undo / redo in Redux在 Redux 中实现撤消/重做
【发布时间】:2016-02-18 05:29:13
【问题描述】:

背景

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

我提出了一个使用command pattern 的解决方案,其中操作通过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

(更完整的例子here

我发现我目前的方法存在几个问题:

  • 由于通过中间件实现,整个应用程序可能只存在一个堆栈。
  • 无法自定义UNDO 命令类型。
  • 创建一个命令来调用操作,然后返回承诺似乎非常复杂。
  • 在操作完成之前将命令添加到堆栈中。错误会发生什么?
  • 由于命令未处于状态,无法添加 is_undoable 功能。
  • 您将如何实施乐观更新?

帮助

那么我的问题是,谁能提出在 Redux 中实现此功能的更好方法?

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

感谢任何见解。

【问题讨论】:

  • 我确实有(不错的小图书馆)。我可以看到如何使用它来帮助解决我的乐观问题,但就像 redux-undo 一样,它只处理减速器内部的反转操作。对于异步撤消,我正在努力将命令堆栈存储在状态中的位置和方式,因为这似乎是它在此类应用程序中存在的最合乎逻辑的位置。
  • 似乎经常吹捧的说法“使用 redux 可以轻松撤消/重做”是半真半假的(充其量)。我发现自己的情况与您相同,需要将服务器状态与 DB/REST API 同步。奇怪的是,redux 文档或相关的 undo/redo 库都没有提到这个极其常见的用例。我敢说大多数设置都必须处理这个问题。我也在寻找一种方法来轻松撤消/重做对我的商店的更改轻松更新我的服务器。我目前的想法是跟踪动作和动作参数,而不是状态变化。这在概念上似乎与您的方法有点相似。

标签: javascript reactjs redux undo-redo


【解决方案1】:

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

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

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

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

优势-

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

【讨论】:

  • 我决定尝试一下这个想法,以更好地权衡实现的样子。我创建了一个演示 here。在这个演示中,我将补丁发送到一个快速驱动的 API,该 API 运行客户端针对本地列表生成的差异。这对于这个简单的用例非常有效。如果我的数据存储在数据库中,我想我需要解析操作才能创建查询。在这种情况下,使用补丁操作比显式操作有什么好处吗?
  • 我担心的另一个问题是相关对象。假设我有一个待办事项列表,并且我允许向它们添加 cmets:在列表中应用补丁是微不足道的。但是对于数据库驱动的数据集,解析补丁似乎变得越来越复杂。你的想法,当然让我暂停了我的纯客户端方法。我确实喜欢这个提倡的简化客户端逻辑。
  • 感谢您试一试。我还没有检查演示,但我最初的想法......你不是先从服务器上的 db 获取文档的完整快照吗?就像您第一次从服务器获取和发送用户待办事项一样。这些补丁只能在客户端和服务器之间使用。服务器始终可以将整个文档更新到数据库。对于小到中等大小的文档,选定编辑所节省的成本与完整更新相比并不多。您会注意到,当客户端与服务器进行频繁且少量的同步时,修补程序设计是有益的。
  • 您可以查看at this 获取 mongoDB 补丁更新。补丁的一个重要方面是它们是一种标准,应该以一种或另一种方式支持大多数流行的 JSON 数据库。
  • @Ashley'CptLemming'Wilson 之后你是怎么做的?你选择了什么方法?你的情况和我的很相似,很感兴趣你是怎么相处的
【解决方案2】:

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

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

在我看来,您的方法的主要问题是使用类并忽略失败的操作(在您的示例中乐观更新过于乐观)。我尝试使用 async generators 解决问题。思路很简单,异步生成器返回的AsyncIterator可以在需要undo时恢复,这基本上意味着你需要dispatch所有中间动作,yield最后的乐观动作和return最后的undo动作.一旦请求撤消,您可以简单地恢复功能并执行撤消所需的一切(应用程序状态突变/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);
};

代码很难理解,所以我决定提供完整的工作example

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

来实现

【讨论】:

  • 这是一个有趣的变化(从未在 javascript 中使用过异步函数或生成器)。给代码一次,我看不出你将如何实现重做?将撤消逻辑隐藏在原始操作中,您可以在撤消逻辑之后添加重做,但您将永远无法再次调用撤消。感谢您设置演示,确实非常有帮助!
  • 我们可以简单地使用strategy described in redux docs 来实现重做,而不是从命令日志中弹出命令。重做基本上意味着再次重新触发原始操作。
  • 我刚刚使用 rxjs github.com/tomkis1/redux-saga-rxjs/blob/master/examples/… 更新了实现(包括撤消/重做/乐观更新)的答案
【解决方案3】:

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

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

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

【讨论】:

  • 据我了解您的建议;您建议向服务器触发create 操作,然后“撤消”您向服务器触发sync 操作的操作?沿着这条路线走,您是否不必将整个应用程序状态发送到服务器并要求它“弄清楚”它需要做什么?您不希望客户端向服务器发送delete 命令吗?
  • 我会让服务器保持整个状态,作为幂等操作。如果您的状态非常大或者您期望有大量流量,这可能会出现问题,在这种情况下,您必须在客户端或服务器上计算两种状态(旧的、当前的)之间的差异,或者维护您为列表中的每个状态所做的操作。无论如何,不​​可变更新都需要特殊操作(例如 React 的 update 方法),因此您可以将其包装在自己的 API 中并在那里执行;
  • 服务器端解决方案与数据使用的耦合似乎比我所接受的要多得多;理想情况下,我会看到一个 API 提供由选择实现撤消/重做的前端客户端使用的数据。我想我希望更多的是解决问题的客户端,而不是把所有的逻辑都转移到服务器上。
  • 也许您可以根据模型中发生的不可变更新的定义创建服务器请求,例如您可能有一个添加用户的操作: var newModel = update(model, { users: { $push: newUser } } 您可能能够从第二个参数创建您的服务器请求。对于撤消,您只需使用相反的操作(弹出而不是推送)。我肯定至少会研究这个方向(不可变模型),因为它是通用的,因为您不需要为每个操作添加带有撤消/重做逻辑的新命令。
  • 另外,如果你选择让你的服务器 API 不是幂等的(发送添加/删除而不是整个集合),请记住操作的顺序很重要,所以你必须有一些逻辑在正确处理任何乱序消息的服务器上。
猜你喜欢
  • 1970-01-01
  • 2015-08-10
  • 2011-11-14
  • 2011-03-10
  • 2012-04-08
  • 2020-08-11
  • 2021-06-04
  • 2012-07-02
  • 2014-04-20
相关资源
最近更新 更多