Babylon.js一帧之旅(十一):原理篇——Observable机制源码解析

「Babylon.js 一帧之旅」系列收官篇。前十篇我们沿着一帧的旅程,认识了几十个 Observable。它们用法统一、行为一致——这份一致性来自同一个地基:一个不到 300 行的 Observable 类。本篇拆开这个类,看看它如何用极简的设计支撑起整个框架的事件体系。读完你会理解:为什么"遍历时删除观察者"要延迟、为什么 EventState 全局复用、mask 位运算解决了什么问题——这些都是高质量面试题。

一、数据结构:简单得惊人

Observable<T> 的全部家当(observable.pure.ts):

class Observer<T> {
    callback: (eventData: T, eventState: EventState) => void;
    mask: number;                        // 掩码,用于过滤通知
    scope: any;                          // 回调的 this 绑定
    unregisterOnNextCall = false;        // 下次触发后自动注销
    _willBeUnregistered = false;         // 已标记待删除(延迟注销用)
}

class Observable<T> {
    private _observers = new Array<Observer<T>>();   // 一个数组,仅此而已
    private _eventState = new EventState(0);         // 复用的事件状态对象
    // ...
}

没有链表、没有 Map、没有按事件名分发的字典——一个 Observable 实例代表一种事件,内部就是一个观察者数组。Scene 上有几十种事件,就是几十个 Observable 实例各自为政。这个"一事件一对象"的设计是理解一切的起点。

二、注册:add 的五个参数

const observer = observable.add(
    callback,                 // 回调
    mask = -1,                // 掩码(-1 即全 1,默认接收一切)
    insertFirst = false,      // 插到队首(优先执行)
    scope = undefined,        // 回调执行时的 this
    unregisterOnFirstCall = false  // 触发一次后自动注销
);

两个便捷方法是它的语法糖:

// addOnce 等价于 unregisterOnFirstCall = true
observable.addOnce(cb);
// 等价于 observable.add(cb, -1, false, undefined, true);

还有一对调整执行顺序的 API(数组头部先执行):

observable.makeObserverTopPriority(observer);     // 提到队首
observable.makeObserverBottomPriority(observer);  // 压到队尾

三、通知:notifyObservers 的完整拆解

核心方法,值得逐行读懂(源码精简版):

public notifyObservers(eventData: T, mask: number = -1, ...): boolean {
    if (!this._observers.length) return true;   // 无观察者,零成本返回

    // 复用同一个 EventState 对象,填入本次通知的上下文
    const state = this._eventState;
    state.mask = mask;
    state.skipNextObservers = false;
    state.lastReturnValue = eventData;

    for (const obs of this._observers) {
        if (obs._willBeUnregistered) continue;      // 跳过已标记删除的

        if (obs.mask & mask) {                       // 掩码过滤(位运算)
            if (obs.unregisterOnNextCall) {
                this._deferUnregister(obs);          // 触发后注销(延迟!)
            }
            // 同步调用回调
            state.lastReturnValue = obs.scope
                ? obs.callback.apply(obs.scope, [eventData, state])
                : obs.callback(eventData, state);
        }
        if (state.skipNextObservers) return false;   // 中断后续观察者
    }
    return true;
}

逐行拆解出五个设计决策,每个都是面试点:

决策 1:同步执行,无任何队列

回调在当前调用栈里立即执行。好处:时序完全可预测(本系列所有事件顺序分析都依赖这一点);代价:一个回调抛异常会中断整个循环——notifyObservers 没有 try/catch,你的回调炸了,排在后面的观察者全部收不到通知。所以回调里的逻辑要么保证不抛异常,要么自己包 try/catch。

决策 2:mask 位运算过滤

obs.mask & mask 一个位与就完成"这个观察者要不要接收本次通知"。经典应用是 scene.onPointerObservable:指针事件有 POINTERDOWN / POINTERUP / POINTERMOVE 等类型,注册时用 mask 声明只关心哪几种,通知时框架把事件类型作为 mask 传入——一次遍历同时完成分发和过滤,不需要为每种指针类型维护一个独立事件。

// 只关心指针按下:mask 过滤的典型用法
scene.onPointerObservable.add((pointerInfo) => {
    // 只会收到 POINTERDOWN 类型
}, BABYLON.PointerEventTypes.POINTERDOWN);

决策 3:EventState 全局复用

每个 Observable 只持有一个 EventState 实例,每次通知覆写字段后传给所有回调。如果每帧每事件都 new 一个状态对象,60 FPS × 几十种事件 × 若干回调,GC 压力可观。复用把分配降为零。

推论:EventState 只在回调执行期间有效——别把它存起来留给异步代码用,下一次通知会覆写它。

决策 4:EventState.skipNextObservers —— 事件拦截

回调里设置 eventState.skipNextObservers = true,当前通知立即中断,后续观察者收不到。GUI 系统用它实现"控件吞噬点击":按钮处理了点击,就不让事件继续传给场景拾取。

决策 5:lastReturnValue 链

每个回调的返回值写入 state.lastReturnValue,下一个回调可以读到上一个的返回值——Observable 可以客串"责任链/管道"。这是个很少人用但确实存在的机制。

四、删除:为什么"遍历时移除"要延迟

这是整个实现中最精妙的部分。直接看源码的处理:

public remove(observer): boolean {
    // 如果当前正在通知遍历中(或保险起见),走延迟注销
    this._deferUnregister(observer);
    // ...
}

public _deferUnregister(observer): void {
    if (observer._willBeUnregistered) return;
    observer._willBeUnregistered = true;        // ① 打标记,遍历中会跳过它
    setTimeout(() => {
        this._remove(observer);                  // ② 当前帧结束后才真正 splice
    }, 0);
}

