首页 > web前端 > js教程 > 正文

如何设计一个支持撤销重做的状态管理系统?

狼影
发布: 2025-09-20 19:59:01
原创
383人浏览过
答案:设计撤销重做系统需选择状态快照或命令模式,结合历史栈管理,限制深度、合并操作,并与Redux/Vuex集成。

如何设计一个支持撤销重做的状态管理系统?

设计一个支持撤销重做的状态管理系统,核心在于维护一套状态或操作的历史记录,并能灵活地在这些记录间穿梭。这听起来有点像时间旅行,但本质上就是把每一次关键的状态变更都“存档”起来,需要的时候再“读档”回来,或者“快进”到未来的某个点。

要实现撤销重做,我们通常会用到两种主要思路,或者说模式:一是记录完整的状态快照,二是记录引起状态变化的操作指令。在我看来,这两种方法各有千秋,没有绝对的优劣,关键看你的应用场景。但无论哪种,其背后都离不开“历史”的概念。想象一下,我们有两个栈:一个叫“过去栈”(undoStack),用来存放所有可以撤销的状态或操作;另一个叫“未来栈”(redoStack),用来存放那些被撤销后又可以重做的状态或操作。

当用户执行一个改变应用状态的操作时(比如,在文本编辑器里输入一个字符,或者在绘图软件里画一条线),我们首先会把这个操作发生前的状态(或者这个操作本身)推入“过去栈”。这时候,“未来栈”就得清空了,因为任何新的操作都会开启一条新的时间线,之前的“未来”就作废了。

当用户点击“撤销”时,我们从“过去栈”里弹出一个记录,然后把当前的状态(也就是撤销前的状态)推入“未来栈”,接着将应用状态恢复到从“过去栈”弹出的那个记录所指示的状态。

而当用户点击“重做”时,逻辑就反过来了。我们从“未来栈”里弹出一个记录,把当前状态推入“过去栈”,然后将应用状态恢复到从“未来栈”弹出的那个记录所指示的状态。

说实话,这玩意儿设计起来,细节可不少。比如,究竟是存整个状态快照好,还是只存操作指令好?这其实是个权衡。

选择存储完整状态还是操作指令,哪种方式更适合我的应用场景?

这确实是设计撤销重做系统时最先要考虑的问题,也是最让人纠结的地方。在我看来,这两种策略各有其适用场景,没有“一招鲜吃遍天”的方案。

存储完整状态快照(Memento Pattern,备忘录模式)

这种方式比较直观。每次状态发生变化时,我们都把当前完整的应用状态“拍个照”,然后把这张“照片”存到历史栈里。当需要撤销时,直接取出上一张照片覆盖当前状态就行了。

  • 优点:
    • 实现简单直接: 特别是对于状态结构相对扁平或变化不那么频繁的应用,你不需要关心具体是哪个操作导致了变化,只要把状态序列化存起来就行。
    • 鲁棒性强: 即使某个操作的实现有bug,只要状态快照是正确的,撤销回去的状态就一定是可靠的。它不依赖于操作的可逆性。
  • 缺点:
    • 内存消耗大: 如果你的应用状态非常庞大,每次都存一个完整副本,那内存很快就会爆掉。想象一个大型图形编辑器,每次都存整个画布的像素数据,那简直是灾难。
    • 性能开销: 序列化和反序列化大状态对象本身就是个耗时操作,频繁的存取可能会导致应用卡顿。
    • 不适合异步操作: 如果状态变化涉及异步副作用,简单的状态恢复可能无法正确处理这些副作用。

存储操作指令(Command Pattern,命令模式)

