Javascript 在Redux中实现撤销/重做 背景
一段时间以来,我一直在绞尽脑汁研究如何在服务器交互中实现撤销/重做(通过ajax) 我提出了一个解决方案,使用where将操作注册为Javascript 在Redux中实现撤销/重做 背景,javascript,reactjs,redux,undo-redo,Javascript,Reactjs,Redux,Undo Redo,一段时间以来,我一直在绞尽脑汁研究如何在服务器交互中实现撤销/重做(通过ajax) 我提出了一个解决方案,使用where将操作注册为execute和undo方法作为命令,而不是分派操作,而是分派命令。命令然后存储在堆栈中,并在需要时引发新操作 我当前的实现使用中间件来截获调度、测试命令和调用命令的方法,如下所示: 中间件 行动 命令 应用程序 (一个更完整的例子) 我发现我目前的方法存在几个问题: 由于通过中间件实现,整个应用程序可能只存在一个堆栈 无法自定义UNDO命令类型 创建一个命令来调
execute
和undo
方法作为命令,而不是分派操作,而是分派命令。命令然后存储在堆栈中,并在需要时引发新操作
我当前的实现使用中间件来截获调度、测试命令和调用命令的方法,如下所示:
中间件
行动
命令
应用程序
(一个更完整的例子)
我发现我目前的方法存在几个问题:
- 由于通过中间件实现,整个应用程序可能只存在一个堆栈
- 无法自定义
命令类型UNDO
- 创建一个命令来调用相应的操作,从而返回承诺似乎非常复杂
- 命令在操作完成之前添加到堆栈中。错误会发生什么
- 由于命令未处于状态,因此无法添加可撤消功能
- 您将如何实现乐观更新
非常感谢您的任何见解。我不确定是否完全理解您的用例,但我认为在ReactJS中实现撤销/重做的最佳方式是通过模型。一旦您的模型是不可变的,您就可以在状态发生变化时轻松地维护状态列表。具体来说,您需要一个撤销列表和重做列表。在您的示例中,它类似于:
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() };
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() };
事实上,我相信你应该能够实现你用这种方法列出的所有目标。如果你不这么认为,你能更具体地说明你需要做什么吗?进一步讨论@vladimir rovensky建议的基于不变的实现… Immutable非常适合客户端撤消重做管理。您可以自己存储不可变状态的最后“N”个实例,也可以使用类似的库来存储不可变状态的最后“N”个实例。由于实例共享内置在不可变内存中,所以不会导致内存开销 但是,如果您希望保持模型的简单性,那么每次将模型与服务器同步可能会花费高昂的成本,因为每次在客户端上修改模型时,您都需要将整个状态发送到服务器。根据州大小,这将无法很好地扩展 更好的方法是只向服务器发送修改。当您最初将“修订”标题发送到客户端时,您的状态中需要一个“修订”标题。在客户机上对状态进行的每一次其他修改都应该只记录差异,并将其与修订一起发送到服务器。服务器可以执行diff操作并发回diff之后状态的新修订和校验和。客户端可以根据当前状态校验和来验证这一点,并存储新版本。差异也可以由服务器在其自己的撤消历史记录中存储,并标记有修订和校验和。如果需要在服务器上撤消,则可以反转差异以获取状态,并可以执行校验和检查。 我遇到的一个不可变的diffing库是。它创建RFC-6902样式的修补程序,您可以在服务器状态下使用这些修补程序执行 优势-
- 简化的客户端架构。服务器同步不会分散在客户端代码中。只要客户机状态发生变化,就可以从您的存储启动它
- 简单的撤消/重做与服务器同步。不需要单独处理不同的客户端状态更改,也就是说,不需要命令堆栈。diff补丁以一致的方式跟踪几乎任何类型的状态变化
- 服务器端撤消历史记录,无重大事务命中
- 验证检查确保数据一致性
- 修订标题允许多客户端同时更新
- 您已经找到了最好的解决方案,是的,命令模式是异步撤消/重做的方式
一个月前,我意识到ES6生成器被低估了,可能会给我们带来比计算斐波那契序列更好的结果。异步撤消/重做就是一个很好的例子
在我看来,您的方法的主要问题是使用类和忽略失败的操作(在您的示例中,乐观更新过于乐观)。我尝试使用异步生成器解决这个问题。想法很简单,异步生成器返回的
asyncierator
可以在需要撤销时恢复,这基本上意味着您需要dispatch
所有中间操作,yield
最终乐观操作和return
最终撤销操作。请求撤销后,您只需恢复函数并执行撤销所需的所有操作(应用程序状态变化/api调用/副作用)。另一个收益率
意味着
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
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() };
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);
};