Lumino 的 signaling 和 messaging:状态与生命周期的底噪

这篇就把 Widget 背后的两条“暗线”单拎出来:signalingmessaging
一个负责「谁对谁说话」,一个负责「什么时候该说话」,很多看起来“魔法般自动”的行为,其实都是这两套机制在背后运转。

我自己的粗暴理解:

  • signaling:更像是“业务层事件/信号系统”,解决的是——
    「A 的状态变了,B 想知道」
    • 典型场景:模型变化通知视图、多个 Widget 之间同步选中状态等。
  • messaging:更偏“框架内部的消息循环”,解决的是——
    「这个 Widget 现在应该执行哪个生命周期钩子」
    • 典型场景:onResize / onUpdateRequest / onAfterAttach 等这些钩子,其实都是通过 messaging 派发进来的。

一句话:
signaling 是「业务信号」;messaging 是「UI 框架自己的消息泵和生命周期调度」。

先看一个 @lumino/signaling 的最小例子,感受一下 API 风格:

 1import { ISignal, Signal } from '@lumino/signaling';
 2
 3// 一个简单的模型对象
 4class CounterModel {
 5  private _value = 0;
 6  private _changed = new Signal<CounterModel, number>(this);
 7
 8  // 对外暴露只读的 signal
 9  get changed(): ISignal<CounterModel, number> {
10    return this._changed;
11  }
12
13  get value(): number {
14    return this._value;
15  }
16
17  increment(): void {
18    this._value++;
19    // 发出“我变了”的信号
20    this._changed.emit(this._value);
21  }
22}
23
24// 某个监听它的视图/Widget
25const model = new CounterModel();
26
27model.changed.connect((sender, value) => {
28  console.log('Counter changed:', sender, value);
29});
30
31model.increment(); // 控制台会输出 Counter changed: ...

几个细节:

  • Signal<Sender, Args> 里的第一个泛型是“信号的发送者类型”,第二个是“携带的数据类型”。
  • connect 接受一个回调 (sender, args) => {},这点跟 Qt 的 signal/slot 有点神似。
  • Widget 通常不会自己 new 一个 Signal,而是:
    • 要么监听别的对象的信号。
    • 要么在某个“模型对象”里管理 signal,然后 Widget 订阅它。

在 Theia 这种架构里,signaling 非常适合放在「状态/模型」那一层,让 UI 只是订阅并响应,而不是自己维护一堆 EventEmitter。

再来一个「模型 + Widget」的小组合,接上上一篇的 Widget 思路:

 1import { ISignal, Signal } from '@lumino/signaling';
 2import { Widget } from '@lumino/widgets';
 3
 4class CounterModel {
 5  private _value = 0;
 6  private _changed = new Signal<CounterModel, number>(this);
 7
 8  get changed(): ISignal<CounterModel, number> {
 9    return this._changed;
10  }
11
12  get value(): number {
13    return this._value;
14  }
15
16  increment(): void {
17    this._value++;
18    this._changed.emit(this._value);
19  }
20}
21
22class CounterView extends Widget {
23  constructor(private model: CounterModel) {
24    super();
25    this.addClass('my-CounterView');
26
27    // 订阅模型变化
28    this.model.changed.connect(this.onModelChanged, this);
29  }
30
31  protected onAfterAttach(msg: any): void {
32    this._render();
33    this.node.addEventListener('click', () => {
34      this.model.increment();
35    });
36  }
37
38  private onModelChanged(sender: CounterModel, value: number): void {
39    this._render();
40  }
41
42  private _render(): void {
43    this.node.textContent = `Clicked: ${this.model.value}`;
44  }
45}

这里可以看到几个小模式:

  • Widget 不直接修改自己的“业务状态”,而是通过 model 来改。
  • Widget 通过 signal.connect 订阅变化,然后在回调里调用 _render()
  • 从架构味道上讲,这和常见的 MVVM / 状态管理思路是一脉相承的,只是工具换成了 Lumino 的 Signal。

再来看 messaging。大多数时候我们只会在 Widget 里重写这些方法:

  • onAfterAttach
  • onBeforeDetach
  • onResize
  • onUpdateRequest
  • ……

但在 Lumino 内部,这些并不是直接「某处手动调用」,而是通过一个消息系统来派发:

 1import { Message, MessageLoop } from '@lumino/messaging';
 2import { Widget } from '@lumino/widgets';
 3
 4class MyWidget extends Widget {
 5  protected onUpdateRequest(msg: Message): void {
 6    console.log('update request:', msg);
 7  }
 8}
 9
10const w = new MyWidget();
11
12// 手动触发一次 update 消息
13MessageLoop.sendMessage(w, Widget.Msg.UpdateRequest);

MessageLoop.sendMessage 会把一条消息送进 Widget 的处理流程里,最终调用到 onUpdateRequest
在正常使用 DockPanel / Layout 的时候,这一切都是框架帮你调度好的,你只要实现对应的钩子函数就行。

还有几个配套概念:

  • Widget.Msg.UpdateRequest / ResizeRequest / AfterAttach 等是一些内置消息常量。
  • MessageLoop.postMessage 则是异步排队发送,在下一轮消息循环里处理,避免同步调用导致递归问题。

可以理解为:messaging 是让 Widget 的生命周期更像“消息驱动”的,而不是直接函数调用。

我自己在看 Theia / Lumino 源码时,通常会这样区分两条线:

  • 看到 Signal / .connect() / .emit()
    • 把它当成“业务事件”,去想「谁在产生这个状态变化」「谁在消费它」。
    • 这部分逻辑通常跟 UI 框架解耦,迁移到别的 UI 技术栈也可以重用。
  • 看到 onXXXRequest / MessageLoop / Widget.Msg.*
    • 把它当成“UI 框架调度”,去想「这个 Widget 在什么时机会收到这些消息」。
    • 这部分逻辑高度绑定 Lumino 的布局和渲染机制。

在调试问题时也很有用:

  • 如果是「状态没同步过去」→ 大概率看 signaling 的连接和 emit。
  • 如果是「界面渲染/尺寸不对」→ 大概率看 messaging 对 onResize / onUpdateRequest 是否按预期触发。

对我个人来说,signaling 和 messaging 有点像 Lumino 这套体系里的“底噪”:

  • 不刻意关注的时候,你只会看到 Widget、Panel、DockPanel 这些“可视”的部分。
  • 但真想在 Theia 里写点更贴近底层的扩展,或者想搞明白一些诡异的刷新/状态问题时,最后都会顺藤摸瓜摸到这两块。

写这一篇,更像是给自己立一个“心智坐标系”:
以后看到 Signal / MessageLoop 相关的调用时,脑子里能立刻知道——
这是在搭“谁对谁说话”的桥,还是在调度“什么时候说话”的节奏。