这种方式更精细。我们不存储状态本身,而是存储那些能够改变状态的“命令”对象。每个命令对象都封装了执行(

execute
登录后复制
)和撤销(
unexecute
登录后复制
)两种行为。

  • 优点:
    • 内存效率高: 通常一个命令对象比一个完整的状态快照要小得多,大大节省了内存。
    • 粒度更细: 你可以精确控制哪些操作可以撤销,哪些不能。这在需要组合多个小操作为一个可撤销单元时非常有用(比如文本编辑器里的一整段输入)。
    • 更易处理异步和副作用: 命令模式可以更好地封装和管理操作的副作用,因为
      unexecute
      登录后复制
      方法可以专门用来撤销这些副作用。
  • 缺点:
    • 实现复杂: 每个可撤销的操作都需要设计对应的命令类,并实现
      execute
      登录后复制
      unexecute
      登录后复制
      方法,这无疑增加了开发成本。而且,
      unexecute
      登录后复制
      的逻辑往往比
      execute
      登录后复制
      更难写,因为它需要知道如何“反向”操作。
    • 依赖操作的可逆性: 如果某个操作本身是不可逆的(比如发送一个不可撤销的网络请求),那么它就很难通过命令模式来撤销。
    • 状态依赖:
      unexecute
      登录后复制
      操作可能依赖于操作发生时的上下文状态,这需要命令对象在创建时捕获足够的信息。

我的看法: 对于大多数前端应用,如果状态不是特别庞大且变化不那么频繁,或者你希望快速上线一个基础的撤销重做功能,那么存储完整状态快照会是更简单的选择。你可以通过JSON.parse(JSON.stringify(state))来快速实现一个深拷贝。

但如果你的应用状态复杂、变化频繁,或者你需要对撤销重做有更细粒度的控制(比如一个复杂的图形编辑工具、代码编辑器),那么命令模式绝对是更专业、更健壮的选择。它虽然前期投入大,但长期来看,维护性和扩展性更好。

有时候,我们也会采取一种混合策略,比如只对部分关键且变化频繁的状态使用命令模式,而对其他不那么重要的部分使用状态快照,或者只存储状态的“差异”(diffs),而不是整个快照。这都取决于具体的业务需求和性能考量。

如何高效管理撤销重做历史,避免内存溢出和性能瓶颈

设计好撤销重做的核心机制后,如何高效管理历史记录就成了下一个大挑战。毕竟,我们不能无限地存储历史,那肯定会把内存吃光。这方面,我通常会从几个角度去考虑。

首先,限制历史深度是必须的。你可以设置一个最大历史记录数,比如50步或者100步。当历史栈的长度超过这个限制时,最旧的那个记录就得被“挤掉”。这就像一个循环队列,但我们用栈来实现。比如,当

undoStack
登录后复制
长度达到上限时,再推入新记录前,先把栈底(最旧的记录)移除。

造物云营销设计
造物云营销设计

造物云是一个在线3D营销设计平台,0基础也能做电商设计

造物云营销设计 37
查看详情 造物云营销设计
class HistoryManager {
    constructor(maxHistory = 50) {
        this.undoStack = [];
        this.redoStack = [];
        this.maxHistory = maxHistory;
    }

    pushState(state) {
        if (this.undoStack.length >= this.maxHistory) {
            this.undoStack.shift(); // 移除最旧的记录
        }
        this.undoStack.push(state);
        this.redoStack = []; // 新操作清空重做栈
    }
    // ... undo/redo 逻辑
}
登录后复制

其次,操作的合并与去抖(Debouncing/Throttling)至关重要。想象一下,用户在文本框里快速输入一串文字。如果每个按键都生成一个独立的撤销记录,那历史栈会瞬间爆炸,而且用户体验也不好,他们可能只想撤销“一句话”而不是“一个字母”。这时,我们可以将连续的、短时间内的类似操作合并成一个单一的撤销单元。例如,在文本编辑器中,可以在用户停止输入一段时间后(比如500ms),将这段时间内的所有字符输入合并为一个“输入操作”记录。

