Ébauche de proposition de standardisation de JavaScript Signals
- Document décrivant une direction commune initiale pour les signaux (
signals) en JavaScript, dans un esprit similaire aux efforts Promises/A+ ayant précédé la standardisation des Promises par le TC39 avec ES2015.
- Cet effort vise à coordonner l’écosystème JavaScript, et si cette coordination réussit, une norme pourra émerger à partir de cette expérience.
- Plusieurs auteurs de frameworks collaborent autour d’un modèle commun pouvant servir de socle à un cœur réactif.
- Le brouillon actuel s’appuie sur des contributions de conception d’auteurs et de mainteneurs d’Angular, Bubble, Ember, FAST, MobX, Preact, Qwik, RxJS, Solid, Starbeam, Svelte, Vue, Wiz, entre autres.
Contexte : pourquoi les signals ?
- Pour développer des interfaces utilisateur (UI) complexes, les développeurs d’applications JavaScript doivent pouvoir stocker, calculer, invalider, synchroniser et propager l’état vers la couche de vue de l’application de manière efficace.
- Une UI ne se limite pas à gérer des valeurs simples : elle implique souvent l’affichage d’un état calculé dépendant d’autres valeurs ou états.
- L’objectif des signals est de fournir l’infrastructure nécessaire à la gestion de cet état applicatif afin que les développeurs puissent se concentrer sur la logique métier plutôt que sur des détails répétitifs.
Exemple - compteur en VanillaJS
- On dispose d’une variable
counter, et l’on souhaite mettre à jour dans le DOM la parité du compteur chaque fois que cette variable change.
- En Vanilla JS, on pourrait avoir un code comme celui-ci :
let counter = 0;
const setCounter = (value) => {
counter = value;
render();
};
const isEven = () => (counter & 1) == 0;
const parity = () => isEven() ? "even" : "odd";
const render = () => element.innerText = parity();
// Simulate external updates to counter...
setInterval(() => setCounter(counter + 1), 1000);
- Ce code présente plusieurs problèmes :
- l’affectation de
counter est verbeuse et pleine de boilerplate ;
- l’état de
counter est étroitement couplé au système de rendu ;
- lorsque
counter change mais que parity ne change pas (par exemple de 2 à 4), des calculs et rendus inutiles sont effectués ;
- si d’autres parties de l’UI veulent ne se rendre qu’au moment des mises à jour de
counter ;
- d’autres parties de l’UI qui ne dépendent que de
isEven ou parity ne peuvent pas être mises à jour sans interagir directement avec counter.
Introduction aux signals
- Les abstractions de liaison de données entre modèle et vue sont depuis longtemps au cœur des frameworks UI, même si ni JS ni la plateforme web n’intègrent nativement un tel mécanisme.
- Au sein des frameworks et bibliothèques JS, de nombreuses expérimentations ont été menées sur les différentes manières de représenter ces liaisons, et l’approche consistant à utiliser des valeurs réactives de premier ordre représentant un état ou des calculs dérivés d’autres données, souvent appelées « Signals », a démontré sa puissance.
- Si l’on réimagine l’exemple ci-dessus avec une API de signals, on obtient ceci :
const counter = new Signal.State(0);
const isEven = new Signal.Computed(() => (counter.get() & 1) == 0);
const parity = new Signal.Computed(() => isEven.get() ? "even" : "odd");
// A library or framework defines effects based on other Signal primitives
declare function effect(cb: () => void): (() => void);
effect(() => element.innerText = parity.get());
// Simulate external updates to counter...
setInterval(() => counter.set(counter.get() + 1), 1000);
Motivations pour la standardisation des signals
Interopérabilité
- Chaque implémentation de signals possède son propre mécanisme de suivi automatique, ce qui rend difficile le partage de modèles, de composants et de bibliothèques entre frameworks.
- Cette proposition vise à séparer complètement le modèle réactif de la vue de rendu, afin que les développeurs n’aient pas à réécrire leur code non-UI lorsqu’ils migrent vers une nouvelle technologie de rendu, ou puissent développer en JS des modèles réactifs partagés déployables dans d’autres contextes.
Performances / utilisation mémoire
- Le fait d’envoyer moins de code grâce à une bibliothèque couramment utilisée intégrée apporte toujours un petit gain potentiel de performance, mais comme les implémentations de signals sont généralement assez petites, on ne s’attend pas à ce que cet effet soit très important.
Outils pour développeurs
- Avec les bibliothèques de signals existantes en JS, il est difficile de suivre la pile d’appels à travers une chaîne de signals calculés, le graphe de références entre signals, etc.
- Des signals intégrés permettraient au runtime JS et aux outils de développement d’offrir un meilleur support pour leur inspection.
Bénéfices annexes
Avantages de la bibliothèque standard
- Historiquement, JavaScript a eu une bibliothèque standard plutôt minimale, mais la tendance au TC39 est d’en faire un langage « batteries incluses » avec un ensemble de fonctionnalités intégrées de haute qualité.
Intégration HTML/DOM (possibilité future)
- Le W3C et les implémenteurs de navigateurs travaillent actuellement à l’introduction de templates natifs dans HTML.
- Pour atteindre cet objectif, HTML aura à terme besoin de primitives réactives.
Objectifs de conception des signals
- Les bibliothèques de signals existantes ne diffèrent pas énormément sur le fond.
- Cette proposition cherche à s’appuyer sur leurs réussites en implémentant des caractéristiques importantes communes à de nombreuses bibliothèques.
Fonctionnalités clés
- Un type
Signal représentant l’état, c’est-à-dire un Signal inscriptible.
- Un type de
Signal calculé / mémoïsé / dérivé qui dépend d’autres signals, se calcule paresseusement et est mis en cache.
- La possibilité pour les frameworks JS de gérer leur propre ordonnancement.
Esquisse d’API
- L’idée initiale d’API pour les signals est la suivante. Il ne s’agit que d’un premier brouillon, qui devrait évoluer avec le temps.
namespace Signal {
// A read-write Signal
class State<T> implements Signal<T> {
// Create a state Signal starting with the value t
constructor(t: T, options?: SignalOptions<T>);
// Get the value of the signal
get(): T;
// Set the state Signal value to t
set(t: T): void;
}
// A Signal which is a formula based on other Signals
class Computed<T> implements Signal<T> {
// Create a Signal which evaluates to the value returned by the callback.
// Callback is called with this signal as the this value.
constructor(cb: (this: Computed<T>) => T, options?: SignalOptions<T>);
// Get the value of the signal
get(): T;
}
// This namespace includes "advanced" features that are better to
// leave for framework authors rather than application developers.
// Analogous to `crypto.subtle`
namespace subtle {
// Run a callback with all tracking disabled (even for nested computed).
function untrack<T>(cb: () => T): T;
// Get the current computed signal which is tracking any signal reads, if any
function currentComputed(): Computed | null;
// Returns ordered list of all signals which this one referenced
// during the last time it was evaluated.
// For a Watcher, lists the set of signals which it is watching.
function introspectSources(s: Computed | Watcher): (State | Computed)[];
// Returns the Watchers that this signal is contained in, plus any
// Computed signals which read this signal last time they were evaluated,
// if that computed signal is (recursively) watched.
function introspectSinks(s: State | Computed): (Computed | Watcher)[];
// True if this signal is "live", in that it is watched by a Watcher,
// or it is read by a Computed signal which is (recursively) live.
function hasSinks(s: State | Computed): boolean;
// True if this element is "reactive", in that it depends
// on some other signal. A Computed where hasSources is false
// will always return the same constant.
function hasSources(s: Computed | Watcher): boolean;
class Watcher {
// When a (recursive) source of Watcher is written to, call this callback,
// if it hasn't already been called since the last `watch` call.
// No signals may be read or written during the notify.
constructor(notify: (this: Watcher) => void);
// Add these signals to the Watcher's set, and set the watcher to run its
// notify callback next time any signal in the set (or one of its dependencies) changes.
// Can be called with no arguments just to reset the "notified" state, so that
// the notify callback will be invoked again.
watch(...s: Signal[]): void;
// Remove these signals from the watched set (e.g., for an effect which is disposed)
unwatch(...s: Signal[]): void;
// Returns the set of sources in the Watcher's set which are still dirty, or is a computed signal
// with a source which is dirty or pending and hasn't yet been re-evaluated
getPending(): Signal[];
}
// Hooks to observe being watched or no longer watched
var watched: Symbol;
var unwatched: Symbol;
}
interface Options<T> {
// Custom comparison function between old and new value. Default: Object.is.
// The signal is passed in as the this value for context.
equals?: (this: Signal<T>, t: T, t2: T) => boolean;
// Callback called when isWatched becomes true, if it was previously false
[Signal.subtle.watched]?: (this: Signal<T>) => void;
// Callback called whenever isWatched becomes false, if it was previously true
[Signal.subtle.unwatched]?: (this: Signal<T>) => void;
}
}
Algorithme des signals
- Décrit les algorithmes implémentés pour chaque API exposée à JavaScript.
- On peut y voir une spécification initiale, qui cherche à fixer autant que possible un ensemble de sémantiques malgré des évolutions encore très ouvertes.
Avis de GN⁺
- La proposition de standardisation de JavaScript Signals vise à améliorer l’interopérabilité entre frameworks et à faciliter la mise en œuvre de la programmation réactive par les développeurs.
- Cette proposition constitue une tentative de standardiser les fonctionnalités de base de plusieurs bibliothèques de signals existantes, ce qui pourrait offrir aux développeurs un modèle de programmation cohérent.
- Le concept de signals peut être utile non seulement pour le développement UI, mais aussi dans des contextes non UI, notamment pour éviter des rebuilds inutiles dans les systèmes de build.
- L’API proposée fournit des outils utiles aux développeurs de frameworks, avec l’espoir d’obtenir de meilleures performances et une meilleure gestion mémoire.
- Cependant, pour que cette technologie soit largement adoptée, davantage de prototypage et de retours de la communauté seront nécessaires, et son efficacité devra être démontrée dans de vraies applications.
- Des frameworks comme React, Vue et Svelte disposent déjà de leurs propres systèmes réactifs, et les questions de compatibilité ou de stratégie d’intégration avec ces frameworks constitueront aussi un point important.
1 commentaires
Avis Hacker News
Exemple Vanilla JS vs. Signals
isEvenou deparity, il pourrait être nécessaire de revoir toute l’approche.Promises et l’évolution de JavaScript
new Promise, mais en pratique je ne m’en suis presque jamais servi..then, ce qui simplifie l’interface avec diverses bibliothèques tierces.Signals comme partie intégrante du langage
Utilisation des événements dans les applications
window.dispatchEventetwindow.addEventListener.La difficulté de la gestion d’état du DOM et des mises à jour
Promises et programmation asynchrone
S.js et Signals
Des Signals proches de MobX
Ajouter un framework à la bibliothèque standard
Compréhension et problèmes de la proposition Signal
effectdétecte les changements deparity, et si cette lambda est appelée à chaque modification de signal.