为什么不能立即 splice? 假设回调 A 执行时移除了回调 B(B 排在 A 后面):

遍历前:[A, B, C],指针指向 A
A 执行时 splice 掉 B:[A, C]
for 循环的索引 +1,指向了下标 1 —— 也就是 C 原来的位置之后
结果:C 被跳过,永远没收到这次通知

这就是数组遍历中原地删除导致的跳过问题。源码注释原话:“This should only be called when not iterating over _observers to avoid callback skipping.”

延迟注销的解法分两步:遍历时用标记位让被删观察者"逻辑上消失"(_willBeUnregistered 跳过),物理删除用 setTimeout(0) 推迟到遍历结束后。既保证本次通知的行为正确(被删者不再收到),又不破坏数组索引。

推论(实用)remove 不是严格同步生效的——刚 remove 的观察者,在同一个宏任务里不会被调用(标记位拦住了),但要等 setTimeout 回调执行后才真正从数组消失。对使用者来说这完全透明,但理解它能解释一些"删了好像还触发"的错觉。

五、与其他事件机制的对比

维度Babylon ObservableDOM addEventListenerNode EventEmitter
事件模型一事件一对象(强类型)字符串事件名字符串事件名
执行方式同步遍历同步(捕获/冒泡)同步
过滤机制mask 位运算事件名即过滤事件名即过滤
拦截skipNextObserversstopPropagation
触发后自删addOnce / unregisterOnFirstCall{ once: true }once()
删除时机延迟(setTimeout)立即立即(有类似跳过问题)

Babylon 的设计明显为高频、热路径优化:位运算过滤、状态对象复用、数组遍历缓存友好——一切为了 60FPS 下每帧几十种事件的零负担分发。

六、手写一个迷你 Observable

理解了原理,30 行就能复刻核心:

class MiniObserver<T> {
    constructor(
        public callback: (data: T) => void,
        public once = false
    ) {}
    willBeRemoved = false;
}

class MiniObservable<T> {
    private observers: MiniObserver<T>[] = [];

    add(callback: (data: T) => void, once = false): MiniObserver<T> {
        const obs = new MiniObserver(callback, once);
        this.observers.push(obs);
        return obs;
    }

    addOnce(callback: (data: T) => void) {
        return this.add(callback, true);
    }

    remove(observer: MiniObserver<T>): void {
        // 延迟删除:打标记 + 宏任务后物理移除
        observer.willBeRemoved = true;
        setTimeout(() => {
            const i = this.observers.indexOf(observer);
            if (i !== -1) this.observers.splice(i, 1);
        }, 0);
    }

    notify(data: T): void {
        for (const obs of this.observers) {
            if (obs.willBeRemoved) continue;
            if (obs.once) this.remove(obs);
            obs.callback(data);   // 同步执行,异常会中断——和真身一致
        }
    }
}

真实版本多出的是:mask 过滤、EventState 上下文、scope 绑定、优先级调整——全是锦上添花,核心骨架就是这些。

七、面试高频问题(自测)

  1. Observable 的回调是同步还是异步执行? 同步。notifyObservers 在调用栈内直接遍历执行,唯一的异步是"删除观察者"用 setTimeout 延迟物理移除。
  2. 为什么遍历中删除观察者要延迟? 数组原地 splice 会导致后续观察者被跳过(索引错位)。先打标记跳过、宏任务后物理删除。
  3. EventState 为什么复用单个实例? 避免每帧每事件分配对象,消除 GC 压力;代价是状态只在回调执行期有效。
  4. mask 参数解决什么问题? 一次遍历同时完成事件类型过滤(如指针事件按类型分发),位运算零成本。
  5. 回调抛异常会怎样? 没有 try/catch 保护,异常中断遍历,后续观察者收不到通知,异常向上抛给调用方(对帧事件来说就是引擎)。

八、系列总结:一张图回到起点

十一篇走完,我们回到第一篇的那句话:记住管线图,就记住了所有事件。而现在你应该比"记住"更深一层——

Engine(节拍器):beginFrame → render → endFrame
Scene(指挥家):
  就绪检查 → 动画物理 → 相机更新 → onBeforeRender
  → 渲染目标(阴影/反射原料)→ 逐相机渲染
    (剔除 → 粒子 → RTT → 渲染组绘制 → 后处理)
  → onAfterRender → 延迟销毁清理
结束:stopRenderLoop → scene.dispose → engine.dispose
  • 每个事件都是这条线上的一个坐标,选挂载点 = 选坐标,原则是"粒度匹配 + 读结果挂结算后";
  • 每个 Observable 都是同一个简单类的实例,同步、数组遍历、位掩码过滤——理解它,所有事件的行为都能推导;
  • 性能纪律只有一条:回调成本 × 触发频率必须远低于帧预算。

感谢读完整个系列。愿你的每一帧都心中有数。

全系列目录:

  1. 总纲——Engine 与 Scene 的双层驱动架构
  2. 起始阶段——引擎创建、资源加载与就绪机制
  3. 动画与物理——每帧最先发生的事
  4. 相机系统——输入、视图矩阵与多相机
  5. 活动网格评估——视锥剔除与可见性判定
  6. 渲染目标——阴影贴图、RTT 与探针的渲染时机
  7. 绘制阶段——渲染组、排序与逐网格事件
  8. 后处理与帧收尾——onAfterRender 之前还发生了什么
  9. 结束阶段——stopRenderLoop 与 dispose 的正确姿势
  10. 实战——事件挂载点选择指南与性能优化
  11. 原理篇——Observable 机制源码解析
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值