再者,优化状态的序列化与反序列化。如果你的系统选择存储完整的状态快照,那么状态对象的序列化(存入历史栈前)和反序列化(从历史栈取出后)性能会直接影响用户体验。

  • 避免不必要的深拷贝: 有些状态对象可能包含大量不变的数据,你可以考虑只拷贝变化的部分,或者使用结构共享(structural sharing)的不可变数据结构(如Immutable.js),这样每次状态更新只会创建新节点,而共享未变的部分,大大减少内存占用和拷贝开销。
  • 选择高效的序列化方式: 对于复杂对象,
    JSON.stringify
    登录后复制
    JSON.parse
    登录后复制
    可能不是最高效的。可以考虑使用更专业的序列化库,或者针对特定数据结构进行定制化优化。

最后,注意内存泄漏。确保历史栈中的旧状态在被移除后能被垃圾回收机制正确回收。如果你存储的是对大对象的引用,而不是深拷贝,那么即使从栈中移除,只要其他地方还有引用,内存就不会释放。这也是为什么深拷贝或者使用不可变数据结构更安全的原因之一。

在实际开发中,如何将撤销重做功能与现有状态管理框架(如Redux、Vuex)集成?

将撤销重做功能集成到现有的状态管理框架中,比如Redux或Vuex,其实是有成熟套路的。这些框架本身并没有直接提供撤销重做功能,但它们都提供了强大的扩展机制,让我们可以很优雅地实现它。

集成到Redux:

Redux的特点是单一状态树和纯粹的Reducer。这使得它非常适合实现撤销重做。

一种常见且推荐的方式是使用高阶Reducer(Higher-Order Reducer)。你可以创建一个专门处理撤销重做逻辑的Reducer,它不直接管理业务状态,而是包装你的核心业务Reducer。这个高阶Reducer会维护自己的

past
登录后复制
present
登录后复制
future
登录后复制
三个状态切片。

// 伪代码示例
const undoable = (reducer) => {
  const initialState = {
    past: [],
    present: reducer(undefined, {}), // 初始状态
    future: [],
  };

  return function(state = initialState, action) {
    switch (action.type) {
      case 'UNDO':
        const past = state.past.slice(0, state.past.length - 1);
        const present = state.past[state.past.length - 1];
        const future = [state.present, ...state.future];
        return { past, present, future };
      case 'REDO':
        const past = [...state.past, state.present];
        const present = state.future[0];
        const future = state.future.slice(1);
        return { past, present, future };
      default:
        // 对于普通业务action
        const newPresent = reducer(state.present, action);
        if (newPresent === state.present) {
          return state; // 如果状态没变,就不用记录历史
        }
        return {
          past: [...state.past, state.present],
          present: newPresent,
          future: [], // 新操作清空重做栈
        };
    }
  };
};

// 使用方式
const rootReducer = combineReducers({
  // ... 其他reducer
  myFeature: undoable(myFeatureReducer), // 包装你的业务reducer
});
登录后复制

这种方式的好处是,你的业务Reducer不需要关心撤销重做的逻辑,保持了纯粹性。社区也有像

redux-undo
登录后复制
这样的库,它们就是基于这种思想实现的,用起来非常方便。

另一种方式是使用Redux中间件。你可以编写一个中间件,在每个action被dispatch后、Reducer处理前,或者Reducer处理后,拦截并记录状态。但我个人觉得,高阶Reducer在管理

past
登录后复制
/
present
登录后复制
/
future
登录后复制
状态上,逻辑会更清晰一些。

集成到Vuex:

Vuex的状态是响应式的,其核心是

state
登录后复制
mutations
登录后复制
actions
登录后复制
getters
登录后复制

在Vuex中实现撤销重做,一个比较自然的方式是利用Vuex的插件系统。Vuex插件可以订阅(subscribe)到每次

mutation
登录后复制
的提交。这意味着你可以在每次状态发生变化时,捕获当前的状态快照或者触发该变化的
mutation
登录后复制
信息。

