Three.js 编辑器源码解析:命令系统与撤销机制
在 3D 编辑器里,撤销(Undo)/ 重做(Redo)就是用户的“后悔药”。
一旦这玩意儿不稳定,用户的信任值会瞬间归零。
Three.js 官方编辑器(下文简称 Editor)虽然只是一个“示例级”项目,但它的命令系统与撤销机制却非常完整,足够支撑你从 0 到 1 搭出一套可维护、可扩展的编辑器“时间线系统”。

在前两篇里,我们已经从功能与架构、信号系统与通信两个角度拆过 Editor。这一篇,我们聚焦一个所有编辑器绕不开的核心能力:
- 用户的每一次操作如何被抽象成“命令”
- 撤销 / 重做是如何在源码中落地的
- Editor 用了哪些“小心机”保证体验和性能
如果你准备自己写一个 Three.js 编辑器,或者给现有业务加上 Undo / Redo,这一篇会非常值回阅读时间。
一、为什么编辑器一定要有“命令系统”?
先想一个简单的问题:要实现撤销 / 重做,最直接的办法是什么?
很多人的第一反应是:
每操作一次,就把整个 Scene(或者关键状态)序列化一份保存下来;
撤销时直接把 Scene 恢复成上一个快照。
这当然可以工作,但问题也非常明显:
- 内存爆炸:场景稍微大一点,每步一份快照,很快就顶不住
- 性能堪忧:频繁序列化 / 反序列化,对主线程是灾难
- 不可读 / 不可控:你只知道“状态变了”,但不知道“发生了什么”
专业编辑器(比如 Photoshop、Unity、Blender),普遍会走另一条路:
不是保存每一帧“结果”,而是记录每一步“操作”本身。
对应到设计模式,就是我们非常熟悉的——命令模式(Command Pattern) + 历史栈(History)。
在 Editor 中,这套设计带来的收益非常直接:
- 所有操作都有统一的抽象:命令对象(Command)
- 每一步都可以
execute()和undo(),天然支持撤销 / 重做 - 命令可以序列化成 JSON,项目保存时连同历史一起保存
- UI 不直接改 Scene,所有变更都通过
Editor.execute()进场
二、先复习一下命令模式:从“直接调用”到“命令对象”
先不用着急看源码,我们用一个极简例子回顾下命令模式。
1. 没有命令模式时的写法
比如移动一个立方体:
// 直接改属性
cube.position.x += 1;撤销怎么办?
要么:
- 在别的地方记一个
oldPosition,撤销时手动写回;
要么:
- 操作前序列化 Scene,撤销时整个恢复。
无论哪种方式,撤销逻辑都散落在各个角落,非常难维护。
2. 引入命令模式后的写法
我们把“移动立方体”抽象成一个命令对象:
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);
}
}然后编辑器的“调度中心”只做一件事:
history.execute(new MoveCubeCommand(cube, new THREE.Vector3(1, 0, 0)));接下来,无论是撤销还是重做,其实都是对命令对象的 undo() / execute() 的调用。
Editor 的命令系统,就是这套思路的一个工程化、可序列化的完整实现。
三、Editor 命令系统全景:三个关键角色
在《功能与架构全览》中,我们已经扫过 Editor 目录结构,这一篇重点关注这几块:
命令基类与命令集合
js/Command.js
命令基类,定义所有命令共有的接口:execute()/undo()/toJSON()/fromJSON()等。注意:虽然
execute()/undo()没有出现在js/Command.js中,但是这两个方法其实也是公共的 API。
具体命令实现
AddObjectCommand.js— 添加物体RemoveObjectCommand.js— 删除物体SetPositionCommand.js— 设置位置SetRotationCommand.js— 设置旋转SetScaleCommand.js— 设置缩放SetMaterialCommand.js— 设置材质SetGeometryCommand.js— 设置几何体- ……以及各种“设置某个属性”的命令
历史管理器
History.js
管理undos/redos两个栈,负责:- 执行新命令:
execute(command) - 撤销:
undo() - 重做:
redo() - 历史序列化 / 反序列化
- 命令合并(拖动时只保留一条记录)
- 执行新命令:
再往上,Editor.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 为例看一下真实代码结构:
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 走的是一个更通用的模式:记录旧值 + 新值。
示意版结构如下(简化了不必要的细节):
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 中修改位置:
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)— 执行新命令,并推入undosundo()— 从undos弹出一条命令,调用其undo(),再推入redosredo()— 反向,从redos弹出一条命令,调用其execute(),再推回undos
伪代码示意如下(贴近真实实现,略去一些细节):
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 );
}
}几个关键点:
新命令执行后会清空
redos栈这就是所有编辑器都有的那条规则:
你回到过去又干了一件新事,原本的“未来”就不存在了。History 不直接操作 Scene
它只负责调用命令对象的
execute/undo。
这保证了历史系统只管理“时间线”,不关心“具体怎么改场景”。通过信号系统与 UI 联动
historyChanged信号被菜单栏 / 工具栏订阅Menubar.Edit.js会根据undos / redos长度,决定 Undo / Redo 是否置灰
命令系统 + 历史栈配合信号系统,把“操作”这条线彻底从 UI 与场景中抽离了出来。
六、“拖动一次只算一步”:命令合并机制
如果你留心体验 Editor,会发现一个很贴心的细节:
- 当你拖动 Transform 控件移动物体时
- 或者拉动侧边栏的数字输入框 / slider 调整数值时
- 无论你中间抖了多少次,松手之后只算“一步”
这背后就是 History 里非常实用的一个优化:命令合并(Command Merging)。
还记得前面 SetValueCommand 里的那个字段吗?
this.updatable = true;命令合并的套路大致如下:
支持合并的命令,需要:
- 设置
command.updatable = true - 实现
command.update(otherCommand)方法
- 设置
History.execute(cmd)内部会:
- 拿到上一个命令
lastCmd- 如果:
lastCmd.updatable === truecmd.updatable === truelastCmd.name === cmd.name(同一类操作)- 时间间隔在某个阈值内(例如 400 ~ 500ms)
- 那么:
- 不再新 push 一条命令
- 而是调用
lastCmd.update(cmd)更新旧命令的参数
- 而是调用
- 如果:
伪代码类似:
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 里,答案是:可以。
整体思路是:
每个命令实现
toJSON()/fromJSON():toJSON()输出:type:命令类型字符串(比如AddObjectCommand)- 与场景关联的对象 ID(避免直接引用 JS 对象,通常是UUID)
- 新旧值、父子关系、索引等必要参数
fromJSON(json)里:- 根据
type找到具体命令类 - 用 JSON 里的 ID 在 Scene 中找回对象
- 还原命令内部状态
- 根据
history.toJSON()/history.fromJSON():- 把
undos/redos两个数组都序列化成 JSON - 加入到 Editor 项目的数据结构中
- 把
这样一来,整个时间线就变成了可以持久化的“操作日志”:
- 项目保存时,除了场景结构 / 资源引用,还可以顺带带上历史
- 理论上,你甚至可以实现:
- 类似“时间轴回放”的功能
- Bug 复现:加载用户的历史记录,一步步重放
- 协同编辑时的操作合并(虽然 Editor 没走到这一步,但基础已经具备)
设计上的关键点只有一个:
命令里不要直接保存对象引用,而要保存 ID + 必要参数。
反序列化时再根据 ID 找回对象。
八、从“点击按钮”到“撤销操作”:完整链路梳理
我们用一个真实场景,把上面所有东西串起来:在编辑器中添加一个立方体,然后撤销。
用户点击菜单栏:
Add → BoxMenubar.Add.js监听到菜单点击- 创建一个
THREE.Mesh立方体 - 调用:
jseditor.execute(new AddObjectCommand(editor, mesh));Editor.execute()收到命令jsexecute( command ) { this.history.execute( command ); }History.execute():- 检查是否可以与上一个命令合并(添加物体通常不合并)
- 把命令压入
undos - 调用
command.execute() - 清空
redos - 通过
signals.historyChanged通知 UI 更新
AddObjectCommand.execute():- 调用
editor.addObject( this.object ) Editor.addObject()内部:- 把物体加入 Scene
- 遍历子节点,注册几何体 / 材质 / 摄像机 / Helper
- 通过
signals.objectAdded/signals.sceneGraphChanged通知视口和侧边栏刷新
- 调用
editor.select( this.object )选中刚添加的物体
- 调用
用户按下
Ctrl + Z或点击“撤销”Menubar.Edit.js监听快捷键 / 菜单,调用editor.undo()Editor.undo()调用history.undo()History.undo():- 从
undos弹出AddObjectCommand - 调用
cmd.undo() - 把命令压入
redos - 派发
historyChanged信号
- 从
AddObjectCommand.undo():- 调用
editor.removeObject( this.object ) Editor.removeObject()内部:- 从 Scene 树移除该物体
- 移除相关摄像机 / Helper 等
- 派发
objectRemoved/sceneGraphChanged信号
- 调用
editor.deselect()取消选中
- 调用
UI 更新
- 视口重新渲染,立方体消失
- 场景树移除对应节点
- 编辑菜单根据
undos / redos状态更新“撤销 / 重做”按钮是否可用
这一整条链路里,只有 Editor 这一层真正操作了 Three.js 的 Scene。
UI、历史系统、命令对象,全都围绕 Editor 转,这就是一种非常健康的分层。
九、如何在自己的项目里复用这套设计?
如果你也在写编辑器类应用(不一定是 3D,甚至不一定是前端),Editor 的这套命令系统与撤销机制非常值得复用。可以直接照着这几个步骤落地:
先约定:所有有副作用的操作,必须是“命令”
- 添加 / 删除对象
- 修改属性(位置、旋转、缩放、材质、灯光参数……)
- 加载 / 替换资源
- 批量修改(可以拆成多个命令,或者一个组合命令)
设计一个命令基类
至少包含:
tsinterface Command { execute(): void; undo(): void; toJSON?(): any; fromJSON?(json: any): void; updatable?: boolean; update?(other: Command): void; }然后所有具体命令
extends Command。实现一个 History 管理器
- 内部维护
undos/redos - 提供
execute(cmd)/undo()/redo()接口 - 新命令执行后清空
redos - 控制历史长度(比如只保留最近 N 步),避免内存持续增长
- 内部维护
UI 不允许直接改“模型层”,只能发命令
- React / Vue 组件 / DOM 事件中,只做一件事:构造命令 + 调用
editor.execute(cmd) - 真正修改状态的逻辑都集中在 Editor / Model 层
- React / Vue 组件 / DOM 事件中,只做一件事:构造命令 + 调用
给高频操作加上“合并命令”
- slider 拖动、Transform 控件拖动这类高频操作
- 命令设置
updatable = true,实现update()合并逻辑 - 在 History 中按时间阈值和命令类型决定是否合并
结合事件 / 信号系统刷新 UI
- 命令执行后通过事件中心派发:
objectAdded/objectChanged/sceneGraphChanged/historyChanged等 - UI 层订阅事件,负责展示与交互
- 命令执行后通过事件中心派发:
短短几步,你就可以把一个“堆脚本式”的编辑器,升级成有时间线、有撤销、可重放的专业工具雏形。
十、总结:先设计“时间线”,再设计“功能”
把这一篇和《功能与架构全览》、《信号系统与通信设计》放在一起看,你会发现 Editor 有一个非常统一的设计哲学:
- 用 信号系统 解耦模块之间的通信
- 用 命令系统 + 历史栈 统一管理所有状态变更
- 用 Editor 作为中枢 把场景、命令、事件、UI 串在一起
命令系统和撤销机制,表面上只是几行 Ctrl + Z 的体验优化,背后却是:
- 所有业务操作的统一抽象层
- 可追踪的“操作日志”
- 将来可扩展的“时间线基础设施”
在实际工程里,不要等功能写完才想起加撤销 / 重做。 更推荐的方式是:
一开始就按“命令 + 历史”的思路设计功能; 把每一步操作,都当成时间线上的一个“命令节点”。
在下一篇里,我们可以继续顺着这条“时间线思维”,去看 Editor 的对象与场景管理机制:
对象是如何被统一注册、查找、管理的?场景树与 UI 又是怎样保持同步的?
那将是你搭建一个完整 Three.js 编辑器必须搞清楚的下一块拼图。
