如何解决基于节点的嵌套Undo/Redo功能中的执行顺序矛盾问题?
嵌套动作Undo/Redo的标准解决方案
核心问题在于当前实现将父动作与子动作作为独立条目推入撤销栈,导致撤销时的执行顺序与操作依赖关系冲突。行业标准解决方案是将子动作的逻辑直接封装到父动作的do/undo方法中,不将子动作作为独立栈条目,让父动作作为单个逻辑单元被撤销/重做,自动保证正确的执行顺序。
实现思路
- 取消父动作中对
Act()的调用,直接执行子动作的do/undo逻辑 - 仅将父动作推入撤销栈,子动作成为父动作内部执行的步骤
- 父动作的
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
相关产品推荐
相关产品推荐

