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

答案:设计撤销重做系统需选择状态快照或命令模式,结合历史管理,限制深度、合并操作,并与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

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

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

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

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。
如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 chuangxiangniao@163.com 举报,一经查实,本站将立刻删除。
发布者:程序猿,转转请注明出处:https://www.chuangxiangniao.com/p/1521237.html

(0)
打赏 微信扫一扫 微信扫一扫 支付宝扫一扫 支付宝扫一扫
JS 函数性能优化技巧 – 避免参数重组与优化递归函数的实践
上一篇 2025年12月20日 13:59:55
如何在分页数据中实现倒序行索引显示
下一篇 2025年12月20日 14:00:06

相关推荐

  • composer require-dev和require有什么不同_Composer Require与Require-Dev区别解析

    require用于声明项目运行必需的依赖,如框架、数据库组件和第三方SDK,这些包会随项目部署到生产环境;2. require-dev用于声明仅在开发和测试阶段需要的工具,如PHPUnit、PHPStan、Faker等,不会默认部署到生产环境;3. 安装时composer install根据环境决定…

    2026年5月10日
    1000
  • 修复Django电商项目中AJAX过滤产品列表图片不显示问题

    在Django电商项目中,当使用AJAX动态加载过滤后的产品列表时,常遇到图片无法正常显示的问题。这通常是由于前端模板中图片加载方式(如data-setbg属性结合JavaScript库)与AJAX动态内容更新机制不兼容所致。解决方案是直接在AJAX返回的HTML中使用标准的标签来渲染图片,确保浏览…

    2026年5月10日
    000
  • 开源免费PHP工具 PHP开发效率提升利器

    推荐开源免费PHP开发工具以提升效率:VS Code、Sublime Text轻量高效,PhpStorm专业强大;调试用Xdebug、Kint、Ray;依赖管理选Composer;代码质量工具包括PHPStan、Psalm、PHP_CodeSniffer;数据库管理可用%ignore_a_1%MyA…

    2026年5月10日
    000
  • Matplotlib 地图中多类型图例的创建与优化

    Matplotlib 地图中多类型图例的创建与优化Matplotlib 地图中多类型图例的创建与优化Matplotlib 地图中多类型图例的创建与优化Matplotlib 地图中多类型图例的创建与优化

    本教程旨在解决matplotlib地图可视化中,如何在一个图例中同时展示颜色块(如区域分类)和自定义标记(如特定兴趣点)的问题。文章详细介绍了当传统`patch`对象无法正确显示标记时,如何利用`matplotlib.lines.line2d`创建标记图例句柄,并将其与颜色块图例句柄合并,从而生成一…

    2026年5月10日 用户投稿
    100
  • Golang JSON序列化:控制敏感字段暴露的最佳实践

    本教程探讨golang中如何高效控制结构体字段在json序列化时的可见性。当需要将包含敏感信息的结构体数组转换为json响应时,通过利用`encoding/json`包提供的结构体标签,特别是`json:”-“`,可以轻松实现对特定字段的忽略,从而避免敏感数据泄露,确保api…

    2026年5月10日
    000
  • 利用海象运算符简化条件赋值:Python教程与最佳实践

    本文旨在探讨Python中海象运算符(:=)在条件赋值场景下的应用。通过对比传统if/else语句与海象运算符,以及条件表达式,分析海象运算符在简化代码、提高可读性方面的优势与局限性。并通过具体示例,展示如何在列表推导式等场景下合理使用海象运算符,同时强调其潜在的复杂性及替代方案,帮助开发者更好地掌…

    2026年5月10日
    100
  • Debian syslog性能优化技巧有哪些

    提升Debian系统syslog (通常基于rsyslog)性能,关键在于精简配置和高效处理日志。以下策略能有效优化日志管理,提升系统整体性能: 精简配置,高效加载: 在rsyslog配置文件中,仅加载必要的输入、输出和解析模块。 使用全局指令设置日志级别和格式,避免不必要的处理。 自定义模板: 创…

    2026年5月10日
    000
  • c++中的SFINAE技术是什么_c++模板编程中的SFINAE原理与应用

    SFINAE 是“替换失败不是错误”的原则,指模板实例化时若参数替换导致错误,只要存在其他合法候选,编译器不报错而是继续重载决议。它用于条件启用模板、类型检测等场景,如通过 decltype 或 enable_if 控制函数重载,实现类型特征判断。尽管 C++20 引入 Concepts 简化了部分…

    2026年5月10日
    000
  • RichHandler与Rich Progress集成:解决显示冲突的教程

    在使用rich库的`richhandler`进行日志输出并同时使用`progress`组件时,可能会遇到显示错乱或溢出问题。这通常是由于为`richhandler`和`progress`分别创建了独立的`console`实例导致的。解决方案是确保日志处理器和进度条组件共享同一个`console`实例…

    2026年5月10日
    000
  • 理解编程指令:当结果正确,但实现方式不符要求时

    本文探讨了在编程实践中,即使程序输出了正确的结果,但若其实现方式未能严格遵循既定指令,仍可能被视为“不正确”的问题。我们将通过具体示例,对比直接求和与累加求和两种实现策略,强调理解和遵守编程规范的重要性,以确保代码的健壮性、可维护性及符合项目要求。 在软件开发过程中,我们经常会遇到这样的情况:编写的…

    2026年5月10日
    000
  • Golang goroutine与channel调试技巧

    使用go run -race检测数据竞争,结合runtime.NumGoroutine监控协程数量,通过pprof分析阻塞调用栈,利用select超时避免永久阻塞,有效排查goroutine泄漏、死锁和数据竞争问题。 Go语言的goroutine和channel是并发编程的核心,但它们也带来了调试上…

    2026年5月10日
    000
  • 使用 Jupyter Notebook 进行探索性数据分析

    Jupyter Notebook通过单元格实现代码与Markdown结合,支持数据导入(pandas)、清洗(fillna)、探索(matplotlib/seaborn可视化)、统计分析(describe/corr)和特征工程,便于记录与分享分析过程。 Jupyter Notebook 是进行探索性…

    2026年5月10日
    000
  • 前端缓存策略与JavaScript存储管理

    根据数据特性选择合适的存储方式并制定清晰的读写与清理逻辑,能显著提升前端性能;合理运用Cookie、localStorage、sessionStorage、IndexedDB及Cache API,结合缓存策略与定期清理机制,可在保证用户体验的同时避免安全与性能隐患。 前端缓存和JavaScript存…

    2026年5月10日
    200
  • 网站标题关键词更新后,搜索引擎为何仍显示旧标题?

    网站标题更新后,搜索引擎为何显示旧标题? 网站SEO优化中,站长常修改网站标题关键词,期望搜索结果显示自定义标题。然而,即使更新标签、meta keywords、meta description和结构化数据中的name属性后,搜索结果仍显示旧标题,这令人费解。本文将对此进行解释。 问题:站长修改了网…

    2026年5月10日
    100
  • HTML5网页如何实现手势操作 HTML5网页移动端交互的处理技巧

    首先利用原生touch事件实现滑动判断,再通过preventDefault解决滚动冲突,接着引入Hammer.js处理复杂手势,最后通过优化点击区域、避免事件冲突和增加视觉反馈提升体验。 在移动端浏览器中,HTML5网页可以通过触摸事件实现手势操作,提升用户体验。虽然原生JavaScript提供了基…

    2026年5月10日
    000
  • 深入理解 Express.js 中 next() 参数的作用与中间件机制

    本文深入探讨 express.js 中间件函数中的 `next()` 参数。它负责将控制权传递给请求-响应周期中的下一个中间件或路由处理程序。文章将详细解释 `next()` 的工作原理、中间件的注册与执行顺序,以及不正确使用 `next()` 可能导致请求挂起的风险,并通过代码示例和实际应用场景,…

    2026年5月10日
    000
  • Python命令怎样使用profile分析脚本性能 Python命令性能分析的基础教程

    使用Python的cProfile模块分析脚本性能最直接的方式是通过命令行执行python -m cProfile your_script.py,它会输出每个函数的调用次数、总耗时、累积耗时等关键指标,帮助定位性能瓶颈;为进一步分析,可将结果保存为文件python -m cProfile -o ou…

    2026年5月10日
    000
  • 如何插入查询结果数据_SQL插入Select查询结果方法

    如何插入查询结果数据_SQL插入Select查询结果方法如何插入查询结果数据_SQL插入Select查询结果方法如何插入查询结果数据_SQL插入Select查询结果方法如何插入查询结果数据_SQL插入Select查询结果方法

    使用INSERT INTO…SELECT语句可高效插入数据,通过NOT EXISTS、LEFT JOIN、MERGE语句或唯一约束避免重复;表结构不一致时可通过别名、类型转换、默认值或计算字段处理;结合存储过程可提升可维护性,支持参数化与动态SQL。 将查询结果数据插入到另一个表中,可以…

    2026年5月10日 用户投稿
    000
  • 使用 WebCodecs VideoDecoder 实现精确逐帧回退

    本文档旨在解决在使用 WebCodecs VideoDecoder 进行视频解码时,实现精确逐帧回退的问题。通过比较帧的时间戳与目标帧的时间戳,可以避免渲染中间帧,从而提高用户体验。本文将提供详细的解决方案和示例代码,帮助开发者实现精确的视频帧控制。 在使用 WebCodecs VideoDecod…

    2026年5月10日
    000
  • Discord.py 交互按钮超时与持久化解决方案

    本教程旨在解决Discord.py中交互按钮在一段时间后出现“This Interaction Failed”错误的问题。我们将深入探讨视图(View)的超时机制,并提供通过正确设置timeout参数以及利用bot.add_view()方法实现按钮持久化的具体方案,确保您的机器人交互功能稳定可靠,即…

    2026年5月10日
    000

发表回复

登录后才能评论
关注微信