Étape 1 (explication)
Champions de la proposition TC39 : Daniel Ehrenberg, Yehuda Katz, Jatin Ramanathan, Shay Lewis, Kristen Hewell Garrett, Dominic Gannaway, Preston Sego, Milo M, Rob Eisenberg
Auteurs originaux : Rob Eisenberg et Daniel Ehrenberg
Ce document décrit une première direction commune pour les signaux en JavaScript, similaire à l'effort Promises/A+ qui a précédé les Promises normalisées par TC39 dans ES2015. Essayez-le par vous-même, en utilisant un polyfill.
À l’instar de Promises/A+, cet effort se concentre sur l’alignement de l’écosystème JavaScript. Si cet alignement réussit, une norme pourrait alors émerger, basée sur cette expérience. Plusieurs auteurs de framework collaborent ici sur un modèle commun qui pourrait soutenir leur noyau de réactivité. La version actuelle est basée sur les contributions de conception des auteurs/mainteneurs d'Angular, Bubble, Ember, FAST, MobX, Preact, Qwik, RxJS, Solid, Starbeam, Svelte, Vue, Wiz, et plus encore…
Contrairement à Promises/A+, nous n'essayons pas de trouver une API de surface commune destinée aux développeurs, mais plutôt la sémantique de base précise du graphe de signal sous-jacent. Cette proposition inclut une API entièrement concrète, mais celle-ci ne s'adresse pas à la plupart des développeurs d'applications. Au lieu de cela, l'API de signal ici est mieux adaptée aux frameworks sur lesquels s'appuyer, offrant une interopérabilité via un graphique de signal commun et un mécanisme de suivi automatique.
Le plan de cette proposition est de réaliser un prototypage précoce important, y compris une intégration dans plusieurs frameworks, avant de passer au-delà de l'étape 1. Nous ne sommes intéressés par la standardisation des signaux que s'ils sont adaptés à une utilisation pratique dans plusieurs frameworks et offrent de réels avantages par rapport au framework- fourni des signaux. Nous espérons qu’un premier prototypage important nous fournira ces informations. Voir « Statut et plan de développement » ci-dessous pour plus de détails.
Pour développer une interface utilisateur (UI) complexe, les développeurs d'applications JavaScript doivent stocker, calculer, invalider, synchroniser et transmettre l'état à la couche d'affichage de l'application de manière efficace. Les interfaces utilisateur impliquent généralement plus que la simple gestion de valeurs simples, mais impliquent souvent le rendu d'un état calculé qui dépend d'un arbre complexe d'autres valeurs ou d'un état également calculé lui-même. L'objectif de Signals est de fournir une infrastructure pour gérer cet état d'application afin que les développeurs puissent se concentrer sur la logique métier plutôt que sur ces détails répétitifs.
Les constructions de type signal se sont également révélées utiles dans des contextes non liés à l'interface utilisateur, en particulier dans les systèmes de construction, afin d'éviter les reconstructions inutiles.
Les signaux sont utilisés dans la programmation réactive pour supprimer le besoin de gérer la mise à jour dans les applications.
Un modèle de programmation déclaratif pour la mise à jour basée sur les changements d'état.
de Qu'est-ce que la réactivité ? .
Étant donné une variable, counter
, vous souhaitez indiquer dans le DOM si le compteur est pair ou impair. Chaque fois que le counter
change, vous souhaitez mettre à jour le DOM avec la dernière parité. Dans Vanilla JS, vous pourriez avoir quelque chose comme ceci :
soit compteur = 0;const setCounter = (valeur) => { compteur = valeur ; render();};const isEven = () => (compteur & 1) == 0;const parity = () => isEven() ? "even" : "odd";const render = () => element.innerText = parity();// Simuler des mises à jour externes pour counter...setInterval(() => setCounter(counter + 1), 1000);
Cela pose un certain nombre de problèmes...
La configuration counter
est bruyante et lourde.
L’état counter
est étroitement couplé au système de rendu.
Si le counter
change mais pas parity
(par exemple le compteur passe de 2 à 4), alors nous effectuons un calcul inutile de la parité et un rendu inutile.
Que se passe-t-il si une autre partie de notre interface utilisateur souhaite simplement s'afficher lorsque le counter
est mis à jour ?
Que se passe-t-il si une autre partie de notre interface utilisateur dépend uniquement de isEven
ou parity
?
Même dans ce scénario relativement simple, un certain nombre de problèmes surgissent rapidement. Nous pourrions essayer de contourner ce problème en introduisant le pub/sub pour le counter
. Cela permettrait à des consommateurs supplémentaires du counter
de s'abonner pour ajouter leurs propres réactions aux changements d'état.
Cependant, nous sommes toujours confrontés aux problèmes suivants :
La fonction de rendu, qui dépend uniquement de parity
doit plutôt "savoir" qu'elle doit réellement s'abonner à counter
.
Il n'est pas possible de mettre à jour l'interface utilisateur en fonction uniquement isEven
ou parity
, sans interagir directement avec counter
.
Nous avons augmenté notre passe-partout. Chaque fois que vous utilisez quelque chose, il ne s'agit pas simplement d'appeler une fonction ou de lire une variable, mais plutôt de vous y abonner et d'y effectuer des mises à jour. La gestion des désabonnements est également particulièrement compliquée.
Maintenant, nous pourrions résoudre quelques problèmes en ajoutant pub/sub non seulement à counter
mais aussi à isEven
et parity
. Nous devrons alors abonner isEven
à counter
, parity
à isEven
et render
à parity
. Malheureusement, non seulement notre code passe-partout a explosé, mais nous sommes également coincés avec une tonne de comptabilité d'abonnements et un désastre potentiel de fuite de mémoire si nous ne nettoyons pas tout correctement de la bonne manière. Nous avons donc résolu certains problèmes mais créé une toute nouvelle catégorie de problèmes et beaucoup de code. Pire encore, nous devons suivre tout ce processus pour chaque élément de l’État de notre système.
Les abstractions de liaison de données dans les interfaces utilisateur pour le modèle et la vue sont depuis longtemps au cœur des frameworks d'interface utilisateur dans plusieurs langages de programmation, malgré l'absence d'un tel mécanisme intégré à JS ou à la plate-forme Web. Au sein des frameworks et des bibliothèques JS, de nombreuses expérimentations ont été réalisées sur différentes manières de représenter cette liaison, et l'expérience a montré la puissance du flux de données unidirectionnel en conjonction avec un type de données de première classe représentant une cellule d'état ou de calcul. dérivés d'autres données, désormais souvent appelées « signaux ». Cette approche de valeur réactive de premier ordre semble avoir fait sa première apparition populaire dans les frameworks Web JavaScript open source avec Knockout en 2010. Au cours des années qui ont suivi, de nombreuses variantes et implémentations ont été créées. Au cours des 3-4 dernières années, les approches primitives Signal et associées ont gagné en popularité, presque toutes les bibliothèques ou frameworks JavaScript modernes ayant quelque chose de similaire, sous un nom ou un autre.
Pour comprendre les signaux, jetons un coup d'œil à l'exemple ci-dessus, réinventé avec une API Signal expliquée plus en détail ci-dessous.
const counter = new Signal.State(0);const isEven = new Signal.Computed(() => (counter.get() & 1) == 0);const parity = new Signal.Computed(() => isEven .get() ? "even" : "odd");// Une bibliothèque ou un framework définit des effets basés sur d'autres primitives de signaldeclare function effect(cb: () => void): (() => void);effect(( ) => element.innerText = parity.get());// Simuler des mises à jour externes pour counter...setInterval(() => counter.set(counter.get() + 1), 1000);
Il y a quelques choses que nous pouvons voir immédiatement :
Nous avons éliminé le passe-partout bruyant autour de la variable counter
de notre exemple précédent.
Il existe une API unifiée pour gérer les valeurs, les calculs et les effets secondaires.
Il n'y a pas de problème de référence circulaire ni de dépendances à l'envers entre counter
et render
.
Il n’y a pas d’abonnement manuel et aucune comptabilité n’est nécessaire.
Il existe un moyen de contrôler le timing/programmation des effets secondaires.
Les signaux nous donnent cependant bien plus que ce que l’on peut voir à la surface de l’API :
Suivi automatique des dépendances - Un signal calculé découvre automatiquement tous les autres signaux dont il dépend, qu'il s'agisse de valeurs simples ou d'autres calculs.
Évaluation paresseuse : les calculs ne sont pas évalués avec impatience lorsqu'ils sont déclarés, ni immédiatement lorsque leurs dépendances changent. Ils ne sont évalués que lorsque leur valeur est explicitement demandée.
Mémorialisation : les signaux calculés mettent en cache leur dernière valeur afin que les calculs dont les dépendances ne sont pas modifiées n'aient pas besoin d'être réévalués, quel que soit le nombre d'accès.
Chaque implémentation de Signal possède son propre mécanisme de suivi automatique, pour garder une trace des sources rencontrées lors de l'évaluation d'un signal calculé. Cela rend difficile le partage de modèles, de composants et de bibliothèques entre différents frameworks : ils ont tendance à présenter un faux couplage avec leur moteur d'affichage (étant donné que les signaux sont généralement implémentés dans le cadre des frameworks JS).
L'un des objectifs de cette proposition est de dissocier complètement le modèle réactif de la vue de rendu, permettant ainsi aux développeurs de migrer vers de nouvelles technologies de rendu sans réécrire leur code non-UI, ou de développer des modèles réactifs partagés en JS à déployer dans différents contextes. Malheureusement, en raison du contrôle de version et de la duplication, il s'est avéré peu pratique d'atteindre un niveau de partage élevé via des bibliothèques de niveau JS : les éléments intégrés offrent une garantie de partage plus forte.
Envoyer moins de code représente toujours une petite amélioration potentielle des performances en raison de l'intégration des bibliothèques couramment utilisées, mais les implémentations de Signals sont généralement assez petites, nous ne nous attendons donc pas à ce que cet effet soit très important.
Nous pensons que les implémentations natives C++ des structures de données et des algorithmes liés à Signal peuvent être légèrement plus efficaces que ce qui est réalisable en JS, par un facteur constant. Cependant, aucun changement algorithmique n'est prévu par rapport à ce qui serait présent dans un polyfill ; On ne s'attend pas à ce que les moteurs soient magiques ici, et les algorithmes de réactivité eux-mêmes seront bien définis et sans ambiguïté.
Le groupe champion prévoit de développer diverses implémentations de signaux et de les utiliser pour étudier ces possibilités de performances.
Avec les bibliothèques Signal existantes en langage JS, il peut être difficile de tracer des éléments tels que :
La pile d'appels à travers une chaîne de signaux calculés, montrant la chaîne causale d'une erreur
Le graphique de référence parmi les signaux, lorsque l'un dépend de l'autre - important lors du débogage de l'utilisation de la mémoire
Les signaux intégrés permettent aux environnements d'exécution JS et aux DevTools d'avoir potentiellement une meilleure prise en charge de l'inspection des signaux, en particulier pour le débogage ou l'analyse des performances, que cela soit intégré aux navigateurs ou via une extension partagée. Les outils existants tels que l'inspecteur d'éléments, l'instantané de performances et les profileurs de mémoire pourraient être mis à jour pour mettre spécifiquement en évidence les signaux dans leur présentation des informations.
En général, JavaScript disposait d'une bibliothèque standard assez minimale, mais une tendance dans TC39 a été de faire de JS davantage un langage « piles incluses », avec un ensemble intégré de fonctionnalités disponibles de haute qualité. Par exemple, Temporal remplace moment.js, et un certain nombre de petites fonctionnalités, par exemple Array.prototype.flat
et Object.groupBy
remplacent de nombreux cas d'utilisation de Lodash. Les avantages incluent des tailles de bundles plus petites, une stabilité et une qualité améliorées, moins de choses à apprendre lors de la participation à un nouveau projet et un vocabulaire généralement commun à tous les développeurs JS.
Les travaux actuels du W3C et des implémenteurs de navigateurs cherchent à intégrer les modèles natifs au HTML (parties DOM et instanciation de modèles). De plus, le CG Web Components du W3C explore la possibilité d'étendre les composants Web pour offrir une API HTML entièrement déclarative. Pour atteindre ces deux objectifs, HTML aura éventuellement besoin d’une primitive réactive. De plus, de nombreuses améliorations ergonomiques du DOM grâce à l'intégration de signaux peuvent être imaginées et ont été demandées par la communauté.
Notez que cette intégration constituerait un effort distinct à venir plus tard, et ne ferait pas partie de cette proposition elle-même.
Les efforts de normalisation peuvent parfois être utiles uniquement au niveau de la « communauté », même sans modification des navigateurs. L'effort Signals rassemble de nombreux auteurs de cadres différents pour une discussion approfondie sur la nature de la réactivité, des algorithmes et de l'interopérabilité. Cela a déjà été utile et ne justifie pas son inclusion dans les moteurs et navigateurs JS ; Les signaux ne doivent être ajoutés à la norme JavaScript que s'ils présentent des avantages significatifs au-delà de l'échange d'informations sur l'écosystème activé.
Il s’avère que les bibliothèques Signal existantes ne sont pas si différentes les unes des autres, à la base. Cette proposition vise à s'appuyer sur leur succès en mettant en œuvre les qualités importantes de bon nombre de ces bibliothèques.
Un type de signal qui représente l'état, c'est-à-dire un signal inscriptible. C'est une valeur que d'autres peuvent lire.
Un type de signal calculé/mémo/dérivé, qui dépend des autres et est calculé et mis en cache paresseusement.
Le calcul est paresseux, ce qui signifie que les signaux calculés ne sont pas calculés à nouveau par défaut lorsqu'une de leurs dépendances change, mais ne sont exécutés que si quelqu'un les lit réellement.
Le calcul est « sans problème », ce qui signifie qu’aucun calcul inutile n’est jamais effectué. Cela implique que, lorsqu'une application lit un signal calculé, il y a un tri topologique des parties potentiellement sales du graphe à exécuter, pour éliminer les doublons.
Le calcul est mis en cache, ce qui signifie que si, après la dernière modification d'une dépendance, aucune dépendance n'a changé, le signal calculé n'est pas recalculé lors de l'accès.
Des comparaisons personnalisées sont possibles pour les signaux calculés ainsi que pour les signaux d'état, afin de noter quand d'autres signaux calculés qui en dépendent doivent être mis à jour.
Les réactions à la condition dans laquelle un signal calculé a une de ses dépendances (ou dépendances imbriquées) deviennent « sales » et changent, ce qui signifie que la valeur du signal peut être obsolète.
Cette réaction vise à planifier des travaux plus importants à effectuer ultérieurement.
Les effets sont implémentés en fonction de ces réactions, ainsi que d'une planification au niveau du framework.
Les signaux calculés doivent être capables de réagir selon qu'ils sont enregistrés ou non comme une dépendance (imbriquée) de l'une de ces réactions.
Permettez aux frameworks JS d’effectuer leur propre planification. Planification forcée intégrée de type No Promesse.
Des réactions synchrones sont nécessaires pour permettre la planification de travaux ultérieurs basés sur la logique du cadre.
Les écritures sont synchrones et prennent effet immédiatement (un framework qui écrit par lots peut le faire par-dessus).
Il est possible de séparer la vérification si un effet peut être "sale" de l'exécution réelle de l'effet (permettant un planificateur d'effets en deux étapes).
Possibilité de lire les signaux sans déclencher l'enregistrement des dépendances ( untrack
)
Activer la composition de différentes bases de code qui utilisent des signaux/réactivité, par exemple :
Utiliser plusieurs frameworks ensemble en ce qui concerne le suivi/réactivité lui-même (omissions modulo, voir ci-dessous)
Structures de données réactives indépendantes du framework (par exemple, proxy de magasin récursivement réactif, Map, Set et Array réactifs, etc.)
Décourager/interdire l’utilisation abusive naïve des réactions synchrones.
Risque de solidité : il peut exposer des "problèmes" s'il est mal utilisé : si le rendu est effectué immédiatement lorsqu'un signal est défini, il peut exposer un état d'application incomplet à l'utilisateur final. Par conséquent, cette fonctionnalité ne doit être utilisée que pour planifier intelligemment le travail pour plus tard, une fois la logique de l'application terminée.
Solution : Interdire la lecture et l'écriture de tout signal à partir d'un rappel de réaction synchrone
Décourager untrack
et marquer sa nature malsaine
Risque de solidité : permet la création de Signaux calculés dont la valeur dépend d'autres Signaux, mais qui ne sont pas mis à jour lorsque ces Signaux changent. Il doit être utilisé lorsque les accès non suivis ne changeront pas le résultat du calcul.
Solution : L'API est marquée « dangereuse » dans son nom.
Remarque : Cette proposition permet de lire et d'écrire des signaux à partir de signaux calculés et d'effets, sans restreindre les écritures qui suivent les lectures, malgré le risque de solidité. Cette décision a été prise pour préserver la flexibilité et la compatibilité dans l'intégration avec les frameworks.
Doit être une base solide pour plusieurs frameworks afin de mettre en œuvre leurs mécanismes de signaux/réactivité.
Devrait constituer une bonne base pour les proxys de magasin récursifs, la réactivité des champs de classe basée sur le décorateur et les API de style .value
et [state, setState]
.
La sémantique est capable d'exprimer les modèles valides activés par différents frameworks. Par exemple, il devrait être possible que ces signaux soient la base soit d'écritures immédiatement réfléchies, soit d'écritures qui sont regroupées et appliquées ultérieurement.
Ce serait bien si cette API était utilisable directement par les développeurs JavaScript.
Idée : fournissez tous les hooks, mais incluez si possible les erreurs en cas de mauvaise utilisation.
Idée : placez des API subtiles dans un espace de noms subtle
, similaire à crypto.subtle
, pour marquer la frontière entre les API nécessaires à une utilisation plus avancée, comme la mise en œuvre d'un framework ou la création d'outils de développement, et une utilisation plus quotidienne du développement d'applications, comme l'instanciation de signaux à utiliser avec un cadre.
Cependant, il est important de ne pas littéralement suivre exactement les mêmes noms !
Si une fonctionnalité correspond à un concept d’écosystème, il est bon d’utiliser un vocabulaire commun.
Tension entre « la convivialité par les développeurs JS » et « fournir tous les hooks aux frameworks »
Être implémentable et utilisable avec de bonnes performances : l'API de surface n'entraîne pas trop de frais généraux
Activez le sous-classement, afin que les frameworks puissent ajouter leurs propres méthodes et champs, y compris des champs privés. Ceci est important pour éviter d’avoir besoin d’allocations supplémentaires au niveau du cadre. Voir « Gestion de la mémoire » ci-dessous.
Si possible : un signal calculé doit pouvoir être récupéré si rien en direct ne le référence pour d'éventuelles lectures futures, même s'il est lié à un graphique plus large qui reste vivant (par exemple, en lisant un état qui reste actif).
Notez que la plupart des frameworks nécessitent aujourd'hui l'élimination explicite des signaux calculés s'ils ont une référence vers ou depuis un autre graphe de signaux qui reste actif.
Cela n'est finalement pas si grave lorsque leur durée de vie est liée à la durée de vie d'un composant de l'interface utilisateur et que les effets doivent de toute façon être éliminés.
S'il est trop coûteux à exécuter avec ces sémantiques, alors nous devrions ajouter une élimination explicite (ou "dissociation") des signaux calculés à l'API ci-dessous, qui en manque actuellement.
Un objectif connexe distinct : minimiser le nombre d'allocations, par exemple :
pour créer un signal inscriptible (éviter deux fermetures distinctes + tableau)
mettre en œuvre des effets (éviter une fermeture pour chaque réaction)
Dans l'API d'observation des changements de Signal, évitez de créer des structures de données temporaires supplémentaires
Solution : API basée sur les classes permettant la réutilisation des méthodes et des champs définis dans les sous-classes
Une première idée d’une API Signal est ci-dessous. Notez qu’il ne s’agit que d’une première ébauche et que nous prévoyons des changements au fil du temps. Commençons par le .d.ts
complet pour avoir une idée de la forme globale, puis nous discuterons des détails de ce que tout cela signifie.
interface Signal<T> {// Récupère la valeur du signalget() : T;}namespace Signal {// Une Signalclass State<T> en lecture-écriture implémente Signal<T> {// Crée un état Signal commençant par la valeur tconstructor(t: T, options?: SignalOptions<T>);// Récupère la valeur du signalget(): T;// Définit la valeur du signal d'état sur tset(t: T): void;}// A Signal qui est une formule basée sur d'autres classes de signaux Computed<T = inconnu> implémente Signal<T> {// Crée un signal qui évalue la valeur renvoyée par le rappel.// Le rappel est appelé avec ce signal comme this value.constructor(cb: (this: Computed<T >) => T, options?: SignalOptions<T>);// Récupère la valeur du signalget(): T;}// Cet espace de noms inclut des fonctionnalités "avancées" qu'il est préférable de// laisser aux auteurs du framework plutôt qu'à application développeurs.// Analogue à `crypto.subtle`namespace subtil {// Exécuter un rappel avec toutes les fonctions de suivi désactivées untrack<T>(cb: () => T): T;// Obtenir le signal calculé actuel qui suit tout signal lit, le cas échéant, la fonction currentComputed() : Computed | null;// Renvoie la liste ordonnée de tous les signaux référencés par celui-ci// lors de la dernière fois qu'il a été évalué.// Pour un Watcher, répertorie l'ensemble des signaux qu'il surveille.function introspectSources(s: Computed | Watcher): (State | Computed)[];// Renvoie les Watchers dans lesquels ce signal est contenu, plus tous// Les signaux calculés qui ont lu ce signal la dernière fois qu'ils ont été évalués,// si ce signal calculé est (récursivement) watch.function introspectSinks(s: State | Computed): (Computed | Watcher)[];// True si ce signal est "live", dans le sens où il est surveillé par un Watcher,// ou s'il est lu par un signal calculé qui est (récursivement) live.function hasSinks(s: State | Computed): boolean;// True si cet élément est "réactif", dans le sens où il dépend// d'un autre signal. Un Computed où hasSources est false// renverra toujours la même constante.function hasSources(s: Computed | Watcher): boolean;class Watcher {// Lorsqu'une source (récursive) de Watcher est écrite, appelez ce rappel,// s'il n'a pas déjà été appelé depuis le dernier appel `watch`.// Aucun signal ne peut être lu ou écrit pendant le notify.constructor(notify: (this: Watcher) => void);// Ajouter ces signaux à l'ensemble de l'observateur, et configurez l'observateur pour qu'il exécute son//notifier le rappel la prochaine fois qu'un signal dans l'ensemble (ou l'une de ses dépendances) change.// Peut être appelé sans argument juste pour réinitialiser l'état "notifié" , de sorte que// le rappel de notification soit à nouveau invoqué.watch(...s: Signal[]): void;// Supprimez ces signaux de l'ensemble surveillé (par exemple, pour un effet qui est supprimé)unwatch(.. .s : Signal[] ): void;// Renvoie l'ensemble des sources de l'observateur qui sont encore sales, ou est un signal calculé // avec une source sale ou en attente et qui n'a pas encore été réévaluéegetPending(): Signal[];} // Hooks pour observer qu'ils sont surveillés ou ne sont plus surveillésvar surveillé : Symbol;var unwatched: Symbol;}interface SignalOptions<T> {// Fonction de comparaison personnalisée entre l'ancienne et la nouvelle valeur. Par défaut : Object.is.// Le signal est transmis comme valeur this pour context.equals ? : (this : Signal<T>, t : T, t2 : T) => boolean ;// Callback appelé lorsque isWatched devient true, si c'était auparavant false[Signal.subtle.watched]?: (this: Signal<T>) => void;// Le rappel appelé chaque fois que isWatched devient faux, si c'était auparavant true[Signal.subtle.unwatched]? : (ceci : Signal<T>) => void;}}
Un signal représente une cellule de données qui peut changer au fil du temps. Les signaux peuvent être soit un « état » (juste une valeur définie manuellement), soit un « calcul » (une formule basée sur d'autres signaux).
Les signaux calculés fonctionnent en suivant automatiquement les autres signaux lus lors de leur évaluation. Lorsqu'un calculé est lu, il vérifie si l'une de ses dépendances précédemment enregistrées a changé et se réévalue si c'est le cas. Lorsque plusieurs signaux calculés sont imbriqués, toute l'attribution du suivi revient au plus interne.
Les signaux calculés sont paresseux, c'est-à-dire basés sur l'extraction : ils ne sont réévalués que lors de leur accès, même si l'une de leurs dépendances a changé plus tôt.
Le rappel transmis aux signaux calculés doit généralement être « pur » dans le sens d'être une fonction déterministe et sans effets secondaires des autres signaux auxquels il accède. Dans le même temps, le moment de l’appel du rappel est déterministe, ce qui permet d’utiliser les effets secondaires avec prudence.
Les signaux comportent une mise en cache/mémoire importante : les signaux d'état et calculés se souviennent de leur valeur actuelle et ne déclenchent le recalcul des signaux calculés qui les référencent que s'ils changent réellement. Une comparaison répétée des anciennes et des nouvelles valeurs n'est même pas nécessaire : la comparaison est effectuée une fois lorsque le signal source est réinitialisé/réévalué, et le mécanisme Signal garde une trace des éléments faisant référence à ce signal qui n'ont pas été mis à jour en fonction du nouveau. valeur encore. En interne, cela est généralement représenté par une « coloration graphique » comme décrit dans (le billet de blog de Milo).
Les signaux calculés suivent leurs dépendances de manière dynamique : chaque fois qu'ils sont exécutés, ils peuvent finir par dépendre de différentes choses, et cet ensemble de dépendances précis est conservé à jour dans le graphique du signal. Cela signifie que si vous avez une dépendance nécessaire sur une seule branche et que le calcul précédent a pris l'autre branche, alors une modification de cette valeur temporairement inutilisée n'entraînera pas le recalcul du signal calculé, même lorsqu'il est extrait.
Contrairement aux promesses JavaScript, tout dans Signals s'exécute de manière synchrone :
Définir un signal sur une nouvelle valeur est synchrone, et cela se reflète immédiatement lors de la lecture ultérieure de tout signal calculé qui en dépend. Il n’existe pas de regroupement intégré de cette mutation.
La lecture des signaux calculés est synchrone : leur valeur est toujours disponible.
Le rappel notify
dans Watchers, comme expliqué ci-dessous, s'exécute de manière synchrone, pendant l'appel .set()
qui l'a déclenché (mais une fois la coloration du graphique terminée).
Comme les promesses, les signaux peuvent représenter un état d'erreur : si le rappel d'un signal calculé est lancé, cette erreur est mise en cache comme une autre valeur et renvoyée à chaque fois que le signal est lu.
Une instance Signal
représente la capacité de lire une valeur changeant dynamiquement dont les mises à jour sont suivies au fil du temps. Il inclut également implicitement la possibilité de s'abonner au Signal, implicitement via un accès suivi à partir d'un autre Signal calculé.
L'API ici est conçue pour correspondre au consensus très approximatif de l'écosystème parmi une grande partie des bibliothèques Signal dans l'utilisation de noms tels que « signal », « calculé » et « état ». Cependant, l'accès aux signaux calculés et d'état se fait via une méthode .get()
, qui est en désaccord avec toutes les API Signal populaires, qui utilisent soit un accesseur de style .value
, soit une syntaxe d'appel signal()
.
L'API est conçue pour réduire le nombre d'allocations, afin de rendre les signaux adaptés à l'intégration dans des frameworks JavaScript tout en atteignant des performances identiques ou supérieures à celles des signaux personnalisés par le framework existant. Cela implique :
Les signaux d'état sont un objet unique inscriptible, accessible et défini à partir de la même référence. (Voir les implications ci-dessous dans la section « Séparation des capacités ».)
Les signaux d'état et calculés sont conçus pour être sous-classables, afin de faciliter la capacité des frameworks à ajouter des propriétés supplémentaires via des champs de classe publics et privés (ainsi que des méthodes d'utilisation de cet état).
Divers rappels (par exemple, equals
, le rappel calculé) sont appelés avec le Signal pertinent comme valeur this
pour le contexte, de sorte qu'une nouvelle fermeture n'est pas nécessaire par Signal. Au lieu de cela, le contexte peut être enregistré dans des propriétés supplémentaires du signal lui-même.
Quelques conditions d'erreur appliquées par cette API :
C'est une erreur de lire un calculé de manière récursive.
Le rappel notify
d'un observateur ne peut lire ou écrire aucun signal
Si le rappel d'un signal calculé est lancé, les accès suivants au signal renvoient cette erreur mise en cache, jusqu'à ce que l'une des dépendances change et qu'elle soit recalculée.
Quelques conditions qui ne sont pas appliquées :
Les signaux calculés peuvent écrire sur d'autres signaux, de manière synchrone dans leur rappel
Le travail qui est mis en file d'attente par le rappel notify
d'un Watcher peut lire ou écrire des signaux, permettant de reproduire les anti-modèles React classiques en termes de signaux !
L'interface Watcher
définie ci-dessus constitue la base de l'implémentation d'API JS typiques pour les effets : des rappels qui sont réexécutés lorsque d'autres signaux changent, uniquement pour leur effet secondaire. La fonction effect
utilisée ci-dessus dans l'exemple initial peut être définie comme suit :
// Cette fonction réside généralement dans une bibliothèque/un framework, pas dans le code d'application// REMARQUE : Cette logique de planification est trop basique pour être utile. Ne pas copier/coller.let ending = false;let w = new Signal.subtle.Watcher(() => {if (!ending) {ending = true;queueMicrotask(() => {ending = false;for (let s of w.getPending()) s.get();w.watch();});}});// Un effet d'effet Signal qui s'évalue en cb, qui planifie une lecture de // lui-même dans la file d'attente des microtâches chaque fois qu'une de ses dépendances pourrait changeexport function effect(cb) {let destructor;let c = new Signal.Computed(() => { destructor?.(); destructor = cb(); });w.watch(c);c.get() ;return () => { destructeur?.(); w.unwatch(c) };}
L'API Signal n'inclut aucune fonction intégrée telle que effect
. En effet, la planification des effets est subtile et est souvent liée aux cycles de rendu du framework et à d'autres états ou stratégies de haut niveau spécifiques au framework auxquels JS n'a pas accès.
En parcourant les différentes opérations utilisées ici : Le rappel notify
passé dans le constructeur Watcher
est la fonction qui est appelée lorsque le signal passe d'un état "propre" (où nous savons que le cache est initialisé et valide) à un état "vérifié" ou "sale". " état (où le cache peut être valide ou non car au moins un des états dont il dépend récursivement a été modifié).
Les appels à notify
sont finalement déclenchés par un appel à .set()
sur un signal d'état. Cet appel est synchrone : il se produit avant le retour .set
. Mais il n'y a pas lieu de s'inquiéter de ce rappel observant le graphe Signal dans un état à moitié traité, car lors d'un rappel notify
, aucun signal ne peut être lu ou écrit, même dans un appel untrack
. Étant donné que notify
est appelé pendant .set()
, il interrompt un autre thread logique, qui pourrait ne pas être complet. Pour lire ou écrire des signaux à partir de notify
, planifiez le travail pour qu'il s'exécute plus tard, par exemple en écrivant le signal dans une liste pour y accéder ultérieurement, ou avec queueMicrotask
comme ci-dessus.
Notez qu'il est parfaitement possible d'utiliser efficacement les signaux sans Symbol.subtle.Watcher
en planifiant l'interrogation des signaux calculés, comme le fait Glimmer. Cependant, de nombreux frameworks ont constaté qu'il est très souvent utile que cette logique de planification s'exécute de manière synchrone, c'est pourquoi l'API Signals l'inclut.
Les signaux calculés et d'état sont récupérés comme toutes les valeurs JS. Mais les observateurs ont une manière particulière de maintenir les choses en vie : tous les signaux surveillés par un observateur seront maintenus en vie aussi longtemps que l'un des états sous-jacents sera accessible, car ceux-ci peuvent déclencher un futur appel notify
(puis un futur .get()
). Pour cette raison, n'oubliez pas d'appeler Watcher.prototype.unwatch
pour nettoyer les effets.
Signal.subtle.untrack
est une trappe de secours permettant de lire des signaux sans suivre ces lectures. Cette fonctionnalité n'est pas sécurisée car elle permet la création de signaux calculés dont la valeur dépend d'autres signaux, mais qui ne sont pas mis à jour lorsque ces signaux changent. Il doit être utilisé lorsque les accès non suivis ne changeront pas le résultat du calcul.
Ces fonctionnalités pourront être ajoutées ultérieurement, mais elles ne sont pas incluses dans la version actuelle. Leur omission est due au manque de consensus établi dans l'espace de conception entre les frameworks, ainsi qu'à la capacité démontrée à contourner leur absence avec des mécanismes au-dessus de la notion de signaux décrite dans ce document. Malheureusement, cette omission limite le potentiel d’interopérabilité entre les frameworks. Au fur et à mesure que les prototypes de signaux décrits dans ce document seront produits, des efforts seront déployés pour réexaminer si ces omissions étaient la décision appropriée.
Async : les signaux sont toujours disponibles de manière synchrone pour l'évaluation, dans ce modèle. Cependant, il est souvent utile de disposer de certains processus asynchrones qui conduisent à l'établissement d'un signal, et de comprendre quand un signal est encore en "chargement". Un moyen simple de modéliser l'état de chargement consiste à utiliser des exceptions, et le comportement de mise en cache des exceptions des signaux calculés s'intègre assez raisonnablement avec cette technique. Les techniques améliorées sont discutées dans le numéro 30.
Transactions : Pour les transitions entre les vues, il est souvent utile de maintenir un état actif pour les états « de » et « vers ». L'état « vers » s'affiche en arrière-plan, jusqu'à ce qu'il soit prêt à basculer (validation de la transaction), tandis que l'état « de » reste interactif. Le maintien des deux états en même temps nécessite de « bifurquer » l'état du graphe de signaux, et il peut même être utile de prendre en charge plusieurs transitions en attente à la fois. Discussion dans le numéro 73.
Certaines méthodes pratiques possibles sont également omises.
Cette proposition est à l'ordre du jour du TC39 d'avril 2024 pour l'étape 1. Elle peut actuellement être considérée comme « l'étape 0 ».
Un polyfill pour cette proposition est disponible, avec quelques tests de base. Certains auteurs de framework ont commencé à expérimenter la substitution de cette implémentation de signal, mais cette utilisation n’en est qu’à ses débuts.
Les collaborateurs de la proposition Signal veulent être particulièrement conservateurs dans la façon dont nous faisons avancer cette proposition, afin de ne pas tomber dans le piège de faire expédier quelque chose que nous finissons par regretter et que nous n'utilisons pas réellement. Notre plan est d'effectuer les tâches supplémentaires suivantes, non requises par le processus TC39, pour nous assurer que cette proposition est sur la bonne voie :
Avant de proposer l'étape 2, nous prévoyons de :
Développer plusieurs implémentations polyfill de qualité production qui sont solides, bien testées (par exemple, en réussissant les tests de divers frameworks ainsi que les tests de style test262) et compétitives en termes de performances (comme vérifié avec un ensemble de références de signaux/framework approfondi).
Intégrez l'API Signal proposée dans un grand nombre de frameworks JS que nous considérons comme quelque peu représentatifs, et certaines grandes applications fonctionnent sur cette base. Testez qu’il fonctionne efficacement et correctement dans ces contextes.
Avoir une solide compréhension de l'espace des extensions possibles de l'API et avoir déterminé lesquelles (le cas échéant) devraient être ajoutées à cette proposition.
Cette section décrit chacune des API exposées à JavaScript, en termes d'algorithmes qu'elles implémentent. Cela peut être considéré comme une proto-spécification et est inclus à ce stade précoce pour définir un ensemble possible de sémantique, tout en étant très ouvert aux changements.
Quelques aspects de l'algorithme :
L'ordre des lectures de Signals dans un calcul est significatif et est observable dans l'ordre dans lequel certains rappels (lequel Watcher
est invoqué, equals
, le premier paramètre du new Signal.Computed
et les rappels watched
/ unwatched
) sont exécutés. Cela signifie que les sources d'un signal calculé doivent être stockées de manière ordonnée.
Ces quatre rappels peuvent tous générer des exceptions, et ces exceptions sont propagées de manière prévisible au code JS appelant. Les exceptions n'arrêtent pas l'exécution de cet algorithme et ne laissent pas le graphique dans un état à moitié traité. Pour les erreurs générées dans le rappel notify
d'un Watcher, cette exception est envoyée à l'appel .set()
qui l'a déclenchée, en utilisant une AggregateError si plusieurs exceptions ont été levées. Les autres (y compris watched
/ unwatched
?) sont stockés dans la valeur du Signal, pour être renvoyés lors de la lecture, et un tel Signal renvoyé peut être marqué ~clean~
comme n'importe quel autre avec une valeur normale.
Des précautions sont prises pour éviter les circularités dans les cas de signaux calculés qui ne sont pas « surveillés » (étant observés par un observateur), afin qu'ils puissent être récupérés indépendamment des autres parties du graphe de signaux. En interne, cela peut être mis en œuvre avec un système de numéros de génération qui sont toujours collectés ; notez que les implémentations optimisées peuvent également inclure des numéros de génération locaux par nœud, ou éviter de suivre certains numéros sur les signaux surveillés.
Les algorithmes de signaux doivent faire référence à un certain état global. Cet état est global pour l'ensemble du thread, ou "agent".
computing
: le signal calculé ou à effet le plus interne actuellement en cours de réévaluation en raison d'un appel .get
ou .run
, ou null
. Initialement null
.
frozen
: Booléen indiquant s'il y a un rappel en cours d'exécution qui nécessite que le graphique ne soit pas modifié. Initialement false
.
generation
: Un entier incrémentiel, commençant à 0, utilisé pour suivre l'actualité d'une valeur tout en évitant les circularités.
Signal
Signal
est un objet ordinaire qui sert d'espace de noms pour les classes et fonctions liées à Signal.
Signal.subtle
est un objet d'espace de noms interne similaire.
Signal.State
Signal.State
value
: La valeur actuelle du signal d'état
equals
: La fonction de comparaison utilisée lors de la modification des valeurs
watched
: Le rappel à appeler lorsque le signal est observé par un effet
unwatched
: Le rappel à appeler lorsque le signal n'est plus observé par un effet
sinks
: Ensemble des signaux surveillés qui dépendent de celui-ci
Signal.State(initialValue, options)
Définissez value
de ce signal sur initialValue
.
Définir equals
de ce signal sur les options ? .equals
Définir ce signal watched
sur les options ?.[Signal.subtle.watched]
Définir ce signal unwatched
sur les options ?.[Signal.subtle.unwatched]
Réglez sinks
de ce signal sur l'ensemble vide
Signal.State.prototype.get()
Si frozen
est vrai, lancez une exception.
Si computing
n'est pas undefined
, ajoutez ce Signal à l'ensemble sources
de computing
.
REMARQUE : Nous n'ajoutons pas computing
à l'ensemble des sinks
de ce signal tant qu'il n'est pas surveillé par un observateur.
Renvoie value
de ce signal.
Signal.State.prototype.set(newValue)
Si le contexte d'exécution actuel est frozen
, lancez une exception.
Exécutez l'algorithme « définir la valeur du signal » avec ce signal et le premier paramètre pour la valeur.
Si cet algorithme a renvoyé ~clean~
, alors renvoyez undefined.
Définissez l' state
de tous sinks
de ce signal sur (s'il s'agit d'un signal calculé) ~dirty~
s'ils étaient auparavant propres, ou (s'il s'agit d'un observateur) ~pending~
s'il était auparavant ~watching~
.
Définissez l' state
de toutes les dépendances du signal calculé des récepteurs (de manière récursive) sur ~checked~
s'ils étaient auparavant ~clean~
(c'est-à-dire, laissez les marquages sales en place), ou pour les observateurs, ~pending~
s'ils étaient auparavant ~watching~
.
Pour chaque observateur ~watching~
précédemment rencontré dans cette recherche récursive, puis dans l'ordre en profondeur d'abord,
Définir frozen
sur vrai.
Appeler leur rappel notify
(en sauvegardant toute exception levée, mais en ignorant la valeur de retour de notify
).
Restaurer frozen
à faux.
Définissez l’ state
du Watcher sur ~waiting~
.
Si une exception a été levée à partir des rappels notify
, propagez-la à l'appelant une fois que tous les rappels notify
ont été exécutés. S'il y a plusieurs exceptions, regroupez-les ensemble dans une AggregateError et lancez-la.
Renvoie indéfini.
Signal.Computed
Signal.Computed
à états calculée L' state
d'un signal calculé peut être l'un des suivants :
~clean~
: La valeur du signal est présente et connue pour ne pas être obsolète.
~checked~
: Une source (indirecte) de ce signal a changé ; ce signal a une valeur mais il est peut- être obsolète. On ne saura si cette information est obsolète ou non que lorsque toutes les sources immédiates auront été évaluées.
~computing~
: le rappel de ce signal est actuellement exécuté comme effet secondaire d'un appel .get()
.
~dirty~
: Soit ce Signal a une valeur connue pour être périmée, soit il n'a jamais été évalué.
Le graphique de transition est le suivant :
stateDiagram-v2
[*] --> sale
sale --> informatique : [4]
informatique --> nettoyer : [5]
propre --> sale : [2]
nettoyer --> vérifié : [3]
vérifié --> propre : [6]
vérifié --> sale : [1]
ChargementLes transitions sont :
Nombre | Depuis | À | Condition | Algorithme |
---|---|---|---|---|
1 | ~checked~ | ~dirty~ | Une source immédiate de ce signal, qui est un signal calculé, a été évaluée et sa valeur a changé. | Algorithme : recalculer le signal calculé sale |
2 | ~clean~ | ~dirty~ | Une source immédiate de ce signal, qui est un Etat, a été fixée, avec une valeur qui n'est pas égale à sa valeur précédente. | Méthode : Signal.State.prototype.set(newValue) |
3 | ~clean~ | ~checked~ | Une source récursive, mais non immédiate, de ce signal, qui est un Etat, a été fixée, avec une valeur qui n'est pas égale à sa valeur précédente. | Méthode : Signal.State.prototype.set(newValue) |
4 | ~dirty~ | ~computing~ | Nous sommes sur le point d'exécuter le callback . | Algorithme : recalculer le signal calculé sale |
5 | ~computing~ | ~clean~ | Le callback a terminé son évaluation et a renvoyé une valeur ou généré une exception. | Algorithme : recalculer le signal calculé sale |
6 | ~checked~ | ~clean~ | Toutes les sources immédiates de ce signal ont été évaluées et toutes ont été découvertes inchangées, nous savons donc maintenant que nous ne sommes pas périmés. | Algorithme : recalculer le signal calculé sale |
Signal.Computed
value
: La valeur précédente mise en cache du signal, ou ~uninitialized~
pour un signal calculé jamais lu. La valeur peut être une exception qui est renvoyée lors de la lecture de la valeur. Toujours undefined
pour les signaux d'effet.
state
: Peut être ~clean~
, ~checked~
, ~computing~
ou ~dirty~
.
sources
: Un ensemble ordonné de signaux dont dépend ce signal.
sinks
: Un ensemble ordonné de signaux qui dépendent de ce signal.
equals
: La méthode equals fournie dans les options.
callback
: Le rappel qui est appelé pour obtenir la valeur du signal calculé. Défini sur le premier paramètre transmis au constructeur.
Signal.Computed
Le constructeur définit
callback
à son premier paramètre
equals
en fonction des options, par défaut Object.is
en cas d'absence
state
à ~dirty~
value
à ~uninitialized~
Avec AsyncContext, le rappel transmis au new Signal.Computed
se ferme sur l'instantané à partir du moment où le constructeur a été appelé et restaure cet instantané lors de son exécution.
Signal.Computed.prototype.get
Si le contexte d'exécution actuel est frozen
ou si ce Signal a l'état ~computing~
, ou si ce signal est un Effet et computing
un Signal calculé, lancez une exception.
Si computing
n'est pas null
, ajoutez ce Signal à l'ensemble sources
de computing
.
REMARQUE : Nous n'ajoutons pas computing
à l'ensemble des sinks
de ce signal jusqu'à ce qu'il soit surveillé par un observateur.
Si l'état de ce Signal est ~dirty~
ou ~checked~
: Répétez les étapes suivantes jusqu'à ce que ce Signal soit ~clean~
:
Remontez via sources
pour trouver la source récursive la plus profonde, la plus à gauche (c'est-à-dire la plus ancienne observée) qui est un signal calculé marqué ~dirty~
(coupant la recherche lorsque vous frappez un signal calculé ~clean~
, et incluant ce signal calculé comme dernière chose à rechercher).
Exécutez l'algorithme « recalculer le signal calculé sale » sur ce signal.
À ce stade, l'état de ce signal sera ~clean~
et aucune source récursive ne sera ~dirty~
ou ~checked~
. Renvoie la value
du signal. Si la valeur est une exception, relancez cette exception.
Signal.subtle.Watcher
Signal.subtle.Watcher
L' state
d'un observateur peut être l'un des suivants :
~waiting~
: le rappel notify
a été exécuté ou l'observateur est nouveau, mais ne surveille activement aucun signal.
~watching~
: L'observateur surveille activement les signaux, mais aucun changement ne s'est encore produit qui nécessiterait un rappel notify
.
~pending~
: Une dépendance du Watcher a changé, mais le rappel notify
n'a pas encore été exécuté.
Le graphique de transition est le suivant :
stateDiagram-v2
[*] --> attendre
attendre --> regarder : [1]
regarder --> attendre : [2]
en train de regarder --> en attente : [3]
en attente --> en attente : [4]
ChargementLes transitions sont :
Nombre | Depuis | À | Condition | Algorithme |
---|---|---|---|---|
1 | ~waiting~ | ~watching~ | La méthode watch du Watcher a été appelée. | Méthode : Signal.subtle.Watcher.prototype.watch(...signals) |
2 | ~watching~ | ~waiting~ | La méthode unwatch du Watcher a été appelée et le dernier signal surveillé a été supprimé. | Méthode : Signal.subtle.Watcher.prototype.unwatch(...signals) |
3 | ~watching~ | ~pending~ | Un signal surveillé peut avoir changé de valeur. | Méthode : Signal.State.prototype.set(newValue) |
4 | ~pending~ | ~waiting~ | Le rappel de notify a été exécuté. | Méthode : Signal.State.prototype.set(newValue) |
Signal.subtle.Watcher
state
: Peut être ~watching~
, ~pending~
ou ~waiting~
signals
: un ensemble ordonné de signaux que cet observateur regarde
notifyCallback
: Le rappel qui est appelé lorsque quelque chose change. Défini sur le premier paramètre transmis au constructeur.
new Signal.subtle.Watcher(callback)
state
est défini sur ~waiting~
.
Initialisez signals
comme un ensemble vide.
notifyCallback
est défini sur le paramètre de rappel.
Avec AsyncContext, le rappel transmis au new Signal.subtle.Watcher
ne se ferme pas sur l'instantané à partir du moment où le constructeur a été appelé, de sorte que les informations contextuelles autour de l'écriture soient visibles.
Signal.subtle.Watcher.prototype.watch(...signals)
Si frozen
est vrai, lancez une exception.
Si l'un des arguments n'est pas un signal, lancez une exception.
Ajoutez tous les arguments à la fin des signals
de cet objet.
Pour chaque signal nouvellement regardé, dans l'ordre de gauche à droite,
Ajoutez cet observateur comme sink
à ce signal.
S'il s'agissait du premier récepteur, revenez aux sources pour ajouter ce signal en tant que récepteur.
Définir frozen
sur vrai.
Appelez le rappel watched
s’il existe.
Restaurer frozen
à faux.
Si l' state
du signal est ~waiting~
, réglez-le sur ~watching~
.
Signal.subtle.Watcher.prototype.unwatch(...signals)
Si frozen
est vrai, lancez une exception.
Si l'un des arguments n'est pas un signal ou n'est pas surveillé par cet observateur, lancez une exception.
Pour chaque signal dans les arguments, dans l'ordre de gauche à droite,
Supprimez ce signal de l'ensemble signals
de cet observateur.
Retirez cet observateur de l'ensemble sink
de ce signal.
Si l'ensemble sink
de ce signal est devenu vide, supprimez ce signal en tant que récepteur de chacune de ses sources.
Définir frozen
sur vrai.
Appelez le rappel unwatched
s'il existe.
Restaurer frozen
à faux.
Si l'observateur n'a plus signals
et que son state
est ~watching~
, alors réglez-le sur ~waiting~
.
Signal.subtle.Watcher.prototype.getPending()
Renvoie un tableau contenant le sous-ensemble de signals
qui sont des signaux calculés dans les états ~dirty~
ou ~pending~
.
Signal.subtle.untrack(cb)
Soit c
l'état computing
actuel du contexte d'exécution.
Définissez computing
sur null.
Appelez cb
.
Restaurez computing
sur c
(même si cb
a levé une exception).
Renvoie la valeur de retour de cb
(en renvoyant toute exception).
Remarque : untracking ne vous sort pas de l'état frozen
, qui est strictement maintenu.
Signal.subtle.currentComputed()
Renvoie la valeur computing
actuelle.
Effacez l'ensemble sources
de ce signal et supprimez-le des ensembles sinks
de ces sources.
Enregistrez la valeur computing
précédente et définissez computing
sur ce signal.
Définissez l'état de ce signal sur ~computing~
.
Exécutez le rappel de ce signal calculé, en utilisant ce signal comme valeur this. Enregistrez la valeur de retour et si le rappel a généré une exception, stockez-la pour la relancer.
Restaurez la valeur computing
précédente.
Appliquez l'algorithme "set Signal value" à la valeur de retour du rappel.
Définissez l'état de ce signal sur ~clean~
.
Si cet algorithme renvoyait ~dirty~
: marquez tous les récepteurs de ce signal comme ~dirty~
(auparavant, les récepteurs pouvaient être un mélange de cochés et de sales). (Ou, si cela n'est pas surveillé, adoptez un nouveau numéro de génération pour indiquer la saleté, ou quelque chose comme ça.)
Sinon, cet algorithme a renvoyé ~clean~
: Dans ce cas, pour chaque puits ~checked~
de ce signal, si toutes les sources de ce signal sont maintenant propres, alors marquez ce signal comme ~clean~
également. Appliquez cette étape de nettoyage à d’autres récepteurs de manière récursive, à tous les signaux nouvellement nettoyés qui ont vérifié des récepteurs. (Ou, si cela n'est pas surveillé, indiquez-le d'une manière ou d'une autre, afin que le nettoyage puisse se dérouler paresseusement.)
Si une valeur a été transmise à cet algorithme (par opposition à une exception pour le nouveau lancement, à partir de l'algorithme de signal recalculé sale) :
Appelez la fonction equals
de ce signal, en passant comme paramètres la value
actuelle, la nouvelle valeur et ce signal. Si une exception est levée, enregistrez cette exception (pour la relancer une fois lue) comme valeur du signal et continuez comme si le rappel avait renvoyé false.
Si cette fonction renvoie vrai, renvoyez ~clean~
.
Définissez la value
de ce signal sur le paramètre.
Retour ~dirty~
Q : N'est-il pas un peu tôt de standardiser quelque chose lié aux signaux, alors qu'ils commencent tout juste à être la nouveauté en vogue en 2022 ? Ne devrait-on pas leur laisser plus de temps pour évoluer et se stabiliser ?
R : L'état actuel des signaux dans les frameworks Web est le résultat de plus de 10 ans de développement continu. À mesure que les investissements s'intensifient, comme cela a été le cas ces dernières années, presque tous les frameworks Web se rapprochent d'un modèle de base de signaux très similaire. Cette proposition est le résultat d'un exercice de conception partagé entre un grand nombre de leaders actuels des frameworks Web, et elle ne sera pas poussée vers la normalisation sans la validation de ce groupe d'experts du domaine dans divers contextes.
Q : Les signaux intégrés peuvent-ils même être utilisés par les frameworks, étant donné leur intégration étroite avec le rendu et la propriété ?
R : Les parties les plus spécifiques au cadre ont tendance à concerner le domaine des effets, de la programmation et de la propriété/élimination, que cette proposition ne tente pas de résoudre. Notre première priorité en matière de prototypage de signaux conformes aux normes est de valider qu'ils peuvent s'installer « sous » les frameworks existants de manière compatible et avec de bonnes performances.
Q : L'API Signal est-elle destinée à être utilisée directement par les développeurs d'applications, ou enveloppée dans des frameworks ?
R : Bien que cette API puisse être utilisée directement par les développeurs d'applications (du moins la partie qui ne fait pas partie de l'espace de noms Signal.subtle
), elle n'est pas conçue pour être particulièrement ergonomique. Au lieu de cela, les besoins des auteurs de bibliothèques/frameworks sont des priorités. La plupart des frameworks devraient envelopper même les API de base Signal.State
et Signal.Computed
avec quelque chose exprimant leur orientation ergonomique. En pratique, il est généralement préférable d'utiliser Signals via un framework, qui gère les fonctionnalités plus délicates (par exemple, Watcher, untrack
), ainsi que la gestion de la propriété et de l'élimination (par exemple, déterminer quand les signaux doivent être ajoutés et supprimés des observateurs), et planification du rendu vers DOM - cette proposition ne tente pas de résoudre ces problèmes.
Q : Dois-je supprimer les signaux liés à un widget lorsque ce widget est détruit ? Quelle est l'API pour ça ?
R : L'opération de démontage pertinente ici est Signal.subtle.Watcher.prototype.unwatch
. Seuls les signaux surveillés doivent être nettoyés (en ne les regardant plus), tandis que les signaux non surveillés peuvent être automatiquement récupérés.
Q : Les signaux fonctionnent-ils avec VDOM ou directement avec le DOM HTML sous-jacent ?
R : Oui ! Les signaux sont indépendants de la technologie de rendu. Les frameworks JavaScript existants qui utilisent des constructions de type Signal s'intègrent au VDOM (par exemple, Preact), au DOM natif (par exemple, Solid) et à une combinaison (par exemple, Vue). La même chose sera possible avec les signaux intégrés.
Q : Est-ce que cela sera ergonomique d'utiliser Signals dans le contexte de frameworks basés sur des classes comme Angular et Lit ? Qu’en est-il des frameworks basés sur un compilateur comme Svelte ?
R : Les champs de classe peuvent être basés sur le signal avec un simple décorateur d'accesseur, comme indiqué dans le fichier readme de Signal polyfill. Les signaux sont très étroitement alignés sur les runes de Svelte 5 : il est simple pour un compilateur de transformer les runes en API Signal définie ici, et en fait c'est ce que fait Svelte 5 en interne (mais avec sa propre bibliothèque de signaux).
Q : Les signaux fonctionnent-ils avec SSR ? L'hydratation ? Possibilité de reprise ?
R : Oui. Qwik utilise les signaux à bon escient avec ces deux propriétés, et d'autres frameworks ont d'autres approches bien développées de l'hydratation avec des signaux avec des compromis différents. Nous pensons qu'il est possible de modéliser les signaux pouvant être repris par Qwik en utilisant un signal d'état et un signal calculé reliés ensemble, et prévoyons de le prouver dans le code.
Q : Les signaux fonctionnent-ils avec un flux de données unidirectionnel comme le fait React ?
R : Oui, les signaux sont un mécanisme de flux de données unidirectionnel. Les frameworks d'interface utilisateur basés sur les signaux vous permettent d'exprimer votre point de vue en fonction du modèle (le modèle intégrant des signaux). Un graphique d'état et de signaux calculés est acyclique par construction. Il est également possible de recréer des anti-modèles React dans Signals (!), par exemple, l'équivalent Signal d'un setState
à l'intérieur de useEffect
consiste à utiliser un Watcher pour planifier une écriture dans un signal State.
Q : Quel est le lien entre les signaux et les systèmes de gestion d'état comme Redux ? Les signaux encouragent-ils un état non structuré ?
R : Les signaux peuvent constituer une base efficace pour des abstractions de gestion d’état de type magasin. Un modèle courant trouvé dans plusieurs frameworks est un objet basé sur un proxy qui représente en interne des propriétés à l'aide de signaux, par exemple Vue reactive()
ou des magasins Solid. Ces systèmes permettent un regroupement flexible d'états au bon niveau d'abstraction pour une application particulière.
Q : Quelles sont les offres Signals que Proxy
ne gère pas actuellement ?
R : Les proxys et les signaux sont complémentaires et vont bien ensemble. Les proxys vous permettent d'intercepter des opérations d'objets superficielles et des signaux de coordonner un graphe de dépendances (de cellules). Soutenir un proxy avec des signaux est un excellent moyen de créer une structure réactive imbriquée avec une grande ergonomie.
Dans cet exemple, nous pouvons utiliser un proxy pour que le signal ait une propriété getter et setter au lieu d'utiliser les méthodes get
et set
:
const a = nouveau Signal.State(0);const b = nouveau Proxy(a, { get(cible, propriété, récepteur) {if (propriété === 'valeur') { return target.get():} } set (cible, propriété, valeur, récepteur) {if (propriété === 'valeur') { target.set (valeur)!} }});// utilisation dans un contexte réactif hypothétique :<template> {b.valeur} <bouton onclick={() => {b.value++; }}>changer</button></template>
lorsque vous utilisez un moteur de rendu optimisé pour une réactivité fine, cliquer sur le bouton entraînera la mise à jour de la cellule b.value
.
Voir:
exemples de structures réactives imbriquées créées avec des signaux et des proxys : signal-utils
exemple d'implémentations antérieures montrant la relation entre les données réactives et les proxys : tracked-built-ins
discussion.
Q : Les signaux sont-ils basés sur le push ou sur le pull ?
R : L'évaluation des signaux calculés est basée sur l'extraction : les signaux calculés ne sont évalués que lorsque .get()
est appelé, même si l'état sous-jacent a changé beaucoup plus tôt. Dans le même temps, la modification d'un signal d'état peut immédiatement déclencher le rappel d'un observateur, « poussant » la notification. Les signaux peuvent donc être considérés comme une construction « push-pull ».
Q : Les signaux introduisent-ils du non-déterminisme dans l'exécution de JavaScript ?
R : Non. D'une part, toutes les opérations Signal ont une sémantique et un ordre bien définis, et ne différeront pas selon les implémentations conformes. À un niveau supérieur, les signaux suivent un certain ensemble d'invariants par rapport auxquels ils sont « sains ». Un signal calculé observe toujours le graphe du signal dans un état cohérent et son exécution n'est pas interrompue par un autre code de mutation du signal (sauf pour les choses qu'il appelle lui-même). Voir la description ci-dessus.
Q : Lorsque j'écris dans un signal d'état, quand la mise à jour du signal calculé est-elle planifiée ?
R : Ce n'est pas prévu ! Le signal calculé se recalculera la prochaine fois que quelqu'un le lira. De manière synchrone, le rappel notify
d'un Watcher peut être appelé, permettant aux frameworks de planifier une lecture au moment qu'ils jugent approprié.
Q : Quand les écritures dans les signaux d'état prennent-elles effet ? Immédiatement, ou sont-ils groupés ?
R : Les écritures dans les signaux d'état sont reflétées immédiatement : la prochaine fois qu'un signal calculé qui dépend du signal d'état est lu, il se recalculera si nécessaire, même si dans la ligne de code immédiatement suivante. Cependant, la paresse inhérente à ce mécanisme (les signaux calculés ne sont calculés que lorsqu'ils sont lus) signifie que, en pratique, les calculs peuvent s'effectuer de manière groupée.
Q : Qu'est-ce que cela signifie pour Signals de permettre une exécution « sans problème » ?
R : Les modèles de réactivité antérieurs basés sur le push étaient confrontés à un problème de calcul redondant : si une mise à jour d'un signal d'état entraîne l'exécution rapide du signal calculé, cela peut finalement pousser une mise à jour de l'interface utilisateur. Mais cette écriture dans l'interface utilisateur peut être prématurée, s'il devait y avoir un autre changement dans l'état d'origine du signal avant la trame suivante. Parfois, des valeurs intermédiaires inexactes étaient même présentées aux utilisateurs finaux en raison de tels problèmes. Les signaux évitent cette dynamique en étant basés sur le pull plutôt que sur le push : au moment où le framework planifie le rendu de l'interface utilisateur, il extraira les mises à jour appropriées, évitant ainsi un travail inutile à la fois en calcul et en écriture dans le DOM.
Q : Qu'est-ce que cela signifie pour les signaux d'être « avec perte » ?
R : C'est le revers d'une exécution sans problème : les signaux représentent une cellule de données - juste la valeur actuelle immédiate (qui peut changer), pas un flux de données au fil du temps. Ainsi, si vous écrivez dans un signal d'état deux fois de suite, sans rien faire d'autre, la première écriture est "perdue" et n'est jamais vue par les signaux ou effets calculés. Ceci est considéré comme une fonctionnalité plutôt qu'un bug : d'autres constructions (par exemple, les itérables asynchrones, les observables) sont plus appropriées pour les flux.
Q : Les signaux natifs seront-ils plus rapides que les implémentations JS Signal existantes ?
R : Nous l'espérons (avec un petit facteur constant), mais cela reste à prouver dans le code. Les moteurs JS ne sont pas magiques et devront finalement implémenter les mêmes types d'algorithmes que les implémentations JS de Signals. Voir la section ci-dessus sur les performances.
Q : Pourquoi cette proposition n'inclut-elle pas de fonction effect()
, alors que les effets sont nécessaires à toute utilisation pratique des signaux ?
R : Les effets sont intrinsèquement liés à la planification et à l'élimination, qui sont gérés par des cadres et en dehors du champ d'application de cette proposition. Au lieu de cela, cette proposition inclut la base de la mise en œuvre des effets via l’API Signal.subtle.Watcher
de plus bas niveau.
Q : Pourquoi les abonnements sont-ils automatiques plutôt que de proposer une interface manuelle ?
R : L'expérience a montré que les interfaces de souscription manuelles pour la réactivité sont peu ergonomiques et sujettes aux erreurs. Le suivi automatique est plus composable et constitue une fonctionnalité essentielle de Signals.
Q : Pourquoi le rappel de Watcher
s'exécute-t-il de manière synchrone, plutôt que planifié dans une microtâche ?
R : Étant donné que le rappel ne peut pas lire ou écrire des signaux, son appel de manière synchrone ne provoque aucun problème. Un rappel typique ajoutera un signal à un tableau pour être lu plus tard, ou marquera un peu quelque part. Il est inutile et peu coûteux de créer une microtâche distincte pour tous ces types d’actions.
Q : Il manque à cette API certaines fonctionnalités intéressantes fournies par mon framework préféré, qui facilitent la programmation avec Signals. Cela peut-il également être ajouté à la norme ?
R : Peut-être. Diverses extensions sont encore à l'étude. Veuillez déposer un problème pour susciter une discussion sur toute fonctionnalité manquante que vous jugez importante.
Q : Cette API peut-elle être réduite en taille ou en complexité ?
R : C'est définitivement un objectif de garder cette API minimale, et nous avons essayé de le faire avec ce qui est présenté ci-dessus. Si vous avez des idées sur d'autres éléments pouvant être supprimés, veuillez déposer un problème pour en discuter.
Q : Ne devrait-on pas commencer le travail de normalisation dans ce domaine avec un concept plus primitif, comme celui des observables ?
R : Les observables peuvent être une bonne idée pour certaines choses, mais ils ne résolvent pas les problèmes que les signaux visent à résoudre. Comme décrit ci-dessus, les observables ou autres mécanismes de publication/abonnement ne constituent pas une solution complète à de nombreux types de programmation d'interface utilisateur, en raison d'un travail de configuration trop sujet aux erreurs pour les développeurs et d'un gaspillage de travail dû au manque de paresse, entre autres problèmes.
Q : Pourquoi les signaux sont-ils proposés dans le TC39 plutôt que dans le DOM, étant donné que la plupart de ses applications sont basées sur le Web ?
R : Certains coauteurs de cette proposition s'intéressent aux environnements d'interface utilisateur non Web, mais de nos jours, l'un ou l'autre type de site peut convenir, car les API Web sont plus fréquemment implémentées en dehors du Web. En fin de compte, les signaux n'ont pas besoin de dépendre d'API DOM, donc les deux méthodes fonctionnent. Si quelqu'un a une bonne raison de changer de groupe, veuillez nous le faire savoir dans un problème. Pour l'instant, tous les contributeurs ont signé les accords de propriété intellectuelle du TC39, et il est prévu de les présenter au TC39.
Q : Combien de temps faudra-t-il avant que je puisse utiliser les signaux standards ?
R : Un polyfill est déjà disponible, mais il vaut mieux ne pas se fier à sa stabilité, car cette API évolue au cours de son processus de révision. Dans quelques mois ou un an, un polyfill stable de haute qualité et performant devrait être utilisable, mais cela sera encore soumis à des révisions en comité et pas encore standard. Suivant la trajectoire typique d'une proposition TC39, il devrait prendre au moins 2 à 3 ans au minimum absolu pour que les signaux soient disponibles de manière native sur tous les navigateurs remontant à quelques versions, de sorte que les polyfills ne soient pas nécessaires.
Q : Comment allons-nous éviter de normaliser trop tôt le mauvais type de signaux, tout comme {{Fonctionnalité JS/Web que vous n'aimez pas}} ?
R : Les auteurs de cette proposition prévoient de faire un effort supplémentaire en matière de prototypage et de démonstration avant de demander un avancement d'étape au TC39. Voir « Statut et plan de développement » ci-dessus. Si vous constatez des lacunes dans ce plan ou des opportunités d'amélioration, veuillez déposer un problème en expliquant.