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

答案:设计撤销重做系统需选择状态快照或命令模式,结合历史管理,限制深度、合并操作,并与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)
打赏 微信扫一扫 微信扫一扫 支付宝扫一扫 支付宝扫一扫
上一篇 2025年12月20日 13:59:55
下一篇 2025年12月20日 14:00:06

相关推荐

  • CSS mask属性无法获取图片:为什么我的图片不见了?

    CSS mask属性无法获取图片 在使用CSS mask属性时,可能会遇到无法获取指定照片的情况。这个问题通常表现为: 网络面板中没有请求图片:尽管CSS代码中指定了图片地址,但网络面板中却找不到图片的请求记录。 问题原因: 此问题的可能原因是浏览器的兼容性问题。某些较旧版本的浏览器可能不支持CSS…

    2025年12月24日
    900
  • 为什么设置 `overflow: hidden` 会导致 `inline-block` 元素错位?

    overflow 导致 inline-block 元素错位解析 当多个 inline-block 元素并列排列时,可能会出现错位显示的问题。这通常是由于其中一个元素设置了 overflow 属性引起的。 问题现象 在不设置 overflow 属性时,元素按预期显示在同一水平线上: 不设置 overf…

    2025年12月24日 好文分享
    400
  • 网页使用本地字体:为什么 CSS 代码中明明指定了“荆南麦圆体”,页面却仍然显示“微软雅黑”?

    网页中使用本地字体 本文将解答如何将本地安装字体应用到网页中,避免使用 src 属性直接引入字体文件。 问题: 想要在网页上使用已安装的“荆南麦圆体”字体,但 css 代码中将其置于第一位的“font-family”属性,页面仍显示“微软雅黑”字体。 立即学习“前端免费学习笔记(深入)”; 答案: …

    2025年12月24日
    000
  • 为什么我的特定 DIV 在 Edge 浏览器中无法显示?

    特定 DIV 无法显示:用户代理样式表的困扰 当你在 Edge 浏览器中打开项目中的某个 div 时,却发现它无法正常显示,仔细检查样式后,发现是由用户代理样式表中的 display none 引起的。但你疑问的是,为什么会出现这样的样式表,而且只针对特定的 div? 背后的原因 用户代理样式表是由…

    2025年12月24日
    200
  • inline-block元素错位了,是为什么?

    inline-block元素错位背后的原因 inline-block元素是一种特殊类型的块级元素,它可以与其他元素行内排列。但是,在某些情况下,inline-block元素可能会出现错位显示的问题。 错位的原因 当inline-block元素设置了overflow:hidden属性时,它会影响元素的…

    2025年12月24日
    000
  • 为什么 CSS mask 属性未请求指定图片?

    解决 css mask 属性未请求图片的问题 在使用 css mask 属性时,指定了图片地址,但网络面板显示未请求获取该图片,这可能是由于浏览器兼容性问题造成的。 问题 如下代码所示: 立即学习“前端免费学习笔记(深入)”; icon [data-icon=”cloud”] { –icon-cl…

    2025年12月24日
    200
  • 为什么使用 inline-block 元素时会错位?

    inline-block 元素错位成因剖析 在使用 inline-block 元素时,可能会遇到它们错位显示的问题。如代码 demo 所示,当设置了 overflow 属性时,a 标签就会错位下沉,而未设置时却不会。 问题根源: overflow:hidden 属性影响了 inline-block …

    2025年12月24日
    000
  • 如何使用 vue-color 创建交互式颜色渐变页面?

    如何创建交互式颜色渐变页面? 实现交互式颜色渐变页面可以通过利用第三方库来简化开发流程。 推荐解决方案: vue-color 立即学习“前端免费学习笔记(深入)”; vue-color是一个vue.js库,提供了一个功能强大的调色板组件。它允许你轻松创建和管理颜色渐变。 特性: 颜色选择器:选择单一…

    2025年12月24日
    200
  • 为什么我的 CSS 元素放大效果无法正常生效?

    css 设置元素放大效果的疑问解答 原提问者在尝试给元素添加 10em 字体大小和过渡效果后,未能在进入页面时看到放大效果。探究发现,原提问者将 CSS 代码直接写在页面中,导致放大效果无法触发。 解决办法如下: 将 CSS 样式写在一个单独的文件中,并使用 标签引入该样式文件。这个操作与原提问者观…

    2025年12月24日
    000
  • 为什么我的 em 和 transition 设置后元素没有放大?

    元素设置 em 和 transition 后不放大 一个 youtube 视频中展示了设置 em 和 transition 的元素在页面加载后会放大,但同样的代码在提问者电脑上没有达到预期效果。 可能原因: 问题在于 css 代码的位置。在视频中,css 被放置在单独的文件中并通过 link 标签引…

    2025年12月24日
    100
  • 为什么在父元素为inline或inline-block时,子元素设置width: 100%会出现不同的显示效果?

    width:100%在父元素为inline或inline-block下的显示问题 问题提出 当父元素为inline或inline-block时,内部元素设置width:100%会出现不同的显示效果。以代码为例: 测试内容 这是inline-block span 效果1:父元素为inline-bloc…

    2025年12月24日
    400
  • 如何利用 vue-color 库打造交互式色彩渐变页面?

    打造交互性前端:色彩渐变页面的制作方法 在前端开发中,色彩渐变页面和交互式元素深受设计师和开发人员的欢迎。本文将探讨如何利用 vue-color 库轻松实现这样的页面。 使用 vue-color 库构建调色板 vue-color 是一个 vue.js 库,可用于创建可定制的调色板。其基本功能包括: …

    2025年12月24日
    000
  • 如何使用前端技术创建交互式颜色渐变页面?

    如何创建交互式颜色渐变页面? 当您希望在前端界面实现颜色渐变效果并实现交互功能时,可以使用以下方法: 解决方案: 1. 使用 vue-color 库 vue-color 库是一个功能强大的 vue.js 库,可用于创建色板和处理颜色操作。它可以帮助您轻松实现颜色渐变效果,如下所示: 立即学习“前端免…

    好文分享 2025年12月24日
    000
  • Vue 中如何动态添加带有动态样式的伪元素?

    vue 动态添加具有动态样式的伪元素 在某些情况下,需要根据动态条件向 dom 元素添加带有动态样式的伪元素。例如,元素的伪元素“before”可能只有在满足特定条件时才会出现,并且其样式(如长度、高度和其他属性)也是不确定的。 解决方案:css 变量 由于伪元素的样式不能直接在 css 中定义,可…

    2025年12月24日
    000
  • Vue 中如何动态添加伪元素?

    vue中如何动态添加伪元素 在某些情况下,需要动态地为元素添加伪元素,但传统方法受限于伪元素不能写死在 css 中。本文将介绍一种使用 css 变量解决此问题的方法。 使用 css 变量 css 变量允许在样式表中定义可重复使用的变量,然后可以在其他样式中使用这些变量。利用这个特性,我们可以动态地控…

    2025年12月24日
    100
  • 如何使用 CSS 变量动态控制 Vue 应用中 DOM 伪元素的样式?

    灵活操纵 vue 中 dom 伪元素 在 vue 应用中,有时需要在特定条件下动态添加和修改伪元素样式。虽然 css 中的伪元素通常是静态定义的,但有些情况下,需要根据用户的行为或数据动态调整其样式。 动态控制伪元素样式 可以使用 css 变量来解决此问题。css 变量允许您在样式表中存储可变值,然…

    2025年12月24日
    100
  • Vue中如何利用CSS变量动态操纵伪元素样式?

    利用css变量动态操纵伪元素 在vue中,有时需要动态地给dom元素添加伪元素,并且伪元素的样式也是动态变化的。不能在css文件中直接定义伪元素样式,因为伪元素包含动态参数。 这个问题的解决方法之一是使用css变量。css变量允许我们在css中定义变量并动态地将其分配给元素的样式。 代码示例: 立即…

    2025年12月24日
    300
  • HTMLrev 上的免费 HTML 网站模板

    HTMLrev 是唯一的人工策划的库专门专注于免费 HTML 模板,适用于由来自世界各地慷慨的模板创建者制作的网站、登陆页面、投资组合、博客、电子商务和管理仪表板世界。 这个人就是我自己 Devluc,我已经工作了 1 年多来构建、改进和更新这个很棒的免费资源。我自己就是一名模板制作者,所以我知道如…

    2025年12月24日
    300
  • Vue/UniApp 中如何实现选中效果的切换?

    vue/uniapp中复现选中的效果 在vue/uniapp中实现此效果,可以使用view元素和样式类来控制外观。让我们来看看这个问题的示例代码。 日 周 月 年 .tabs { display: flex; justify-content: space-between; flex-directio…

    2025年12月24日
    000
  • 如何简化五子棋代码中的重复部分?

    五子棋代码简化 问题: 如何简化五子棋代码中重复的部分? 问题内容: 提供了vue编写的五子棋代码,但其中有多个重复的部分。希望得到一个更简化的代码版本。 问题答案: 拆分重复方法 将大方法中的重复部分拆分成更小的函数,例如: placepiece():放置棋子checkandplace():检查某…

    2025年12月24日
    000

发表回复

登录后才能评论
关注微信