Skip to content

Three.js 编辑器源码解析:命令系统与撤销机制

在 3D 编辑器里,撤销(Undo)/ 重做(Redo)就是用户的“后悔药”
一旦这玩意儿不稳定,用户的信任值会瞬间归零。

Three.js 官方编辑器(下文简称 Editor)虽然只是一个“示例级”项目,但它的命令系统与撤销机制却非常完整,足够支撑你从 0 到 1 搭出一套可维护、可扩展的编辑器“时间线系统”。

image-20251123180912343

在前两篇里,我们已经从功能与架构信号系统与通信两个角度拆过 Editor。这一篇,我们聚焦一个所有编辑器绕不开的核心能力:

  • 用户的每一次操作如何被抽象成“命令”
  • 撤销 / 重做是如何在源码中落地的
  • Editor 用了哪些“小心机”保证体验和性能

如果你准备自己写一个 Three.js 编辑器,或者给现有业务加上 Undo / Redo,这一篇会非常值回阅读时间。


一、为什么编辑器一定要有“命令系统”?

先想一个简单的问题:要实现撤销 / 重做,最直接的办法是什么?

很多人的第一反应是:

每操作一次,就把整个 Scene(或者关键状态)序列化一份保存下来;
撤销时直接把 Scene 恢复成上一个快照。

这当然可以工作,但问题也非常明显:

  • 内存爆炸:场景稍微大一点,每步一份快照,很快就顶不住
  • 性能堪忧:频繁序列化 / 反序列化,对主线程是灾难
  • 不可读 / 不可控:你只知道“状态变了”,但不知道“发生了什么”

专业编辑器(比如 Photoshop、Unity、Blender),普遍会走另一条路:

不是保存每一帧“结果”,而是记录每一步“操作”本身。

对应到设计模式,就是我们非常熟悉的——命令模式(Command Pattern) + 历史栈(History)

在 Editor 中,这套设计带来的收益非常直接:

  • 所有操作都有统一的抽象:命令对象(Command)
  • 每一步都可以 execute()undo(),天然支持撤销 / 重做
  • 命令可以序列化成 JSON,项目保存时连同历史一起保存
  • UI 不直接改 Scene,所有变更都通过 Editor.execute() 进场

二、先复习一下命令模式:从“直接调用”到“命令对象”

先不用着急看源码,我们用一个极简例子回顾下命令模式。

1. 没有命令模式时的写法

比如移动一个立方体:

ts
// 直接改属性
cube.position.x += 1;

撤销怎么办?
要么:

  • 在别的地方记一个 oldPosition,撤销时手动写回;

要么:

  • 操作前序列化 Scene,撤销时整个恢复。

无论哪种方式,撤销逻辑都散落在各个角落,非常难维护。

2. 引入命令模式后的写法

我们把“移动立方体”抽象成一个命令对象:

ts
interface Command {
  execute(): void; // 执行
  undo(): void; // 撤销
}

class MoveCubeCommand implements Command {
  private oldPosition: THREE.Vector3;
  private newPosition: THREE.Vector3;

  constructor(private cube: THREE.Object3D, delta: THREE.Vector3) {
    this.oldPosition = cube.position.clone();
    this.newPosition = cube.position.clone().add(delta);
  }

  execute() {
    this.cube.position.copy(this.newPosition);
  }

  undo() {
    this.cube.position.copy(this.oldPosition);
  }
}

然后编辑器的“调度中心”只做一件事:

ts
history.execute(new MoveCubeCommand(cube, new THREE.Vector3(1, 0, 0)));

接下来,无论是撤销还是重做,其实都是对命令对象的 undo() / execute() 的调用。

Editor 的命令系统,就是这套思路的一个工程化、可序列化的完整实现。


三、Editor 命令系统全景:三个关键角色

