如何在前端编辑器中实现像 Ctrl + Z 一样的撤销和重做

我正在开发 DocFlow,它是一个完整的 AI 全栈协同文档平台。该项目融合了多个技术栈,包括基于 Tiptap 的富文本编辑器、NestJs 后端服务、AI 集成功能和实时协作。在开发过程中,我积累了丰富的实战经验,涵盖了 Tiptap 的深度定制、性能优化和协作功能的实现等核心难点。

如果你对 AI 全栈开发、Tiptap 富文本编辑器定制或 DocFlow 项目的完整技术方案感兴趣,欢迎加我微信 yunmz777 进行私聊咨询,获取详细的技术分享和最佳实践。

在前端开发中,撤销和重做功能是提升用户体验的重要特性。无论是文本编辑器、图形设计工具,还是可视化搭建平台,都需要提供历史操作的回退和前进能力。这个功能看似简单,但实现起来需要考虑性能、内存占用、用户体验等多个方面。

核心原理

UndoRedo 的核心就是:

  • 把用户的操作记录下来
  • 能在历史记录里往回退(Undo)或前进(Redo)

这个功能实现的底层思路有两种:

命令模式(Command Pattern)

命令模式是最常见也最灵活的方案:

  • 每次用户操作封装成一个操作对象(Command
  • 每个命令实现 execute()undo() 方法
  • 用两个栈分别管理历史操作和已撤销操作
  • 添加新操作时清空 redo

这种方式的优点是内存占用低,每个命令只保存必要的状态信息,而不是整个应用状态。同时,命令模式具有很高的扩展性,可以轻松添加新的操作类型。命令模式还支持操作的回放、批量执行、操作日志等功能。

状态快照法(Memento Pattern)

状态快照法是一种更简单但内存消耗更大的方案:

  • 在每次操作前保存整个状态快照
  • 撤销时恢复上一个状态
  • 优点:不需要一个个命令实现 undo,实现简单
  • 缺点:状态大时内存占用高,深拷贝整个状态对象成本高

这种方式适合状态较小、结构简单的场景,比如简单的表单状态管理。对于复杂的状态结构,快照法会导致内存快速增长。

前端里命令模式是更常见也更灵活的方案,下面我们重点展开。

命令模式实现

核心数据结构

我们用两个栈来管理历史:

ts 复制代码
const undoStack: Command[] = [];
const redoStack: Command[] = [];

这两个栈的工作机制:

  • 执行新的操作:undoStack.push(cmd)
  • 撤销操作:undoStack.pop() → 放入 redoStack
  • 重做操作:redoStack.pop() → 放入 undoStack

这种设计保证了操作的线性历史记录,用户可以按照操作顺序进行撤销和重做。栈的 LIFO(后进先出)特性完美契合了撤销重做的需求。

命令接口定义

核心接口非常简单:

ts 复制代码
interface Command {
  execute(): void; // 执行操作
  undo(): void; // 撤销操作
}

每个操作都要:

  • 执行时保存旧状态
  • 撤销时恢复旧状态

这个接口的设计遵循了单一职责原则,每个命令只负责一个特定的操作及其撤销。在实际项目中,我们还可以扩展这个接口,添加 redo() 方法(虽然通常 redo 就是再次执行 execute()),或者添加 description 属性用于显示操作描述。

历史管理函数

ts 复制代码
function runCommand(cmd: Command) {
  cmd.execute();
  undoStack.push(cmd);
  // 新操作清空 redo 栈
  redoStack.length = 0;
}

function undo() {
  const cmd = undoStack.pop();
  if (!cmd) return;
  cmd.undo();
  redoStack.push(cmd);
}

function redo() {
  const cmd = redoStack.pop();
  if (!cmd) return;
  cmd.execute();
  undoStack.push(cmd);
}

这里有一个关键的设计决策:当执行新操作时,必须清空 redoStack。这是因为新操作改变了应用的状态,之前被撤销的操作已经不再有效,不能继续重做。这个设计保证了状态的一致性。

这样你就可以在 UI 里调用:

html 复制代码
<button onclick="undo()">撤销</button> <button onclick="redo()">重做</button>

命令实现示例

文本编辑器示例

假设我们有一个简单的文本编辑器对象:

ts 复制代码
class Editor {
  private text = "";

  getText() {
    return this.text;
  }

  setText(val: string) {
    this.text = val;
  }
}

我们封装一个"追加文本"的命令:

ts 复制代码
class AppendTextCommand implements Command {
  private prevText: string;

  constructor(private editor: Editor, private newText: string) {
    this.prevText = "";
  }

  execute() {
    // 保存旧状态
    this.prevText = this.editor.getText();
    this.editor.setText(this.prevText + this.newText);
  }

  undo() {
    // 恢复旧状态
    this.editor.setText(this.prevText);
  }
}

使用示例:

ts 复制代码
const editor = new Editor();

runCommand(new AppendTextCommand(editor, "Hello"));
runCommand(new AppendTextCommand(editor, ", world!"));

console.log(editor.getText()); // Hello, world!

undo();
console.log(editor.getText()); // Hello

redo();
console.log(editor.getText()); // Hello, world!

DOM 操作示例

在实际的前端开发中,我们经常需要操作 DOM 元素。下面是一个删除节点的命令示例:

ts 复制代码
class DeleteNodeCommand implements Command {
  private parentNode: Node | null = null;
  private nextSibling: Node | null = null;
  private deletedNode: Node;

  constructor(private node: Node) {
    this.deletedNode = node;
  }

  execute() {
    this.parentNode = this.node.parentNode;
    this.nextSibling = this.node.nextSibling;
    this.parentNode?.removeChild(this.node);
  }

  undo() {
    if (this.parentNode && this.deletedNode) {
      if (this.nextSibling) {
        this.parentNode.insertBefore(this.deletedNode, this.nextSibling);
      } else {
        this.parentNode.appendChild(this.deletedNode);
      }
    }
  }
}

这个命令保存了被删除节点的父节点和下一个兄弟节点,这样在撤销时可以精确地恢复到原来的位置。对于插入节点的操作,我们可以类似地保存插入位置的信息。

样式修改示例

修改元素样式的命令:

ts 复制代码
class ChangeStyleCommand implements Command {
  private oldValue: string;

  constructor(
    private element: HTMLElement,
    private property: string,
    private newValue: string
  ) {
    this.oldValue = "";
  }

  execute() {
    this.oldValue = this.element.style[this.property] || "";
    this.element.style[this.property] = this.newValue;
  }

  undo() {
    this.element.style[this.property] = this.oldValue;
  }
}

命令组合

有时候一个用户操作可能包含多个子操作,比如"复制粘贴"操作。我们可以使用组合命令:

ts 复制代码
class CompositeCommand implements Command {
  private commands: Command[] = [];

  addCommand(cmd: Command) {
    this.commands.push(cmd);
  }

  execute() {
    this.commands.forEach((cmd) => cmd.execute());
  }

  undo() {
    // 反向执行撤销
    for (let i = this.commands.length - 1; i >= 0; i--) {
      this.commands[i].undo();
    }
  }
}

使用组合命令可以让我们将多个相关操作作为一个整体进行撤销和重做,这在复杂编辑器中非常有用。例如,在图形编辑器中,移动一个元素可能同时需要更新多个属性(位置、层级、连接关系等),这些操作可以组合成一个命令。

拖拽操作示例

拖拽是前端常见的交互,实现拖拽的撤销重做需要保存位置信息:

ts 复制代码
class DragCommand implements Command {
  private startX: number;
  private startY: number;
  private endX: number;
  private endY: number;

  constructor(
    private element: HTMLElement,
    startX: number,
    startY: number,
    endX: number,
    endY: number
  ) {
    this.startX = startX;
    this.startY = startY;
    this.endX = endX;
    this.endY = endY;
  }

  execute() {
    this.element.style.left = `${this.endX}px`;
    this.element.style.top = `${this.endY}px`;
  }

  undo() {
    this.element.style.left = `${this.startX}px`;
    this.element.style.top = `${this.startY}px`;
  }
}

状态快照法

对于简单状态,例如 Redux 的整个状态对象,快照法很常用:

ts 复制代码
// 假设 state 是一个对象
const history: any[] = [];
let pointer = -1;

function saveState(state: any) {
  // 清空未来版本(如果有新操作,之前的 redo 历史就无效了)
  history.splice(pointer + 1);
  // 深拷贝当前状态
  history.push(JSON.parse(JSON.stringify(state)));
  pointer = history.length - 1;
}

function undoState() {
  if (pointer > 0) {
    pointer--;
    return JSON.parse(JSON.stringify(history[pointer]));
  }
  return null;
}

function redoState() {
  if (pointer < history.length - 1) {
    pointer++;
    return JSON.parse(JSON.stringify(history[pointer]));
  }
  return null;
}

缺点是复制整个状态对象成本高,适合状态较小场景。如果状态对象很大,每次保存快照都会消耗大量内存和时间。对于支持结构化克隆的环境,可以使用更高效的深拷贝方法:

ts 复制代码
function saveState(state: any) {
  history.splice(pointer + 1);
  // 使用结构化克隆,比 JSON 序列化更快且支持更多类型
  history.push(structuredClone(state));
  pointer = history.length - 1;
}

结构化克隆比 JSON.parse(JSON.stringify()) 更快,并且支持更多数据类型(如 DateRegExpMapSet 等)。

性能优化

限制历史长度

无限制记录可能爆内存,特别是在长时间使用的应用中:

ts 复制代码
const MAX_HISTORY = 50;

function runCommand(cmd: Command) {
  cmd.execute();
  undoStack.push(cmd);

  // 限制历史长度
  if (undoStack.length > MAX_HISTORY) {
    undoStack.shift(); // 移除最旧的操作
  }

  redoStack.length = 0;
}

合并相似操作

例如连续输入文本可以合并成一个操作组,避免每个输入都记录一条。这对于提升性能和用户体验都很重要:

ts 复制代码
class TextInputCommand implements Command {
  private prevText: string;
  private inputBuffer: string = "";
  private debounceTimer: number | null = null;

  constructor(private editor: Editor) {
    this.prevText = "";
  }

  addText(text: string) {
    this.inputBuffer += text;

    // 清除之前的定时器
    if (this.debounceTimer) {
      clearTimeout(this.debounceTimer);
    }

    // 延迟执行,合并连续输入
    this.debounceTimer = setTimeout(() => {
      this.execute();
      this.inputBuffer = "";
    }, 300); // 300ms 内的输入会被合并
  }

  execute() {
    this.prevText = this.editor.getText();
    this.editor.setText(this.prevText + this.inputBuffer);
  }

  undo() {
    this.editor.setText(this.prevText);
  }
}

同步 UI 状态

每次 undo/redo 后触发 UI 更新,确保按钮状态和界面显示一致:

ts 复制代码
function updateButtons() {
  undoBtn.disabled = undoStack.length === 0;
  redoBtn.disabled = redoStack.length === 0;

  // 更新按钮文本,显示可撤销/重做的操作数量
  undoBtn.textContent = `撤销 (${undoStack.length})`;
  redoBtn.textContent = `重做 (${redoStack.length})`;
}

function undo() {
  const cmd = undoStack.pop();
  if (!cmd) return;
  cmd.undo();
  redoStack.push(cmd);
  updateButtons(); // 更新 UI
}

function redo() {
  const cmd = redoStack.pop();
  if (!cmd) return;
  cmd.execute();
  undoStack.push(cmd);
  updateButtons(); // 更新 UI
}

键盘快捷键支持

大多数应用都支持 Ctrl+ZCtrl+Y(或 Ctrl+Shift+Z)快捷键:

ts 复制代码
document.addEventListener("keydown", (e) => {
  // Ctrl+Z 或 Cmd+Z (Mac)
  if ((e.ctrlKey || e.metaKey) && e.key === "z" && !e.shiftKey) {
    e.preventDefault();
    undo();
  }

  // Ctrl+Y 或 Ctrl+Shift+Z 或 Cmd+Shift+Z (Mac)
  if (
    (e.ctrlKey || e.metaKey) &&
    (e.key === "y" || (e.key === "z" && e.shiftKey))
  ) {
    e.preventDefault();
    redo();
  }
});

延迟执行

对于频繁的操作(如拖拽、实时输入),可以使用防抖或节流来减少历史记录的数量:

ts 复制代码
function debounce<T extends (...args: any[]) => void>(
  func: T,
  wait: number
): T {
  let timeout: number | null = null;
  return ((...args: any[]) => {
    if (timeout) clearTimeout(timeout);
    timeout = setTimeout(() => func(...args), wait);
  }) as T;
}

// 使用防抖的保存函数
const debouncedSave = debounce((state: any) => {
  saveState(state);
}, 500); // 500ms 内的操作只保存最后一次

增量更新

对于大型状态对象,可以使用增量更新而不是完整快照:

ts 复制代码
interface Diff {
  path: string[];
  oldValue: any;
  newValue: any;
}

class IncrementalSnapshot {
  private diffs: Diff[] = [];

  applyDiff(diff: Diff) {
    this.diffs.push(diff);
  }

  applyToState(state: any): any {
    const newState = JSON.parse(JSON.stringify(state));
    this.diffs.forEach((diff) => {
      let target = newState;
      for (let i = 0; i < diff.path.length - 1; i++) {
        target = target[diff.path[i]];
      }
      target[diff.path[diff.path.length - 1]] = diff.newValue;
    });
    return newState;
  }

  reverse(): IncrementalSnapshot {
    const reversed = new IncrementalSnapshot();
    this.diffs.forEach((diff) => {
      reversed.diffs.push({
        path: diff.path,
        oldValue: diff.newValue,
        newValue: diff.oldValue,
      });
    });
    reversed.diffs.reverse();
    return reversed;
  }
}

增量更新只保存状态变化的部分,而不是整个状态对象,这在状态对象很大时能显著减少内存占用。

React Hook 实现

在 React 中,我们可以创建一个自定义 Hook 来管理撤销重做:

tsx 复制代码
import { useState, useCallback } from "react";

function useUndoRedo<T>(initialState: T) {
  const [history, setHistory] = useState<{
    past: T[];
    present: T;
    future: T[];
  }>({
    past: [],
    present: initialState,
    future: [],
  });

  const setState = useCallback((newState: T) => {
    setHistory((prev) => ({
      past: [...prev.past, prev.present],
      present: newState,
      future: [],
    }));
  }, []);

  const undo = useCallback(() => {
    setHistory((prev) => {
      if (prev.past.length === 0) return prev;
      const previous = prev.past[prev.past.length - 1];
      const newPast = prev.past.slice(0, prev.past.length - 1);
      return {
        past: newPast,
        present: previous,
        future: [prev.present, ...prev.future],
      };
    });
  }, []);

  const redo = useCallback(() => {
    setHistory((prev) => {
      if (prev.future.length === 0) return prev;
      const next = prev.future[0];
      const newFuture = prev.future.slice(1);
      return {
        past: [...prev.past, prev.present],
        present: next,
        future: newFuture,
      };
    });
  }, []);

  const canUndo = history.past.length > 0;
  const canRedo = history.future.length > 0;

  return {
    state: history.present,
    setState,
    undo,
    redo,
    canUndo,
    canRedo,
  };
}

使用示例:

tsx 复制代码
function Editor() {
  const { state, setState, undo, redo, canUndo, canRedo } = useUndoRedo("");

  return (
    <div>
      <textarea value={state} onChange={(e) => setState(e.target.value)} />
      <button onClick={undo} disabled={!canUndo}>
        撤销
      </button>
      <button onClick={redo} disabled={!canRedo}>
        重做
      </button>
    </div>
  );
}

总结

实现方式 抽象程度 内存成本 易用性 适用场景
命令模式 ⭐⭐⭐ 复杂操作、需要细粒度控制
状态快照 ⭐⭐ 简单状态、快速实现

推荐方案:命令模式 + 历史栈

这是 UI 里最常见、灵活、可扩展的做法。命令模式不仅适用于撤销重做,还可以用于操作日志记录、批量操作、操作回放、协作编辑中的操作同步等场景。在实际应用中,无论是富文本编辑器中的文本插入和格式化、图形编辑器中的图形移动和删除,还是可视化表单编辑器中的组件增删改,都可以通过命令模式优雅地实现撤销重做功能。

理解撤销重做的实现原理,不仅能帮助我们构建更好的用户体验,也是深入理解状态管理和操作历史的重要基础。在实际项目中,我们需要根据应用的特点选择合适的实现方式,并做好性能优化,确保功能既强大又高效。

相关推荐
小二·3 小时前
前端监控体系完全指南:从错误捕获到用户行为分析(Vue 3 + Sentry + Web Vitals)
前端·vue.js·sentry
阿珊和她的猫5 小时前
IIFE:JavaScript 中的立即调用函数表达式
开发语言·javascript·状态模式
阿珊和她的猫5 小时前
`require` 与 `import` 的区别剖析
前端·webpack
智商偏低5 小时前
JSEncrypt
javascript
谎言西西里5 小时前
零基础 Coze + 前端 Vue3 边玩边开发:宠物冰球运动员生成器
前端·coze
努力的小郑5 小时前
2025年度总结:当我在 Cursor 里敲下 Tab 的那一刻,我知道时代变了
前端·后端·ai编程
GIS之路5 小时前
GDAL 实现数据空间查询
前端
OEC小胖胖6 小时前
01|从 Monorepo 到发布产物:React 仓库全景与构建链路
前端·react.js·前端框架
2501_944711436 小时前
构建 React Todo 应用:组件通信与状态管理的最佳实践
前端·javascript·react.js
困惑阿三7 小时前
2025 前端技术全景图:从“夯”到“拉”排行榜
前端·javascript·程序人生·react.js·vue·学习方法