tc39 / proposal-signals

A proposal to add signals to JavaScript.
MIT License
2.95k stars 54 forks source link

Don't we need Events/Observer Pattern first? #111

Open rbuckton opened 2 months ago

rbuckton commented 2 months ago

The core functionality of Signals depend on the general principle of the Observer Pattern/Events. Adopting Signals before a standard event mechanism feels like putting the cart before the horse.

IMO, the only reason we don't have a standard event pub/sub mechanism in ECMA262 is that there already is one in the DOM (EventTarget) and NodeJS (EventEmitter). But the lack of a common, general purpose event system has led to situations like AbortSignal dragging along EventTarget's complexity along with it to every non-DOM implementation. We continue to tack on new built-in and host-provided functionality that could be served by a common event system and end up with numerous, disparate ways of handling the same underlying mechanisms:

I'd prefer to see some sort of observer pattern/events mechanism in the core specification before something more advanced like Signals.

mfwgenerics commented 2 months ago

+1 I would be sad if we saw ECMA standardized signals before a lower-level observer primitive. I'd like to see dependency tracking and glitch-free propagation guarantees handled at the observer layer rather than the signals layer.

rbuckton commented 2 months ago

I would be happy with either a lower-level building block here, or even a standardized, symbol-based event protocol that could sit on top of EventTarget/EventEmitter, assuming we could iron out a minimal variant consistent with both.

For example, a bare-bones event protocol might look something like:

interface Evented {
  [Symbol.listen](eventKey: unknown, handler: (...args: any[]) => void): Disposable;
}

Where a Disposable is used for deregistration as a means for an implementation to wrap the underlying deregistration mechanism with a consistent API, e.g.:

EventTarget.prototype[Symbol.listen] = function (eventKey, handler) {
  this.addEventListener(eventKey, handler);
  return { [Symbol.dispose]: () => { this.removeEventListener(eventKey, handler); } };  
};

Alternatively, if function identity-based registration is preferred, we could have a simple protocol like this:

interface Evented {
  [Symbol.listen](eventKey, handler): void;
  [Symbol.unlisten](eventKey, handler): void;
  [Symbol.notify](eventKey, ...args): void;
}

It's more consistent with EventTarget/EventEmitter, though less flexible.

Several years ago I sketched a design for protocol-based events + syntax like so:

class Button {
  event click;
  event mousedown;
  event mouseup;

  #handleClick(m) {
    this::click({ x: m.x, y: m.y }); // looks up an event key and invokes it's handlers
  }
}

const btn = new Button();
const handler = e => { };
btn::click += handler;
btn::click -= handler;

which might be transposed into something like:

class Button {
  #events = new Map([
    ["click", new Set()],
    ["mousedown", new Set()],
    ["mouseup", new Set()]
  ]);
  [Symbol.notify](eventKey, ...args) { ... }
  [Symbol.listen](eventKey, handler) { ... }
  [Symbol.unlisten](eventKey, handler) { ... }

  #handleClick(m) {
    this[Symbol.notify]("click", { x: m.x, y: m.y });
  }
}

const btn = new Button();
const handler = e => { };
btn[Symbol.listen]("click", handler);
btn[Symbol.unlisten]("click", handler);

I used :: since there was an ancient precedent set by IE/JScript where you were allowed to write

function window::onload(e) {}

to attach events.

daKmoR commented 2 months ago

I would be interested in something like a generic performant "not DOM related EventTarget".

Why? We have an app where we have about 150k Objects/Class Instance for (clients, emails, contracts, users ...) each of those fires an event whenever they change so "rendering" Web Component will "rerender"

As clients has multiple contracts and a single user can have multipel contracts we end up with about 400k "EventListener" on those Objects.

We tried using extends EventTarget but that really did not perform. Then we used a custom "EventTarget like solution" => e.g. exactly the same api but nothing to do with DOM and written in JS. Performance was night and day.

Summary: 150k Class Instances 400k Event Listener

using native EventTarget: ~25 seconds loading time using custom EventTarget: ~1 seconds loading time

I would have assume the exact opposite - but it seems EventTarget is doing a lot more then just registering and dispatching Events... => so I assume a native "basic" EventTarget would be even faster then our custom implementation

hlege commented 1 month ago

+1 I'm keen on having an EventBased Signal as well, perhaps something like new Signal.Event(). Additionally, a Signal base class would be more versatile if it exposes APIs like:

class EventSignal {
  connect(...);
  disconnect(...);
  dispose();
  emit(...);
}

class WriteableSignal {
  connect(...);
  disconnect(...);
  dispose();
  get();
  set();
  mutate(...); // should call an fn and after is notify all listener. should be use to update a complex state.
  peek();
  asReadonly();
}

class ReadonlySignal {
  connect(...);
  disconnect(...);
  dispose();
  get();
  peek();
}

This would enhance the usability and flexibility of the Signal class.

backbone87 commented 1 month ago

I think the semantics for signals differ somewhat from that of a classical events/observer pattern. Signals need to be pulled to "do" anything while events are usually pushed. While they could use the same structural interface it may even be confusing for the user since usually interfaces and naming conventions set expectations around their behavior.

littledan commented 1 month ago

The place where an event/observer pattern comes up with Signals is in the Watcher API. It would be great to follow some broader pattern if it can satisfy the following requirements:

Overall, the Observer pattern might be a relatively good fit (as @alxhub proposed several months ago), especially if we permit ourselves the tweak of using the Computed signals directly as the events, rather than wrapping them an extra time. I don't think Events or Observables would meet some of these efficiency goals, especially when it comes to batching or memory allocation.

Computed and State signals do not follow any sort of event/observable/observer pattern, since they are not about explicit subscription/disposal or triggering at particular times. They are generally intended to be used for data dependency graphs. Using them in an event/observable fashion quickly leads to the classic "glitch" patterns and is somehow "push-based". The graph itself has to be based around a "pull-based" organizing principle for it to work.