在《功能与架构全览》中,我们已经扫过 Editor 目录结构,这一篇重点关注这几块:

  1. 命令基类与命令集合

    • js/Command.js
      命令基类,定义所有命令共有的接口:
      execute() / undo() / toJSON() / fromJSON() 等。

      注意:虽然execute() / undo() 没有出现在js/Command.js 中,但是这两个方法其实也是公共的 API。

  2. 具体命令实现

    • AddObjectCommand.js — 添加物体
    • RemoveObjectCommand.js — 删除物体
    • SetPositionCommand.js — 设置位置
    • SetRotationCommand.js — 设置旋转
    • SetScaleCommand.js — 设置缩放
    • SetMaterialCommand.js — 设置材质
    • SetGeometryCommand.js — 设置几何体
    • ……以及各种“设置某个属性”的命令
  3. 历史管理器

    • History.js
      管理 undos / redos 两个栈,负责:
      • 执行新命令:execute(command)
      • 撤销:undo()
      • 重做:redo()
      • 历史序列化 / 反序列化
      • 命令合并(拖动时只保留一条记录)

再往上,Editor.js 里把命令系统与整个应用绑定起来:

js
import { History } from "./History.js";

var Editor = function () {
  // ... 省略其它初始化
  this.history = new History(this);
};

Editor.prototype = {
  // 对外暴露统一入口
  execute: function (command, optionalName) {
    this.history.execute(command, optionalName);
  },
  undo: function () {
    this.history.undo();
  },
  redo: function () {
    this.history.redo();
  },
};

这层设计有两个非常重要的点:

  • UI 完全不接触 History,只调用 editor.execute/undo/redo
  • 所有对 Scene 有副作用的操作,都要走 Editor 的“闸门”

这就是 Editor 保持整体可控、易扩展的关键所在。


四、典型命令源码拆解:AddObject 与属性设置

搞懂一个命令,你基本就摸清了整套命令系统的套路。我们看两个高频命令。

1. 添加物体:AddObjectCommand

新版本 Editor 已经全面采用 ES Module + class 写法,这里以 r176 为例看一下真实代码结构:

js
import { Command } from "../Command.js";
import { ObjectLoader } from "three";

class AddObjectCommand extends Command {
  /**
   * @param {Editor} editor
   * @param {THREE.Object3D|null} [object=null]
   * @constructor
   */
  constructor(editor, object = null) {
    super(editor);

    this.type = "AddObjectCommand";

    this.object = object;

    if (object !== null) {
      this.name = editor.strings.getKey("command/AddObject") + ": " + object.name;
    }
  }

  execute() {
    this.editor.addObject(this.object);
    this.editor.select(this.object);
  }

  undo() {
    this.editor.removeObject(this.object);
    this.editor.deselect();
  }

  toJSON() {
    const output = super.toJSON(this);

    output.object = this.object.toJSON();

    return output;
  }

  fromJSON(json) {
    super.fromJSON(json);

    this.object = this.editor.objectByUuid(json.object.object.uuid);

    if (this.object === undefined) {
      const loader = new ObjectLoader();
      this.object = loader.parse(json.object);
    }
  }
}

export { AddObjectCommand };

几个关键点:

  • 命令本身不直接改动场景树,而是调用 editor.addObject/removeObject,复用 Editor 内部关于几何体、材质、辅助对象的管理和信号派发逻辑。
  • toJSON / fromJSON 让命令本身也是可序列化的:
    • 存储时将 this.object.toJSON() 一并写入
    • 读取时优先通过 editor.objectByUuid 找现有对象,找不到就用 ObjectLoader 重新 parse,这样无论是“继续编辑”还是“回放历史”,都能恢复完整操作。

由此可见:AddObjectCommand 自己就带着“撤销脚本”和“序列化脚本”,是一个完全自洽的操作单元。

2. 修改属性:SetValue / SetPosition 类型命令

对于“设置某个属性”的操作,Editor 走的是一个更通用的模式:记录旧值 + 新值

示意版结构如下(简化了不必要的细节):

js
class SetValueCommand extends Command {
  constructor(editor, object, attributeName, newValue, optionalOldValue) {
    super( editor );

    this.type = 'SetValueCommand';
    this.name = editor.strings.getKey( 'command/SetValue' ) + ': ' + attributeName;
    this.updatable = true; // 用于“合并命令”,后面会讲

    this.object = object;
    this.attributeName = attributeName;
    this.oldValue = ( object !== null ) ? object[ attributeName ] : null;
    this.newValue = newValue;
  }

