Skip to content

Three.js编辑器源码解析:信号系统与通信设计

​ Editor 是一个典型的复杂前端应用,上方的菜单栏、左侧的工具栏、右侧的属性面板、中间的视口等等,各个模块都需要彼此通信,但又不能互相耦合,于是聪明的你一定想到了设计模式中两种关于通信的模式:观察者模式和发布订阅模式。那么Editor中是如何实现的呢,让我们先来复习一下这两种模式。

一、观察者模式(Observer Pattern)

1、定义

观察者模式定义了对象之间的一种一对多依赖关系,当一个对象(被观察者/Subject)的状态发生变化时,所有依赖它的对象(观察者/Observer)都会自动收到通知并更新。

这是典型的“推模型”通信模式。


2、特点

特点说明
耦合度较高观察者直接依赖被观察者,被观察者要维护观察者列表。
通信方向单向(Subject → Observers)。
通知机制被观察者主动通知所有观察者。
常见应用Vue2 的响应式系统、DOM 事件机制。

3、代码

观察者模式示例:下单通知库存和物流场景。

订单系统(被观察者):

typescript
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);
  }
}

库存系统和物流系统(观察者):

tsx
// 定义观察者接口,所有观察者实现该接口
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},准备发货`);
  }
}

业务调用:

typescript
const orderSystem = new OrderSystem();
orderSystem.addSubscriber(new InventorySystem());
orderSystem.addSubscriber(new DeliverySystem());

// 订单系统创建订单
orderSystem.createOrder();

运行结果:

tsx
✅ 用户下单成功,订单号:x8df4z
	 订单系统:订单 x8df4z 已创建,通知相关服务...
📦 库存系统:收到订单 x8df4z,减少库存
🚚 物流系统:收到订单 x8df4z,准备发货

二、发布订阅模式(Pub/Sub Pattern)

1、定义

发布订阅模式通过一个“事件中心(Event Bus / Broker)”来实现消息的解耦。

发布者(Publisher)不会直接通知订阅者,而是通过事件中心广播事件;

订阅者(Subscriber)通过事件中心接收自己感兴趣的事件。


2、特点

特点说明
耦合度更低发布者与订阅者完全解耦,通过中间事件中心通信。
通信方向间接(Publisher → EventBus → Subscribers)。
通知机制事件中心根据事件类型分发消息。
常见应用Node.js 的 EventEmitter、mitt库、前端全局事件总线。

3、代码

发布订阅模式示例:事件中心驱动业务。

定义事件中心类:

typescript
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));
    }
  }
}

不同系统模块:

tsx
// 初始化全局事件中心
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);
  }
}

业务调用:

typescript
const orderService = new OrderService();
orderService.createOrder();

运行结果:

tsx
✅ 用户下单成功,订单号:t3k91s
📦 库存系统:收到事件,订单 t3k91s,减少库存
🚚 物流系统:收到事件,订单 t3k91s,准备发货
📩 通知系统:订单 t3k91s 已创建,发送短信

总结:

  • 观察者模式强调对象间的直接依赖与通知
  • 发布订阅模式强调通过事件总线实现完全解耦的消息传递

三、Editor中的通信实现

1、js-signals库

Editor中通过js-signals库实现了各个模块之间的通信。通过其核心源码可以看出这是基于观察者模式实现的一种变体,它抽象出了Signal作为被观察者。Signal有以下重要方法:

  • add:添加观察者,即等待调用的方法
  • dispatch:派发事件,调用该方法会通知所有add过的方法
  • remove:移除观察者,参数为观察者的方法名

其他方法可参见官方仓库的说明。

官方wiki可以看出,官方推荐的使用方式是根据业务创建一个信号集合,不同业务拥有自己的信号对象:

typescript
var Signal = signals.Signal;
  
// 创建信号集合
var myObject = {
    started : new Signal(), 
    stopped : new Signal()
};

这样myObject.startedmyObject.stopped就可以各自处理了:

typescript
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 容器,每种场景的变化,都会各自调用各自的信号。

javascript
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 同步更新视图和侧边栏,同时当点击侧边栏选择对象时需要视口中对应的物体显示选中状态。先看从视口中选择物体的逻辑:

  1. 信号触发 - Viewport.js 检测到鼠标点击后,触发 intersectionsDetected 信号:

    javascript
    function handleClick() {
        if ( onDownPosition.distanceTo( onUpPosition ) === 0 ) {
            const intersects = selector.getPointerIntersects( onUpPosition, camera );
            // 触发intersectionsDetected信号
            signals.intersectionsDetected.dispatch( intersects );
            render();
        }
    }
  2. 信号处理 - 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 );
        }
    } );
    javascript
    select( 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 );
    }
  3. 视图更新 - Viewport.js 监听 objectSelected 信号,更新选择框和变换控制器:

    javascript
    signals.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();
    } );
  4. 侧边栏场景树更新 - Sidebar.Scene.js 监听 objectSelected 信号,更新场景树选中状态:

    javascript
    signals.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 );
        }
    } );

到这里已经实现了从视口中选择物体,同时更新侧边栏选中状态的逻辑。

再看从侧边栏选中对象,视口中物体置为选中状态的逻辑:

  1. 用户点击侧边栏对象 - Sidebar.Scene.js监听场景树组件的 change 事件:

    javascript
    outliner.onChange( function () {
        ignoreObjectSelectedSignal = true;
       	// 调用editor.selectById(),传入对象id。
        editor.selectById( parseInt( outliner.getValue() ) );
        ignoreObjectSelectedSignal = false;
    } );

    设置 ignoreObjectSelectedSignal = true,避免侧边栏再次更新自身,造成循环更新。

  2. 根据id查找对象 - Editor.js 的 selectById 方法根据 id 查找对象:

    javascript
    selectById: function (id) {
        if (id === this.camera.id) {
            this.select(this.camera);
            return;
        }
        this.select(this.scene.getObjectById(id));
    }

​ 调用之前出现的select方法更新场景选中状态。

2.2 场景图变化与UI同步

当场景中添加或删除对象时,更新视图和侧边栏。

  1. 信号定义 - Editor.js 中定义 sceneGraphChanged 信号:

    javascript
    sceneGraphChanged: new Signal(),
  2. 添加对象时触发 - Editor.js 的 addObject 方法在添加对象后触发信号:

    javascript
    addObject: 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();
    },
  3. 删除对象时触发 - Editor.js 的 removeObject 方法在删除对象后触发信号:

    javascript
    removeObject: 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();
    }
  4. 设置场景时触发 - Editor.js 的 setScene 方法在批量操作时临时禁用信号,操作完成后统一触发:

    javascript
    setScene: 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();
    }
  5. 视图更新 - Viewport.js 监听 sceneGraphChanged 信号,重新初始化路径追踪并渲染:

    javascript
    signals.sceneGraphChanged.add( function () {
        initPT();
        render();
    } );
  6. 侧边栏更新 - Sidebar.Scene.js 监听 sceneGraphChanged 信号,刷新场景树 UI:

    javascript
    signals.sceneGraphChanged.add( refreshUI );

至此已实现了场景图变化与UI的同步。

四、总结

本文先引入两个经典的事件相关的设计模式,为读者建立了通过事件的方式解耦复杂工程的思维。又介绍了js-signals库的基本使用和在Editor中实际使用的场景。Editor中js-signals库实现通信的方式简单轻量,但是在更复杂的编辑器应用中,往往会选择事件中心作为通信方式,优点是更灵活,同时通信逻辑会更复杂,读者可根据实际场景选择合适的通信方式。