// 伪代码示例
const undoRedoPlugin = store => {
  let history = [];
  let historyIndex = -1;
  let isUndoingRedoing = false; // 标记是否是撤销/重做操作,避免无限循环

  store.subscribe((mutation, state) => {
    if (!isUndoingRedoing && mutation.type !== 'undo' && mutation.type !== 'redo') {
      // 存储状态快照(深拷贝)
      const currentState = JSON.parse(JSON.stringify(state));
      history = history.slice(0, historyIndex + 1); // 新操作清空未来历史
      history.push(currentState);
      historyIndex++;
      // 限制历史深度
      if (history.length > 50) {
        history.shift();
        historyIndex--;
      }
    }
  });

  store.registerModule('undoRedo', {
    namespaced: true,
    state: {}, // 这个模块本身不需要太多状态
    mutations: {
      undo(state) {
        if (historyIndex > 0) {
          isUndoingRedoing = true;
          historyIndex--;
          // 恢复到历史状态
          const prevState = history[historyIndex];
          // 这里需要一个机制来更新主store的状态
          // 最直接但粗暴的方式是直接替换根state,但更优雅的可能是dispatch一个特殊的mutation
          // 比如 store.commit('SET_ROOT_STATE', prevState);
          // 但 Vuex 官方不推荐直接修改 state,所以通常需要逐个模块地恢复
          // 或者在插件外部提供一个 action 来做这件事
          console.log('执行撤销,恢复到:', prevState);
          isUndoingRedoing = false;
        }
      },
      redo(state) {
        if (historyIndex < history.length - 1) {
          isUndoingRedoing = true;
          historyIndex++;
          const nextState = history[historyIndex];
          console.log('执行重做,恢复到:', nextState);
          isUndoingRedoing = false;
        }
      }
    },
    actions: {
      // 提供 action 包装 mutation
      undo: ({ commit }) => commit('undo'),
      redo: ({ commit }) => commit('redo')
    }
  });
};

// 在创建 store 时应用插件
// const store = new Vuex.Store({
//   // ...你的模块
//   plugins: [undoRedoPlugin]
// });
登录后复制

Vuex的插件方式允许你监听所有

mutation
登录后复制
,并在适当的时机记录状态。但要真正“恢复”状态,你可能需要一个特殊的
mutation
登录后复制
来接收并设置整个状态树,或者为每个模块提供一个
SET_STATE
登录后复制
之类的
mutation
登录后复制
。这比Redux的高阶Reducer稍微复杂一点,因为Vuex的响应式系统和
mutation
登录后复制
的限制。不过,社区也有一些现成的Vuex撤销重做库,它们通常会处理这些细节。

总的来说,无论Redux还是Vuex,其核心都是在状态发生变化时“捕获”状态或操作,并提供机制来“回放”或“撤销”这些变化。选择哪种方式,很大程度上取决于你对框架的熟悉程度和项目的具体需求。

以上就是如何设计一个支持撤销重做的状态管理系统?的详细内容,更多请关注php中文网其它相关文章!

最佳 Windows 性能的顶级免费优化软件
最佳 Windows 性能的顶级免费优化软件

每个人都需要一台速度更快、更稳定的 PC。随着时间的推移,垃圾文件、旧注册表数据和不必要的后台进程会占用资源并降低性能。幸运的是,许多工具可以让 Windows 保持平稳运行。

下载
来源:php中文网
本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系admin@php.cn
最新问题
开源免费商场系统广告
热门教程
更多>
最新下载
更多>
网站特效
网站源码
网站素材
前端模板
关于我们 免责申明 举报中心 意见反馈 讲师合作 广告合作 最新更新 English
php中文网:公益在线php培训,帮助PHP学习者快速成长!
关注服务号 技术交流群
PHP中文网订阅号
每天精选资源文章推送
PHP中文网APP
随时随地碎片化学习

Copyright 2014-2025 https://www.php.cn/ All Rights Reserved | php.cn | 湘ICP备2023035733号