Babylon.js一帧之旅(十一):原理篇——Observable机制源码解析
2026/9/5 11:06:42 网站建设 项目流程

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

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

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

classObserver<T>{callback:(eventData:T,eventState:EventState)=>void;mask:number;// 掩码,用于过滤通知scope:any;// 回调的 this 绑定unregisterOnNextCall=false;// 下次触发后自动注销_willBeUnregistered=false;// 已标记待删除(延迟注销用)}classObservable<T>{private_observers=newArray<Observer<T>>();// 一个数组,仅此而已private_eventState=newEventState(0);// 复用的事件状态对象// ...}

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

二、注册:add 的五个参数

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

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

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

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

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

三、通知:notifyObservers 的完整拆解

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

publicnotifyObservers(eventData:T,mask:number=-1,...):boolean{if(!this._observers.length)returntrue;// 无观察者,零成本返回// 复用同一个 EventState 对象,填入本次通知的上下文conststate=this._eventState;state.mask=mask;state.skipNextObservers=false;state.lastReturnValue=eventData;for(constobsofthis._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)returnfalse;// 中断后续观察者}returntrue;}

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

决策 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 可以客串"责任链/管道"。这是个很少人用但确实存在的机制。

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

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

publicremove(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 行就能复刻核心:

classMiniObserver<T>{constructor(publiccallback:(data:T)=>void,publiconce=false){}willBeRemoved=false;}classMiniObservable<T>{privateobservers:MiniObserver<T>[]=[];add(callback:(data:T)=>void,once=false):MiniObserver<T>{constobs=newMiniObserver(callback,once);this.observers.push(obs);returnobs;}addOnce(callback:(data:T)=>void){returnthis.add(callback,true);}remove(observer:MiniObserver<T>):void{// 延迟删除:打标记 + 宏任务后物理移除observer.willBeRemoved=true;setTimeout(()=>{consti=this.observers.indexOf(observer);if(i!==-1)this.observers.splice(i,1);},0);}notify(data:T):void{for(constobsofthis.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 机制源码解析

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询