  execute() {
    this.object[ this.attributeName ] = this.newValue;
    this.editor.signals.objectChanged.dispatch( this.object );
  }

  undo() {
    this.object[ this.attributeName ] = this.oldValue;
    this.editor.signals.objectChanged.dispatch( this.object );
  }

  update(command) {
    // 拖动 slider 时,不断用最新的值覆盖
    this.newValue = command.newValue;
  }
}

很多更具体的命令(如 SetPositionCommand),会把 position 当作整体向量来处理,或者拆分为 position.x/y/z,本质上都是“保存旧值 / 新值,然后根据路径写回”。

UI 触发这类命令的代码非常简单,例如在 Sidebar.Object.js 中修改位置:

js
function update() {
    const object = editor.selected;
    if (object !== null) {
        // 根据新值创建新位置
        const newPosition = new THREE.Vector3( objectPositionX.getValue(), objectPositionY.getValue(), objectPositionZ.getValue() );
        // 做一个距离判断,如果移动的距离大于等于0.01,就执行此命令
        if ( object.position.distanceTo( newPosition ) >= 0.01 ) {
            editor.execute( new SetPositionCommand( editor, object, newPosition ) );
        }
    }
    
}

注意这里的分工:

  • UI 完全不关心撤销细节,只负责构造命令并交给 Editor
  • 命令内部负责保存旧值 / 新值,并实现 execute / undo / update

这样的好处是:任何一个支持撤销的操作,只需要新写一个命令类,不会牵一发而动全身。


五、History:两条栈撑起的“时间线”

命令定义好了,要实现“时光机”,就需要一个专门的模块把这些命令串成时间线,这就是 History.js

它内部的核心概念非常简单:

  • undos: Command[] — 已执行、可撤销的命令栈
  • redos: Command[] — 已撤销、可重做的命令栈
  • execute(cmd) — 执行新命令,并推入 undos
  • undo() — 从 undos 弹出一条命令,调用其 undo(),再推入 redos
  • redo() — 反向,从 redos 弹出一条命令,调用其 execute(),再推回 undos

伪代码示意如下(贴近真实实现,略去一些细节):

js
class History {
  constructor(editor) {
    this.editor = editor;

    this.undos = [];
    this.redos = [];

    this.lastCmdTime = Date.now();
  }

  execute(cmd, optionalName) {
    const lastCmd = this.undos[this.undos.length - 1];
    const time = Date.now();

    // 命令合并逻辑(后面展开)
    if (lastCmd && lastCmd.updatable && cmd.updatable && lastCmd.name === cmd.name && time - this.lastCmdTime < 500) {
      lastCmd.update(cmd);
      cmd = lastCmd;
    } else {
      this.undos.push(cmd);
    }

    cmd.execute();
    this.redos = []; // 执行新命令时清空重做栈

    this.editor.signals.historyChanged.dispatch( cmd );

    this.lastCmdTime = time;
  }

  undo() {
    const cmd = this.undos.pop();
    if (cmd === undefined) return;

    cmd.undo();
    this.redos.push(cmd);

    this.editor.signals.historyChanged.dispatch( cmd );
  }

  redo() {
    const cmd = this.redos.pop();
    if (cmd === undefined) return;

    cmd.execute();
    this.undos.push(cmd);

    this.editor.signals.historyChanged.dispatch( cmd );
  }
}

几个关键点:

  1. 新命令执行后会清空 redos

    这就是所有编辑器都有的那条规则:
    你回到过去又干了一件新事,原本的“未来”就不存在了。

  2. History 不直接操作 Scene

    它只负责调用命令对象的 execute / undo
    这保证了历史系统只管理“时间线”,不关心“具体怎么改场景”。

  3. 通过信号系统与 UI 联动

    • historyChanged 信号被菜单栏 / 工具栏订阅
    • Menubar.Edit.js 会根据 undos / redos 长度,决定 Undo / Redo 是否置灰

命令系统 + 历史栈配合信号系统,把“操作”这条线彻底从 UI 与场景中抽离了出来


六、“拖动一次只算一步”:命令合并机制

