Three.js编辑器源码解析:信号系统与通信设计
Editor 是一个典型的复杂前端应用,上方的菜单栏、左侧的工具栏、右侧的属性面板、中间的视口等等,各个模块都需要彼此通信,但又不能互相耦合,于是聪明的你一定想到了设计模式中两种关于通信的模式:观察者模式和发布订阅模式。那么Editor中是如何实现的呢,让我们先来复习一下这两种模式。
一、观察者模式(Observer Pattern)
1、定义
观察者模式定义了对象之间的一种一对多依赖关系,当一个对象(被观察者/Subject)的状态发生变化时,所有依赖它的对象(观察者/Observer)都会自动收到通知并更新。
这是典型的“推模型”通信模式。
2、特点
| 特点 | 说明 |
|---|---|
| 耦合度较高 | 观察者直接依赖被观察者,被观察者要维护观察者列表。 |
| 通信方向 | 单向(Subject → Observers)。 |
| 通知机制 | 被观察者主动通知所有观察者。 |
| 常见应用 | Vue2 的响应式系统、DOM 事件机制。 |
3、代码
观察者模式示例:下单通知库存和物流场景。
订单系统(被观察者):
class OrderSystem {
private subscribers: Subscriber[] = [];
// 添加订阅者(注册监听)
addSubscriber(subscriber: Subscriber) {
this.subscribers.push(subscriber);
}
// 通知所有订阅者
notify(orderId: string) {
console.log(`订单系统:订单 ${orderId} 已创建,通知相关服务...`);
this.subscribers.forEach(s => s.update(orderId));
}
// 用户下单
createOrder() {
const orderId = Math.random().toString(36).slice(2, 8);
console.log(`✅ 用户下单成功,订单号:${orderId}`);
this.notify(orderId);
}
}库存系统和物流系统(观察者):
// 定义观察者接口,所有观察者实现该接口
interface Subscriber {
update(orderId: string): void;
}
// 库存系统
class InventorySystem implements Subscriber {
update(orderId: string) {
console.log(`📦 库存系统:收到订单 ${orderId},减少库存`);
}
}
// 物流系统
class DeliverySystem implements Subscriber {
update(orderId: string) {
console.log(`🚚 物流系统:收到订单 ${orderId},准备发货`);
}
}业务调用:
const orderSystem = new OrderSystem();
orderSystem.addSubscriber(new InventorySystem());
orderSystem.addSubscriber(new DeliverySystem());
// 订单系统创建订单
orderSystem.createOrder();运行结果:
✅ 用户下单成功,订单号:x8df4z
订单系统:订单 x8df4z 已创建,通知相关服务...
📦 库存系统:收到订单 x8df4z,减少库存
🚚 物流系统:收到订单 x8df4z,准备发货二、发布订阅模式(Pub/Sub Pattern)
1、定义
发布订阅模式通过一个“事件中心(Event Bus / Broker)”来实现消息的解耦。
发布者(Publisher)不会直接通知订阅者,而是通过事件中心广播事件;
订阅者(Subscriber)通过事件中心接收自己感兴趣的事件。
2、特点
| 特点 | 说明 |
|---|---|
| 耦合度更低 | 发布者与订阅者完全解耦,通过中间事件中心通信。 |
| 通信方向 | 间接(Publisher → EventBus → Subscribers)。 |
| 通知机制 | 事件中心根据事件类型分发消息。 |
| 常见应用 | Node.js 的 EventEmitter、mitt库、前端全局事件总线。 |
3、代码
发布订阅模式示例:事件中心驱动业务。
定义事件中心类:
type EventHandler = (...args: any[]) => void;
class EventBus {
private events = new Map<string, EventHandler[]>();
// 订阅
on(event: string, handler: EventHandler) {
const handlers = this.events.get(event) || [];
handlers.push(handler);
this.events.set(event, handlers);
}
// 发布
emit(event: string, ...args: any[]) {
const handlers = this.events.get(event);
if (handlers) {
handlers.forEach(h => h(...args));
}
}
// 取消订阅
off(event: string, handler: EventHandler) {
const handlers = this.events.get(event);
if (handlers) {
this.events.set(event, handlers.filter(h => h !== handler));
}
}
}不同系统模块:
// 初始化全局事件中心
const eventBus = new EventBus();
// 库存系统
eventBus.on("orderCreated", (orderId: string) => {
console.log(`📦 库存系统:收到事件,订单 ${orderId},减少库存`);
});
// 物流系统
eventBus.on("orderCreated", (orderId: string) => {
console.log(`🚚 物流系统:收到事件,订单 ${orderId},准备发货`);
});
// 用户通知系统
eventBus.on("orderCreated", (orderId: string) => {
console.log(`📩 通知系统:订单 ${orderId} 已创建,发送短信`);
});
// 订单系统(发布事件)
class OrderService {
createOrder() {
const orderId = Math.random().toString(36).slice(2, 8);
console.log(`✅ 用户下单成功,订单号:${orderId}`);
eventBus.emit("orderCreated", orderId);
}
}业务调用:
const orderService = new OrderService();
orderService.createOrder();运行结果:
✅ 用户下单成功,订单号:t3k91s
📦 库存系统:收到事件,订单 t3k91s,减少库存
🚚 物流系统:收到事件,订单 t3k91s,准备发货
📩 通知系统:订单 t3k91s 已创建,发送短信总结:
- 观察者模式强调对象间的直接依赖与通知。
- 发布订阅模式强调通过事件总线实现完全解耦的消息传递。
三、Editor中的通信实现
1、js-signals库
Editor中通过js-signals库实现了各个模块之间的通信。通过其核心源码可以看出这是基于观察者模式实现的一种变体,它抽象出了Signal作为被观察者。Signal有以下重要方法:
- add:添加观察者,即等待调用的方法
- dispatch:派发事件,调用该方法会通知所有add过的方法
- remove:移除观察者,参数为观察者的方法名
其他方法可参见官方仓库的说明。
从官方wiki可以看出,官方推荐的使用方式是根据业务创建一个信号集合,不同业务拥有自己的信号对象:
var Signal = signals.Signal;
// 创建信号集合
var myObject = {
started : new Signal(),
stopped : new Signal()
};这样myObject.started和myObject.stopped就可以各自处理了:
function onStarted(param1, param2){
alert(param1 + param2);
}
myObject.started.add(onStarted);
myObject.started.dispatch('foo', 'bar');
function onStopped(){
alert('stopped');
}
myObject.stopped.add(onStopped);
myObject.stopped.dispatch();在 editor/Editor.js 中,有一个统一的 signals 容器,每种场景的变化,都会各自调用各自的信号。
function Editor() {
const Signal = signals.Signal;
this.signals = {
// ...
objectAdded: new Signal(),
objectChanged: new Signal(),
objectRemoved: new Signal(),
cameraAdded: new Signal(),
cameraRemoved: new Signal(),
helperAdded: new Signal(),
helperRemoved: new Signal(),
materialAdded: new Signal(),
materialChanged: new Signal(),
materialRemoved: new Signal(),
// ...
};
}2、两个场景分析
下面以三个主要的场景来说明Editor内部的通信细节。
2.1 对象选择与视图同步
当用户在视口中点击选择对象时,通过 signals 同步更新视图和侧边栏,同时当点击侧边栏选择对象时需要视口中对应的物体显示选中状态。先看从视口中选择物体的逻辑:
信号触发 - Viewport.js 检测到鼠标点击后,触发 intersectionsDetected 信号:
javascriptfunction handleClick() { if ( onDownPosition.distanceTo( onUpPosition ) === 0 ) { const intersects = selector.getPointerIntersects( onUpPosition, camera ); // 触发intersectionsDetected信号 signals.intersectionsDetected.dispatch( intersects ); render(); } }信号处理 - Selector.js 监听 intersectionsDetected 信号,处理选择逻辑并触发 objectSelected 信号:
javascript// 监听信号,当收到intersectionsDetected后执行select方法选择物体 signals.intersectionsDetected.add( ( intersects ) => { if ( intersects.length > 0 ) { const object = intersects[ 0 ].object; if ( object.userData.object !== undefined ) { this.select( object.userData.object ); } else { this.select( object ); } } else { this.select( null ); } } );javascriptselect( object ) { if ( this.editor.selected === object ) return; let uuid = null; if ( object !== null ) { uuid = object.uuid; } this.editor.selected = object; this.editor.config.setKey( 'selected', uuid ); // 触发objectSelected信号 this.signals.objectSelected.dispatch( object ); }视图更新 - Viewport.js 监听 objectSelected 信号,更新选择框和变换控制器:
javascriptsignals.objectSelected.add( function ( object ) { selectionBox.visible = false; transformControls.detach(); if ( object !== null && object !== scene && object !== camera ) { box.setFromObject( object, true ); if ( box.isEmpty() === false ) { selectionBox.visible = true; } transformControls.attach( object ); } render(); } );侧边栏场景树更新 - Sidebar.Scene.js 监听 objectSelected 信号,更新场景树选中状态:
javascriptsignals.objectSelected.add( function ( object ) { if ( ignoreObjectSelectedSignal === true ) return; if ( object !== null && object.parent !== null ) { let needsRefresh = false; let parent = object.parent; while ( parent !== editor.scene ) { if ( nodeStates.get( parent ) !== true ) { nodeStates.set( parent, true ); needsRefresh = true; } parent = parent.parent; } if ( needsRefresh ) refreshUI(); outliner.setValue( object.id ); } else { outliner.setValue( null ); } } );
到这里已经实现了从视口中选择物体,同时更新侧边栏选中状态的逻辑。
再看从侧边栏选中对象,视口中物体置为选中状态的逻辑:
用户点击侧边栏对象 - Sidebar.Scene.js监听场景树组件的 change 事件:
javascriptoutliner.onChange( function () { ignoreObjectSelectedSignal = true; // 调用editor.selectById(),传入对象id。 editor.selectById( parseInt( outliner.getValue() ) ); ignoreObjectSelectedSignal = false; } );设置 ignoreObjectSelectedSignal = true,避免侧边栏再次更新自身,造成循环更新。
根据id查找对象 - Editor.js 的 selectById 方法根据 id 查找对象:
javascriptselectById: function (id) { if (id === this.camera.id) { this.select(this.camera); return; } this.select(this.scene.getObjectById(id)); }
调用之前出现的select方法更新场景选中状态。
2.2 场景图变化与UI同步
当场景中添加或删除对象时,更新视图和侧边栏。
信号定义 - Editor.js 中定义 sceneGraphChanged 信号:
javascriptsceneGraphChanged: new Signal(),添加对象时触发 - Editor.js 的 addObject 方法在添加对象后触发信号:
javascriptaddObject: function (object, parent, index) { var scope = this; object.traverse(function (child) { if (child.geometry !== undefined) scope.addGeometry(child.geometry); if (child.material !== undefined) scope.addMaterial(child.material); scope.addCamera(child); scope.addHelper(child); }); if (parent === undefined) { this.scene.add(object); } else { parent.children.splice(index, 0, object); object.parent = parent; } this.signals.objectAdded.dispatch(object); // 触发sceneGraphChanged信号 this.signals.sceneGraphChanged.dispatch(); },删除对象时触发 - Editor.js 的 removeObject 方法在删除对象后触发信号:
javascriptremoveObject: function (object) { if (object.parent === null) return; // avoid deleting the camera or scene var scope = this; object.traverse(function (child) { scope.removeCamera(child); scope.removeHelper(child); if (child.material !== undefined) scope.removeMaterial(child.material); }); object.parent.remove(object); this.signals.objectRemoved.dispatch(object); // 触发sceneGraphChanged信号 this.signals.sceneGraphChanged.dispatch(); }设置场景时触发 - Editor.js 的 setScene 方法在批量操作时临时禁用信号,操作完成后统一触发:
javascriptsetScene: function (scene) { // ... this.signals.sceneGraphChanged.active = false; while (scene.children.length > 0) { this.addObject(scene.children[0]); } this.signals.sceneGraphChanged.active = true; // 触发sceneGraphChanged信号 this.signals.sceneGraphChanged.dispatch(); }视图更新 - Viewport.js 监听 sceneGraphChanged 信号,重新初始化路径追踪并渲染:
javascriptsignals.sceneGraphChanged.add( function () { initPT(); render(); } );侧边栏更新 - Sidebar.Scene.js 监听 sceneGraphChanged 信号,刷新场景树 UI:
javascriptsignals.sceneGraphChanged.add( refreshUI );
至此已实现了场景图变化与UI的同步。
四、总结
本文先引入两个经典的事件相关的设计模式,为读者建立了通过事件的方式解耦复杂工程的思维。又介绍了js-signals库的基本使用和在Editor中实际使用的场景。Editor中js-signals库实现通信的方式简单轻量,但是在更复杂的编辑器应用中,往往会选择事件中心作为通信方式,优点是更灵活,同时通信逻辑会更复杂,读者可根据实际场景选择合适的通信方式。
