Modèles de réactivité expliqués
Cela fait (déjà) 10 ans que je commence à développer des applications et des sites web, mais l'écosystème JavaScript n'a jamais été aussi passionnant qu'aujourd'hui !
En 2022, la communauté a été captivée par le concept de « Signal » au point que la plupart des frameworks JavaScript les ont intégrés dans leur propre moteur. Je pense à Preact, qui propose depuis septembre 2022 des variables réactives découplées du cycle de vie des composants ; ou plus récemment Angular, qui a implémenté Signals expérimentalement en mai 2023, puis officiellement à partir de la version 18. D'autres librairies JavaScript ont également choisi de repenser leur approche...
Entre 2023 et jusqu’à présent, j’ai systématiquement utilisé Signals dans divers projets. Leur simplicité de mise en œuvre et d'utilisation m'a pleinement convaincu, à tel point que j'ai partagé leurs bénéfices avec mon réseau professionnel lors d'ateliers techniques, de formations et de conférences.
Mais plus récemment, j'ai commencé à me demander si ce concept était vraiment "révolutionnaire" / s'il existait des alternatives aux Signaux ? J’ai donc approfondi cette réflexion et découvert différentes approches des systèmes réactifs.
Cet article est un aperçu des différents modèles de réactivité, ainsi que de ma compréhension de leur fonctionnement.
NB : À ce stade, vous l'aurez probablement deviné, je ne parlerai pas des "Reactive Streams" de Java ; sinon, j'aurais intitulé cet article "WTF Is Backpression !?" ?
Quand on parle de modèles de réactivité, on parle (avant tout) de "programmation réactive", mais surtout de "réactivité".
La programmation réactive est un paradigme de développement qui permet de propager automatiquement le changement d'une source de données aux consommateurs.
On peut donc définir la réactivité comme la possibilité de mettre à jour les dépendances en temps réel, en fonction de l'évolution des données.
NB : Bref, lorsqu'un utilisateur remplit et/ou soumet un formulaire, il faut réagir à ces changements, afficher un composant de chargement, ou tout autre chose qui précise qu'il se passe quelque chose. .. Autre exemple, lors de la réception de données de manière asynchrone, il faut réagir en affichant tout ou partie de ces données, en exécutant une nouvelle action, etc.
Dans ce contexte, les bibliothèques réactives fournissent des variables qui se mettent automatiquement à jour et se propagent efficacement, facilitant ainsi l'écriture de code simple et optimisé.
Pour être efficaces, ces systèmes doivent recalculer/réévaluer ces variables si, et seulement si, leurs valeurs ont changé ! De la même manière, pour garantir que les données diffusées restent cohérentes et à jour, le système doit éviter d'afficher tout état intermédiaire (notamment lors du calcul des changements d'état).
NB : L'état fait référence aux données/valeurs utilisées tout au long de la durée de vie d'un programme/application.
D'accord, mais alors… C'est quoi exactement ces "modèles de réactivité" ?
Le premier modèle de réactivité est appelé "PUSH" (ou réactivité "empressée"). Ce système repose sur les principes suivants :
Comme vous l'avez peut-être deviné, le modèle « PUSH » s'appuie sur le modèle de conception « Observable/Observer ».
Considérons l'état initial suivant,
let a = { firstName: "John", lastName: "Doe" }; const b = a.firstName; const c = a.lastName; const d = `${b} ${c}`;
En utilisant une bibliothèque réactive (telle que RxJS), cet état initial ressemblerait davantage à ceci :
let a = observable.of({ firstName: "John", lastName: "Doe" }); const b = a.pipe(map((a) => a.firstName)); const c = a.pipe(map((a) => a.lastName)); const d = merge(b, c).pipe(reduce((b, c) => `${b} ${c}`));
NB : Pour les besoins de cet article, tous les extraits de code doivent être considérés comme des "pseudo-codes".
Maintenant, supposons qu'un consommateur (un composant, par exemple) souhaite enregistrer la valeur de l'état D chaque fois que cette source de données est mise à jour,
d.subscribe((value) => console.log(value));
Notre composant s'abonnerait au flux de données ; il faut encore qu'il déclenche un changement,
a.next({ firstName: "Jane", lastName: "Doe" });
A partir de là, le système « PUSH » détecte le changement et le diffuse automatiquement aux consommateurs. Sur la base de l'état initial ci-dessus, voici une description des opérations qui pourraient se produire :
L'un des défis de ce système réside dans l'ordre de calcul. En effet, d’après notre cas d’usage, vous remarquerez que D peut être évalué deux fois : une première fois avec la valeur de C dans son état précédent ; et une seconde fois avec la valeur de C à jour ! Dans ce genre de modèle de réactivité, ce défi s'appelle le "Diamond Problem" ♦️.
Maintenant, supposons que l'État s'appuie sur deux sources de données principales,
let a = { firstName: "John", lastName: "Doe" }; const b = a.firstName; const c = a.lastName; const d = `${b} ${c}`;
Lors de la mise à jour de E, le système recalculera l'intégralité de l'état, ce qui lui permet de conserver une seule source de vérité en écrasant l'état précédent.
Encore une fois, le "Diamond Problem" survient... Cette fois sur la source de données C qui est potentiellement évaluée 2 fois, et toujours sur D.
Le « Problème du Diamant » n'est pas un nouveau défi dans le modèle de réactivité « avide ». Certains algorithmes de calcul (en particulier ceux utilisés par MobX) peuvent baliser les « nœuds de l'arbre de dépendances réactif » pour niveler le calcul de l'état. Avec cette approche, le système évaluerait d'abord les sources de données « racine » (A et E dans notre exemple), puis B et C, et enfin D. Changer l'ordre des calculs d'état permet de résoudre ce genre de problème.
Le deuxième modèle de réactivité est appelé "PULL". Contrairement au modèle "PUSH", il repose sur les principes suivants :
C'est cette dernière règle qu'il est le plus important de retenir : contrairement au système précédent, ce dernier diffère le calcul de l'état pour éviter les évaluations multiples d'une même source de données.
Conservons l'état initial précédent...
Dans ce genre de système, la syntaxe de l'état initial serait sous la forme suivante :
let a = observable.of({ firstName: "John", lastName: "Doe" }); const b = a.pipe(map((a) => a.firstName)); const c = a.pipe(map((a) => a.lastName)); const d = merge(b, c).pipe(reduce((b, c) => `${b} ${c}`));
NB :Les passionnés de React reconnaîtront probablement cette syntaxe ?
Déclarer une variable réactive donne "naissance" à un tuple : variable immuable d'un côté ; fonction de mise à jour de cette variable d'autre part. Les instructions restantes (B, C et D dans notre cas) sont considérées comme des états dérivés puisqu'elles « écoutent » leurs dépendances respectives.
d.subscribe((value) => console.log(value));
La caractéristique déterminante d'un système « paresseux » est qu'il ne propage pas les modifications immédiatement, mais uniquement lorsqu'elles sont explicitement demandées.
let a = { firstName: "John", lastName: "Doe" }; const b = a.firstName; const c = a.lastName; const d = `${b} ${c}`;
Dans un modèle "PULL", l'utilisation d'un effect() (issu d'un composant) pour enregistrer la valeur d'une variable réactive (spécifiée comme dépendance) déclenche le calcul du changement d'état :
Une optimisation de ce système est possible lors de l'interrogation des dépendances. En effet, dans le scénario ci-dessus, A est interrogé deux fois pour déterminer s'il a été mis à jour. Cependant, la première requête pourrait suffire à définir si l’état a changé. C n'aurait pas besoin d'effectuer cette action... Au lieu de cela, A pourrait uniquement diffuser sa valeur.
Compliquons quelque peu l'état en ajoutant une deuxième variable réactive "root",
let a = observable.of({ firstName: "John", lastName: "Doe" }); const b = a.pipe(map((a) => a.firstName)); const c = a.pipe(map((a) => a.lastName)); const d = merge(b, c).pipe(reduce((b, c) => `${b} ${c}`));
Une fois de plus, le système diffère le calcul de l'état jusqu'à ce qu'il soit explicitement demandé. En utilisant le même effet que précédemment, la mise à jour d'une nouvelle variable réactive déclenchera les étapes suivantes :
Étant donné que la valeur de A n'a pas changé, il n'est pas nécessaire de recalculer cette variable (la même chose s'applique à la valeur de B). Dans de tels cas, l'utilisation d'algorithmes de mémorisation améliore les performances lors du calcul de l'état.
Le dernier modèle de réactivité est le système "PUSH-PULL". Le terme "PUSH" reflète la propagation immédiate des notifications de modification, tandis que "PULL" fait référence à la récupération des valeurs d'état à la demande. Cette approche est étroitement liée à ce que l'on appelle la réactivité « fine », qui adhère aux principes suivants :
A noter que ce genre de réactivité n'est pas exclusif au modèle "PUSH-PULL". La réactivité fine fait référence au suivi précis des dépendances du système. Il existe donc des modèles de réactivité PUSH et PULL qui fonctionnent aussi de cette façon (je pense au Jotai ou au Recoil.
Toujours basé sur l'état initial précédent... La déclaration d'un état initial dans un système de réactivité "à grain fin" ressemblerait à ceci :
let a = { firstName: "John", lastName: "Doe" }; const b = a.firstName; const c = a.lastName; const d = `${b} ${c}`;
NB : L'utilisation du mot-clé signal n'est pas ici qu'anecdotique ?
En termes de syntaxe, il est très similaire au modèle "PUSH", mais il y a une différence notable et importante : les dépendances ! Dans un système de réactivité "à grain fin" , il n'est pas nécessaire de déclarer explicitement les dépendances requises pour calculer un état dérivé, car ces états suivent implicitement les variables qu'ils utilisent. Dans notre cas, B et C suivront automatiquement les modifications apportées à la valeur de A, et D suivra les modifications apportées à B et C.
let a = observable.of({ firstName: "John", lastName: "Doe" }); const b = a.pipe(map((a) => a.firstName)); const c = a.pipe(map((a) => a.lastName)); const d = merge(b, c).pipe(reduce((b, c) => `${b} ${c}`));
Dans un tel système, la mise à jour d'une variable réactive est plus efficace que dans un modèle basique "PUSH" car le changement est automatiquement propagé aux variables dérivées qui en dépendent (uniquement sous forme de notification, pas la valeur elle-même).
d.subscribe((value) => console.log(value));
Ensuite, à la demande (prenons l'exemple du logger), l'utilisation de D au sein du système va récupérer les valeurs des états racines associés (dans notre cas A), calculer les valeurs des états dérivés (B et C), et enfin évaluer D. N'est-ce pas un mode de fonctionnement intuitif ?
Considérons l'état suivant,
a.next({ firstName: "Jane", lastName: "Doe" });
Encore une fois, l'aspect "fin" du système PUSH-PULL permet un suivi automatique de chaque état. Ainsi, l'état dérivé C suit désormais les états racine A et E. La mise à jour de la variable E déclenchera les actions suivantes :
C'est cette association préalable de dépendances réactives entre elles qui rend ce modèle si efficace !
En effet, dans un système classique "PULL" (comme le Virtual DOM de React par exemple), lors de la mise à jour d'un état réactif depuis un composant, le framework sera notifié du changement (déclenchant un " phase différente"). Ensuite, à la demande (et en différé), le framework calculera les modifications en parcourant l'arbre de dépendances réactif ; à chaque fois qu'une variable est mise à jour ! Cette "découverte" de l'état des dépendances a un coût important...
Avec un système de réactivité "à grain fin" (comme les signaux), la mise à jour des variables/primitives réactives notifie automatiquement tout état dérivé qui leur est lié du changement. Il n’est donc pas nécessaire de (re)découvrir les dépendances associées ; la propagation de l'État est ciblée !
En 2024, la plupart des frameworks web ont choisi de repenser leur fonctionnement, notamment au niveau de leur modèle de réactivité. Ce changement les a rendus généralement plus efficaces et compétitifs. D'autres choisissent d'être (encore) hybrides (je pense ici à Vue), ce qui les rend plus flexibles dans de nombreuses situations.
Enfin, quel que soit le modèle choisi, selon moi, un (bon) système réactif se construit sur quelques règles principales :
Ce dernier point, qui peut être interprété comme un principe fondamental de la programmation déclarative, est la façon dont je vois un (bon) système réactif comme devant être déterministe ! C'est ce « déterminisme » qui rend un modèle réactif fiable, prévisible et facile à utiliser dans des projets techniques à grande échelle, quelle que soit la complexité de l'algorithme.
Ce qui précède est le contenu détaillé de. pour plus d'informations, suivez d'autres articles connexes sur le site Web de PHP en chinois!