如果你留心体验 Editor,会发现一个很贴心的细节:

  • 当你拖动 Transform 控件移动物体时
  • 或者拉动侧边栏的数字输入框 / slider 调整数值时
  • 无论你中间抖了多少次,松手之后只算“一步”

这背后就是 History 里非常实用的一个优化:命令合并(Command Merging)

还记得前面 SetValueCommand 里的那个字段吗?

js
this.updatable = true;

命令合并的套路大致如下:

  1. 支持合并的命令,需要:

    • 设置 command.updatable = true
    • 实现 command.update(otherCommand) 方法
  2. History.execute(cmd) 内部会:

  • 拿到上一个命令 lastCmd
    • 如果:
      • lastCmd.updatable === true
      • cmd.updatable === true
      • lastCmd.name === cmd.name(同一类操作)
      • 时间间隔在某个阈值内(例如 400 ~ 500ms)
    • 那么:
    • 不再新 push 一条命令
      • 而是调用 lastCmd.update(cmd) 更新旧命令的参数

伪代码类似:

js
if (lastCmd && lastCmd.updatable && cmd.updatable && lastCmd.name === cmd.name && time - this.lastCmdTime < 500) {
  lastCmd.update(cmd);
} else {
  this.undos.push(cmd);
}

效果非常显著:

  • 拖动 slider 过程中,UI 可能每 16ms 发一次命令
  • 但最终历史里只会留下“从 0 改到 10”这一条
  • 用户撤销时,一步回到 0,而不是“僵尸抖动式后退”

这类小优化,属于既提升体验,又控制体积的高性价比设计,在自己的项目里非常值得借鉴。


七、命令的序列化:让时间线“能保存、能回放”

再往前走一步:历史记录能不能一起保存进项目文件?

在 Editor 里,答案是:可以。

