Babylon.js一帧之旅(十一):原理篇——Observable机制源码解析
「Babylon.js 一帧之旅」系列收官篇。前十篇我们沿着一帧的旅程认识了几十个 Observable。它们用法统一、行为一致——这份一致性来自同一个地基一个不到 300 行的Observable类。本篇拆开这个类看看它如何用极简的设计支撑起整个框架的事件体系。读完你会理解为什么遍历时删除观察者要延迟、为什么 EventState 全局复用、mask 位运算解决了什么问题——这些都是高质量面试题。一、数据结构简单得惊人ObservableT的全部家当observable.pure.tsclassObserverT{callback:(eventData:T,eventState:EventState)void;mask:number;// 掩码用于过滤通知scope:any;// 回调的 this 绑定unregisterOnNextCallfalse;// 下次触发后自动注销_willBeUnregisteredfalse;// 已标记待删除延迟注销用}classObservableT{private_observersnewArrayObserverT();// 一个数组仅此而已private_eventStatenewEventState(0);// 复用的事件状态对象// ...}没有链表、没有 Map、没有按事件名分发的字典——一个 Observable 实例代表一种事件内部就是一个观察者数组。Scene 上有几十种事件就是几十个 Observable 实例各自为政。这个一事件一对象的设计是理解一切的起点。二、注册add 的五个参数constobserverobservable.add(callback,// 回调mask-1,// 掩码-1 即全 1默认接收一切insertFirstfalse,// 插到队首优先执行scopeundefined,// 回调执行时的 thisunregisterOnFirstCallfalse// 触发一次后自动注销);两个便捷方法是它的语法糖// 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 对象填入本次通知的上下文conststatethis._eventState;state.maskmask;state.skipNextObserversfalse;state.lastReturnValueeventData;for(constobsofthis._observers){if(obs._willBeUnregistered)continue;// 跳过已标记删除的if(obs.maskmask){// 掩码过滤位运算if(obs.unregisterOnNextCall){this._deferUnregister(obs);// 触发后注销延迟}// 同步调用回调state.lastReturnValueobs.scope?obs.callback.apply(obs.scope,[eventData,state]):obs.callback(eventData,state);}if(state.skipNextObservers)returnfalse;// 中断后续观察者}returntrue;}逐行拆解出五个设计决策每个都是面试点决策 1同步执行无任何队列回调在当前调用栈里立即执行。好处时序完全可预测本系列所有事件顺序分析都依赖这一点代价一个回调抛异常会中断整个循环——notifyObservers 没有 try/catch你的回调炸了排在后面的观察者全部收不到通知。所以回调里的逻辑要么保证不抛异常要么自己包 try/catch。决策 2mask 位运算过滤obs.mask mask一个位与就完成这个观察者要不要接收本次通知。经典应用是scene.onPointerObservable指针事件有 POINTERDOWN / POINTERUP / POINTERMOVE 等类型注册时用 mask 声明只关心哪几种通知时框架把事件类型作为 mask 传入——一次遍历同时完成分发和过滤不需要为每种指针类型维护一个独立事件。// 只关心指针按下mask 过滤的典型用法scene.onPointerObservable.add((pointerInfo){// 只会收到 POINTERDOWN 类型},BABYLON.PointerEventTypes.POINTERDOWN);决策 3EventState 全局复用每个 Observable 只持有一个EventState实例每次通知覆写字段后传给所有回调。如果每帧每事件都 new 一个状态对象60 FPS × 几十种事件 × 若干回调GC 压力可观。复用把分配降为零。推论EventState 只在回调执行期间有效——别把它存起来留给异步代码用下一次通知会覆写它。决策 4EventState.skipNextObservers —— 事件拦截回调里设置eventState.skipNextObservers true当前通知立即中断后续观察者收不到。GUI 系统用它实现控件吞噬点击按钮处理了点击就不让事件继续传给场景拾取。决策 5lastReturnValue 链每个回调的返回值写入state.lastReturnValue下一个回调可以读到上一个的返回值——Observable 可以客串责任链/管道。这是个很少人用但确实存在的机制。四、删除为什么遍历时移除要延迟这是整个实现中最精妙的部分。直接看源码的处理publicremove(observer):boolean{// 如果当前正在通知遍历中或保险起见走延迟注销this._deferUnregister(observer);// ...}public_deferUnregister(observer):void{if(observer._willBeUnregistered)return;observer._willBeUnregisteredtrue;// ① 打标记遍历中会跳过它setTimeout((){this._remove(observer);// ② 当前帧结束后才真正 splice},0);}为什么不能立即 splice假设回调 A 执行时移除了回调 BB 排在 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 行就能复刻核心classMiniObserverT{constructor(publiccallback:(data:T)void,publiconcefalse){}willBeRemovedfalse;}classMiniObservableT{privateobservers:MiniObserverT[][];add(callback:(data:T)void,oncefalse):MiniObserverT{constobsnewMiniObserver(callback,once);this.observers.push(obs);returnobs;}addOnce(callback:(data:T)void){returnthis.add(callback,true);}remove(observer:MiniObserverT):void{// 延迟删除打标记 宏任务后物理移除observer.willBeRemovedtrue;setTimeout((){constithis.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 绑定、优先级调整——全是锦上添花核心骨架就是这些。七、面试高频问题自测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 机制源码解析