You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何解决基于节点的嵌套Undo/Redo功能中的执行顺序矛盾问题?

嵌套动作Undo/Redo的标准解决方案

核心问题在于当前实现将父动作与子动作作为独立条目推入撤销栈,导致撤销时的执行顺序与操作依赖关系冲突。行业标准解决方案是将子动作的逻辑直接封装到父动作的do/undo方法中,不将子动作作为独立栈条目,让父动作作为单个逻辑单元被撤销/重做,自动保证正确的执行顺序。

实现思路

  1. 取消父动作中对Act()的调用,直接执行子动作的do/undo逻辑
  2. 仅将父动作推入撤销栈,子动作成为父动作内部执行的步骤
  3. 父动作的undo方法按正确顺序处理自身逻辑与子动作的撤销

修改后的完整Demo代码

let nodeI = 0;
let Node = function(fromStr) {
  let el = document.createElement('div');
  el.classList.add('node');
  el.innerHTML = 'Node ' + nodeI + '<br>';
  document.getElementById('nodes').appendChild(el);
  el.onclick = () => {
    if (!this.selected) Act('select', { id: this.id });
    else Act('deselect', { id: this.id });
  };
  el.node = this;
  this.el = el;
  this.id = nodeI;
  if (fromStr) {
    fromStr = JSON.parse(fromStr);
    for (var i in fromStr) { this[i] = fromStr[i]; }
  }
  this.el.id = 'node-' + this.id;
  let btn = document.createElement('button');
  btn.innerHTML = '[kill]';
  el.appendChild(btn);
  btn.addEventListener('click', (e) => {
    e.stopPropagation();
    Act('kill node', { id: this.id, fromStr: this.serialize() });
  });
  this.selected = false;
  nodeI++;
};
Node.prototype.serialize = function() {
  return JSON.stringify({ id: this.id });
};

let ActionTypes = {};
let ActionType = function(id, o) {
  this.id = id;
  for (var i in o) { this[i] = o[i]; }
  ActionTypes[id] = this;
};

// 修改create node动作:直接执行select逻辑,不调用Act
new ActionType('create node', {
  do: stored => {
    let node = new Node(stored.fromStr || 0);
    stored.fromStr = node.serialize();
    stored.id = node.id;
    // 直接执行select动作的do方法
    ActionTypes['select'].do({ id: stored.id });
  },
  undo: stored => {
    // 直接执行deselect动作的do方法
    ActionTypes['deselect'].do({ id: stored.id });
    document.getElementById('node-' + stored.id).remove();
  }
});

// 修改kill node动作:直接执行deselect逻辑,不调用Act
new ActionType('kill node', {
  do: stored => {
    // 直接执行deselect动作的do方法
    ActionTypes['deselect'].do({ id: stored.id });
    document.getElementById('node-' + stored.id).remove();
  },
  undo: stored => {
    let node = new Node(stored.fromStr);
    // 直接执行select动作的do方法
    ActionTypes['select'].do({ id: stored.id });
  }
});

new ActionType('select', {
  do: stored => {
    document.getElementById('node-' + stored.id).node.selected = true;
    document.getElementById('node-' + stored.id).classList.add('selected');
  },
  undo: stored => {
    document.getElementById('node-' + stored.id).node.selected = false;
    document.getElementById('node-' + stored.id).classList.remove('selected');
  }
});

new ActionType('deselect', {
  do: stored => {
    document.getElementById('node-' + stored.id).node.selected = false;
    document.getElementById('node-' + stored.id).classList.remove('selected');
  },
  undo: stored => {
    document.getElementById('node-' + stored.id).node.selected = true;
    document.getElementById('node-' + stored.id).classList.add('selected');
  }
});

let undoSteps = [];
let redoSteps = [];
let inUndoRedo = false;

let updateQueueDisplay = function() {
  document.getElementById('queue').innerHTML = 
    'Undo: ' + undoSteps.map(a => '[' + a.type.id + ']').join(', ') + 
    '<br>Redo: ' + redoSteps.map(a => '[' + a.type.id + ']').join(', ');
};

let Undo = function() {
  if (undoSteps.length === 0) return;
  inUndoRedo = true;
  let step = undoSteps[0];
  undoSteps.shift();
  redoSteps.unshift(step);
  step.undo();
  updateQueueDisplay();
  inUndoRedo = false;
};
let Redo = function() {
  if (redoSteps.length === 0) return;
  inUndoRedo = true;
  let step = redoSteps[0];
  redoSteps.shift();
  undoSteps.unshift(step);
  step.do();
  updateQueueDisplay();
  inUndoRedo = false;
};

let Action = function(id, data) {
  this.type = ActionTypes[id];
  this.stored = data || {};
};
Action.prototype.do = function() {
  this.type.do(this.stored);
};
Action.prototype.undo = function() {
  this.type.undo(this.stored);
};

let Act = function(id, data) {
  let action = new Action(id, data);
  action.do();
  
  if (!inUndoRedo) {
    redoSteps = [];
    undoSteps.unshift(action);
  }
  updateQueueDisplay();
};

let Reset = function() {
  nodeI = 0;
  undoSteps = [];
  redoSteps = [];
  document.getElementById('queue').innerHTML = '-<br>';
  document.getElementById('nodes').innerHTML = '';
};
body{font-family:Verdana;font-size:11px;}
#nodes{background:#ccc;padding:2px;min-height:68px;}
.node{width:64px;height:64px;text-align:center;display:inline-block;margin:2px;background:#cf9;cursor:pointer;}
.node.selected{box-shadow:0px 0px 0px 3px #000 inset;}
<body>
<button onclick="Act('create node',{})">Create node</button>
<button onclick="Undo()">Undo</button>
<button onclick="Redo()">Redo</button><button onclick="Reset()">Reset</button>
(click a node to select it!)

<div id="queue">(action queue will show here)<br></div>

<div id="nodes"></div>
</body>

为什么这能解决问题?

  • 创建节点:撤销时先执行取消选中(节点仍存在),再删除节点,避免了操作不存在节点的错误
  • 删除节点:撤销时先重建节点,再执行选中操作,保证选中逻辑能找到目标节点
  • 无需逐案标记:所有子动作逻辑被封装在父动作内部,撤销栈仅保留逻辑完整的用户操作,自动维护正确顺序

内容的提问来源于stack exchange,提问作者Orteil

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.24 17:15:58