整体思路是:

  1. 每个命令实现 toJSON() / fromJSON()

    • toJSON() 输出:
      • type:命令类型字符串(比如 AddObjectCommand
      • 与场景关联的对象 ID(避免直接引用 JS 对象,通常是UUID)
      • 新旧值、父子关系、索引等必要参数
    • fromJSON(json) 里:
      • 根据 type 找到具体命令类
      • 用 JSON 里的 ID 在 Scene 中找回对象
      • 还原命令内部状态
  2. history.toJSON() / history.fromJSON()

    • undos / redos 两个数组都序列化成 JSON
    • 加入到 Editor 项目的数据结构中

这样一来,整个时间线就变成了可以持久化的“操作日志”

  • 项目保存时,除了场景结构 / 资源引用,还可以顺带带上历史
  • 理论上,你甚至可以实现:
    • 类似“时间轴回放”的功能
    • Bug 复现:加载用户的历史记录,一步步重放
    • 协同编辑时的操作合并(虽然 Editor 没走到这一步,但基础已经具备)

设计上的关键点只有一个:

命令里不要直接保存对象引用,而要保存 ID + 必要参数。
反序列化时再根据 ID 找回对象。


八、从“点击按钮”到“撤销操作”:完整链路梳理

我们用一个真实场景,把上面所有东西串起来:在编辑器中添加一个立方体,然后撤销

  1. 用户点击菜单栏:Add → Box

    • Menubar.Add.js 监听到菜单点击
    • 创建一个 THREE.Mesh 立方体
    • 调用:
    js
    editor.execute(new AddObjectCommand(editor, mesh));
  2. Editor.execute() 收到命令

    js
    execute( command ) {
      this.history.execute( command );
    }
  3. History.execute()

    • 检查是否可以与上一个命令合并(添加物体通常不合并)
    • 把命令压入 undos
    • 调用 command.execute()
    • 清空 redos
    • 通过 signals.historyChanged 通知 UI 更新
  4. AddObjectCommand.execute()

    • 调用 editor.addObject( this.object )
    • Editor.addObject() 内部:
      • 把物体加入 Scene
      • 遍历子节点,注册几何体 / 材质 / 摄像机 / Helper
      • 通过 signals.objectAdded / signals.sceneGraphChanged 通知视口和侧边栏刷新
    • 调用 editor.select( this.object ) 选中刚添加的物体
  5. 用户按下 Ctrl + Z 或点击“撤销”

    • Menubar.Edit.js 监听快捷键 / 菜单,调用 editor.undo()
    • Editor.undo() 调用 history.undo()
    • History.undo()
      • undos 弹出 AddObjectCommand
      • 调用 cmd.undo()
      • 把命令压入 redos
      • 派发 historyChanged 信号
  6. AddObjectCommand.undo()

    • 调用 editor.removeObject( this.object )
    • Editor.removeObject() 内部:
      • 从 Scene 树移除该物体
      • 移除相关摄像机 / Helper 等
      • 派发 objectRemoved / sceneGraphChanged 信号
    • 调用 editor.deselect() 取消选中
  7. UI 更新

    • 视口重新渲染,立方体消失
    • 场景树移除对应节点
    • 编辑菜单根据 undos / redos 状态更新“撤销 / 重做”按钮是否可用

这一整条链路里,只有 Editor 这一层真正操作了 Three.js 的 Scene
UI、历史系统、命令对象,全都围绕 Editor 转,这就是一种非常健康的分层。


九、如何在自己的项目里复用这套设计?

如果你也在写编辑器类应用(不一定是 3D,甚至不一定是前端),Editor 的这套命令系统与撤销机制非常值得复用。可以直接照着这几个步骤落地:

  1. 先约定:所有有副作用的操作,必须是“命令”

    • 添加 / 删除对象
    • 修改属性(位置、旋转、缩放、材质、灯光参数……)
    • 加载 / 替换资源
    • 批量修改(可以拆成多个命令,或者一个组合命令)
  2. 设计一个命令基类

    至少包含:

    ts
    interface Command {
      execute(): void;
      undo(): void;
      toJSON?(): any;
      fromJSON?(json: any): void;
      updatable?: boolean;
      update?(other: Command): void;
    }

    然后所有具体命令 extends Command

  3. 实现一个 History 管理器

    • 内部维护 undos / redos
    • 提供 execute(cmd) / undo() / redo() 接口
    • 新命令执行后清空 redos
    • 控制历史长度(比如只保留最近 N 步),避免内存持续增长
  4. UI 不允许直接改“模型层”,只能发命令

    • React / Vue 组件 / DOM 事件中,只做一件事:构造命令 + 调用 editor.execute(cmd)
    • 真正修改状态的逻辑都集中在 Editor / Model 层
  5. 给高频操作加上“合并命令”

    • slider 拖动、Transform 控件拖动这类高频操作
    • 命令设置 updatable = true,实现 update() 合并逻辑
    • 在 History 中按时间阈值和命令类型决定是否合并
  6. 结合事件 / 信号系统刷新 UI

    • 命令执行后通过事件中心派发:objectAdded / objectChanged / sceneGraphChanged / historyChanged
    • UI 层订阅事件,负责展示与交互

短短几步,你就可以把一个“堆脚本式”的编辑器,升级成有时间线、有撤销、可重放的专业工具雏形。


十、总结:先设计“时间线”,再设计“功能”

把这一篇和《功能与架构全览》、《信号系统与通信设计》放在一起看,你会发现 Editor 有一个非常统一的设计哲学:

  • 信号系统 解耦模块之间的通信
  • 命令系统 + 历史栈 统一管理所有状态变更
  • Editor 作为中枢 把场景、命令、事件、UI 串在一起

命令系统和撤销机制,表面上只是几行 Ctrl + Z 的体验优化,背后却是:

  • 所有业务操作的统一抽象层
  • 可追踪的“操作日志”
  • 将来可扩展的“时间线基础设施”

在实际工程里,不要等功能写完才想起加撤销 / 重做。 更推荐的方式是:

一开始就按“命令 + 历史”的思路设计功能; 把每一步操作,都当成时间线上的一个“命令节点”。

在下一篇里,我们可以继续顺着这条“时间线思维”,去看 Editor 的对象与场景管理机制
对象是如何被统一注册、查找、管理的?场景树与 UI 又是怎样保持同步的?
那将是你搭建一个完整 Three.js 编辑器必须搞清楚的下一块拼图。