「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 Observable | DOM addEventListener | Node EventEmitter |
|---|---|---|---|
| 事件模型 | 一事件一对象(强类型) | 字符串事件名 | 字符串事件名 |
| 执行方式 | 同步遍历 | 同步(捕获/冒泡) | 同步 |
| 过滤机制 | mask 位运算 | 事件名即过滤 | 事件名即过滤 |
| 拦截 | skipNextObservers | stopPropagation | 无 |
| 触发后自删 | 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 绑定、优先级调整——全是锦上添花,核心骨架就是这些。
七、面试高频问题(自测)
- Observable 的回调是同步还是异步执行? 同步。
notifyObservers在调用栈内直接遍历执行,唯一的异步是"删除观察者"用 setTimeout 延迟物理移除。 - 为什么遍历中删除观察者要延迟? 数组原地 splice 会导致后续观察者被跳过(索引错位)。先打标记跳过、宏任务后物理删除。
- EventState 为什么复用单个实例? 避免每帧每事件分配对象,消除 GC 压力;代价是状态只在回调执行期有效。
- mask 参数解决什么问题? 一次遍历同时完成事件类型过滤(如指针事件按类型分发),位运算零成本。
- 回调抛异常会怎样? 没有 try/catch 保护,异常中断遍历,后续观察者收不到通知,异常向上抛给调用方(对帧事件来说就是引擎)。
八、系列总结:一张图回到起点
十一篇走完,我们回到第一篇的那句话:记住管线图,就记住了所有事件。而现在你应该比"记住"更深一层——
Engine(节拍器):beginFrame → render → endFrame
Scene(指挥家):
就绪检查 → 动画物理 → 相机更新 → onBeforeRender
→ 渲染目标(阴影/反射原料)→ 逐相机渲染
(剔除 → 粒子 → RTT → 渲染组绘制 → 后处理)
→ onAfterRender → 延迟销毁清理
结束:stopRenderLoop → scene.dispose → engine.dispose
- 每个事件都是这条线上的一个坐标,选挂载点 = 选坐标,原则是"粒度匹配 + 读结果挂结算后";
- 每个 Observable 都是同一个简单类的实例,同步、数组遍历、位掩码过滤——理解它,所有事件的行为都能推导;
- 性能纪律只有一条:回调成本 × 触发频率必须远低于帧预算。
感谢读完整个系列。愿你的每一帧都心中有数。
全系列目录:
- 总纲——Engine 与 Scene 的双层驱动架构
- 起始阶段——引擎创建、资源加载与就绪机制
- 动画与物理——每帧最先发生的事
- 相机系统——输入、视图矩阵与多相机
- 活动网格评估——视锥剔除与可见性判定
- 渲染目标——阴影贴图、RTT 与探针的渲染时机
- 绘制阶段——渲染组、排序与逐网格事件
- 后处理与帧收尾——onAfterRender 之前还发生了什么
- 结束阶段——stopRenderLoop 与 dispose 的正确姿势
- 实战——事件挂载点选择指南与性能优化
- 原理篇——Observable 机制源码解析
:原理篇——Observable机制源码解析&spm=1001.2101.3001.5002&articleId=164358177&d=1&t=3&u=7c5e443cfc384124a458eb0ffeed675a)

被折叠的 条评论
为什么被折叠?



