Réactivité JavaScript: un exemple simple et intuitif

De nombreux frameworks frontaux JavaScript (tels que Angular, React et Vue) ont leurs propres systèmes de réactivité. Comprendre les fonctionnalités de ces systèmes sera utile à tout développeur, l'aidera à utiliser plus efficacement les frameworks JS modernes.



Le matériel, dont nous publions la traduction aujourd'hui, montre un exemple étape par étape de développement d'un système de réactivité en JavaScript pur. Ce système implémente les mêmes mécanismes que ceux utilisés dans Vue.

Système de réactivité


Pour quelqu'un qui rencontre pour la première fois le système de réactivité Vue, cela peut sembler une mystérieuse boîte noire. Prenons une simple application Vue. Voici le balisage:

<div id="app">    <div>Price: ${{ price }}</div>    <div>Total: ${{ price*quantity }}</div>    <div>Taxes: ${{ totalPriceWithTax }}</div> </div> 

Voici la commande de connexion du framework et le code d'application.

 <script src="https://cdn.jsdelivr.net/npm/vue"></script> <script>   var vm = new Vue({       el: '#app',       data: {           price: 5.00,           quantity: 2       },       computed: {           totalPriceWithTax() {               return this.price * this.quantity * 1.03           }       }   }) </script> 

D'une manière ou d'une autre, Vue découvre que lorsque le price change, le moteur doit faire trois choses:

  1. Actualisez price valeur du price sur la page Web.
  2. Recalculez l'expression dans laquelle le price est multiplié par la quantity et affichez la valeur résultante sur la page.
  3. Appelez la fonction totalPriceWithTax et, encore une fois, mettez ce qu'elle renvoie sur la page.

Ce qui se passe ici est illustré dans l'illustration suivante.


Comment Vue sait-il quoi faire lorsque la propriété de prix change?

Nous avons maintenant des questions sur la façon dont Vue sait exactement ce qui doit être mis à jour lorsque le price change, et comment le moteur suit ce qui se passe sur la page. Ce que vous pouvez observer ici ne ressemble pas à une application JS standard.

Ce n'est peut-être pas encore évident, mais le principal problème que nous devons résoudre ici est que les programmes JS ne fonctionnent généralement pas comme ça. Par exemple, exécutons le code suivant:

 let price = 5 let quantity = 2 let total = price * quantity //  10 price = 20; console.log(`total is ${total}`) 

Que pensez-vous qui sera affiché dans la console? Étant donné que rien n'est utilisé ici, sauf JS ordinaire, 10 arrivera à la console.


Le résultat du programme

Et lorsque vous utilisez les capacités de Vue, dans une situation similaire, nous pouvons implémenter un scénario dans lequel la valeur total est recomptée lorsque les variables de price ou de quantity changent. Autrement dit, si le système de réactivité était utilisé dans l'exécution du code ci-dessus, alors pas 10, mais 40 serait affiché sur la console:


Sortie console générée par un code hypothétique utilisant un système de réactivité

JavaScript est un langage qui peut fonctionner à la fois procédural et orienté objet, mais il n'a pas de système de réactivité intégré, donc le code que nous avons pris en compte lors du changement de price ne donnera pas le numéro 40 à la console. Pour que l'indicateur total soit recalculé lorsque le price ou la quantity change, nous devrons créer nous-mêmes un système de réactivité et ainsi obtenir le comportement dont nous avons besoin. Nous allons briser le chemin vers cet objectif en plusieurs petites étapes.

Tâche: stockage des règles de calcul des indicateurs


Nous avons besoin d'un endroit pour enregistrer des informations sur la façon dont l'indicateur total est calculé, ce qui nous permettra de le recalculer lors du changement des valeurs des variables de price ou de quantity .

▍Solution


Tout d'abord, nous devons dire à l'application ce qui suit: "Voici le code que je vais exécuter, enregistrez-le, je devrai peut-être l'exécuter une autre fois." Ensuite, nous devrons exécuter le code. Plus tard, si les indicateurs de price ou de quantity ont changé, vous devrez appeler le code enregistré pour recalculer le total . Cela ressemble à ceci:


Le code de calcul total doit être enregistré quelque part afin de pouvoir y accéder plus tard

Le code que vous pouvez appeler en JavaScript pour effectuer une action est formaté en tant que fonctions. Par conséquent, nous écrirons une fonction qui traite du calcul du total , et créerons également un mécanisme pour stocker les fonctions dont nous pourrions avoir besoin plus tard.

 let price = 5 let quantity = 2 let total = 0 let target = null target = function () {   total = price * quantity } record() //       ,       target() //   

Notez que nous stockons la fonction anonyme dans la variable target , puis appelons la fonction d' record . Nous en parlerons ci-dessous. Je voudrais également noter que la fonction target , en utilisant la syntaxe des fonctions fléchées ES6, peut être réécrite comme suit:

 target = () => { total = price * quantity } 

Voici la déclaration de la fonction d' record et la structure de données utilisée pour stocker les fonctions:

 let storage = [] //     target function record () { // target = () => { total = price * quantity }   storage.push(target) } 

En utilisant la fonction d' record , nous enregistrons la fonction target (dans notre cas { total = price * quantity } ) dans la matrice de storage , ce qui nous permet d'appeler cette fonction plus tard, éventuellement en utilisant la fonction de replay , dont le code est illustré ci-dessous. Cela nous permettra d'appeler toutes les fonctions stockées dans le storage .

 function replay () {   storage.forEach(run => run()) } 

Ici, nous passons en revue toutes les fonctions anonymes stockées dans la matrice de storage et exécutons chacune d'entre elles.

Ensuite, dans notre code, nous pouvons faire ce qui suit:

 price = 20 console.log(total) // 10 replay() console.log(total) // 40 

Ça n'a pas l'air si difficile, non? Voici l'intégralité du code, dont nous avons discuté les fragments ci-dessus, au cas où il serait plus pratique pour vous de finalement y faire face. Soit dit en passant, ce code n'est pas accidentellement écrit de cette façon.

 let price = 5 let quantity = 2 let total = 0 let target = null let storage = [] function record () {   storage.push(target) } function replay () {   storage.forEach(run => run()) } target = () => { total = price * quantity } record() target() price = 20 console.log(total) // 10 replay() console.log(total) // 40 

C'est ce qui sera affiché dans la console du navigateur après son démarrage.


Résultat du code

Challenge: une solution fiable pour stocker des fonctions


Nous pouvons continuer à noter les fonctions dont nous avons besoin si nécessaire, mais ce serait bien si nous avions une solution plus fiable qui peut être mise à l'échelle avec l'application. Ce sera peut-être une classe qui conserve une liste de fonctions écrites à l'origine dans la variable target et qui reçoit des notifications si nous devons réexécuter ces fonctions.

▍Solution: classe de dépendance


Une approche pour résoudre le problème ci-dessus consiste à encapsuler le comportement dont nous avons besoin dans une classe, qui peut être appelée dépendance. Cette classe implémentera le modèle de programmation d'observateur standard.

Par conséquent, si nous créons une classe JS utilisée pour gérer nos dépendances (qui sera proche de la façon dont des mécanismes similaires sont implémentés dans Vue), cela peut ressembler à ceci:

 class Dep { // Dep -    Dependency   constructor () {       this.subscribers = [] //  ,                               //    notify()   }   depend () { //   record       if (target && !this.subscribers.includes(target)){           //    target                //                this.subscribers.push(target)       }   }   notify () { //   replay       this.subscribers.forEach(sub => sub())       //  -     } } 

Veuillez noter qu'au lieu de la matrice de storage , nous stockons maintenant nos fonctions anonymes dans la matrice des subscribers . Au lieu de la fonction d' record , la méthode depend est maintenant appelée. Ici aussi, au lieu de la fonction de replay , la fonction de notify est notify . Voici comment exécuter notre code à l'aide de la classe Dep :

 const dep = new Dep() let price = 5 let quantity = 2 let total = 0 let target = () => { total = price * quantity } dep.depend() //   target    target() //     total console.log(total) // 10 -   price = 20 console.log(total) // 10 -    ,    dep.notify() //   -  console.log(total) // 40 -    

Notre nouveau code fonctionne de la même manière qu'auparavant, mais maintenant il est mieux conçu et il semble préférable de le réutiliser.

Jusqu'à présent, la seule chose qui semble étrange est de travailler avec une fonction stockée dans la variable target .

Tâche: mécanisme de création de fonctions anonymes


À l'avenir, nous devrons créer un objet de classe Dep pour chaque variable. De plus, il serait bien d'encapsuler le comportement de création de fonctions anonymes quelque part, qui devrait être appelé lors de la mise à jour des données pertinentes. Peut-être que cela nous aidera avec une fonction supplémentaire, que nous appellerons watcher . Cela conduira au fait que nous pouvons remplacer cette construction de l'exemple précédent par une nouvelle fonction:

 let target = () => { total = price * quantity } dep.depend() target() 

En fait, un appel à la fonction watcher qui remplace ce code ressemblera à ceci:

 watcher(() => {   total = price * quantity }) 

▍ Solution: fonction d'observateur


A l'intérieur de la fonction watcher , dont le code est présenté ci-dessous, nous pouvons effectuer plusieurs actions simples:

 function watcher(myFunc) {   target = myFunc //   target   myFunc   dep.depend() //  target      target() //     target = null //   target } 

Comme vous pouvez le voir, la fonction watcher prend, comme argument, la fonction myFunc , l'écrit dans la variable target globale, appelle dep.depend() pour ajouter cette fonction à la liste des abonnés, appelle cette fonction et réinitialise la variable target .
Maintenant, nous obtenons tous les mêmes valeurs 10 et 40 si nous exécutons le code suivant:

 price = 20 console.log(total) dep.notify() console.log(total) 

Vous vous demandez peut-être pourquoi nous avons implémenté target tant que variable globale, au lieu de transmettre cette variable à nos fonctions, si nécessaire. Nous avons de bonnes raisons de le faire, vous comprendrez plus tard.

Tâche: posséder un objet Dep pour chaque variable


Nous avons un seul objet de classe Dep . Et si nous avions besoin que chacune de nos variables ait son propre objet de classe Dep ? Avant de continuer, déplaçons les données avec lesquelles nous travaillons vers les propriétés de l'objet:

 let data = { price: 5, quantity: 2 } 

Imaginez un instant que chacune de nos propriétés ( price et quantity ) possède son propre objet de classe Dep interne.


Propriétés de prix et de quantité

Maintenant, nous pouvons appeler la fonction watcher comme ceci:

 watcher(() => {   total = data.price * data.quantity }) 

Puisque nous travaillons ici avec la valeur de la propriété data.price , nous avons besoin de l'objet de classe Dep de la propriété price pour placer une fonction anonyme (stockée dans target ) dans son tableau d'abonnés (en appelant dep.depend() ). De plus, puisque nous travaillons avec data.quantity , nous avons besoin de l'objet Dep de la propriété quantity pour placer une fonction anonyme (à nouveau, stockée dans target ) dans son tableau d'abonnés.

Si vous décrivez cela sous la forme d'un diagramme, vous obtenez ce qui suit.


Les fonctions tombent dans des tableaux d'abonnés d'objets de classe Dep correspondant à différentes propriétés

Si nous avons une fonction anonyme de plus dans laquelle nous travaillons uniquement avec la propriété data.price , alors la fonction anonyme correspondante ne doit aller qu'au dépôt de l'objet de cette propriété.


Des observateurs supplémentaires peuvent être ajoutés à une seule des propriétés disponibles.

Quand pourriez-vous avoir besoin d'appeler dep.notify() pour les fonctions souscrites aux modifications de la propriété price ? Cela sera nécessaire lors du changement de price . Cela signifie que lorsque notre exemple est complètement prêt, le code suivant devrait fonctionner pour nous.


Ici, lors du changement de prix, vous devez appeler dep.notify () pour toutes les fonctions souscrites au changement de prix

Pour que tout fonctionne de cette façon, nous avons besoin d'un moyen d'intercepter les événements d'accès à la propriété (dans notre cas, c'est le price ou la quantity ). Cela permettra, lorsque cela se produira, de sauvegarder la fonction target dans un tableau d'abonnés, et lorsque la variable correspondante changera, d'exécuter la fonction stockée dans ce tableau.

▍Solution: Object.defineProperty ()


Maintenant, nous devons nous familiariser avec la méthode ES5 standard Object.defineProperty (). Il vous permet d'affecter des getters et setters aux propriétés des objets. Permettez-moi, avant de passer à leur utilisation pratique, de démontrer le fonctionnement de ces mécanismes avec un exemple simple.

 let data = { price: 5, quantity: 2 } Object.defineProperty(data, 'price', { //       price   get() { //        console.log(`I was accessed`)   },   set(newVal) { //        console.log(`I was changed`)   } }) data.price //       data.price = 20 //      

Si vous exécutez ce code dans la console du navigateur, il affichera le texte suivant.


Getter and Setter Results

Comme vous pouvez le voir, notre exemple imprime simplement quelques lignes de texte sur la console. Cependant, il ne lit ni ne définit de valeurs, car nous avons redéfini la fonctionnalité standard des getters et setters. Nous restaurerons la fonctionnalité de ces méthodes. À savoir, il est prévu que les getters retournent les valeurs des méthodes correspondantes et que les setters les définissent. Par conséquent, nous ajouterons une nouvelle variable, internalValue , au code, que nous utiliserons pour stocker la valeur actuelle du price .

 let data = { price: 5, quantity: 2 } let internalValue = data.price //   Object.defineProperty(data, 'price', { //       price   get() { //        console.log(`Getting price: ${internalValue}`)       return internalValue   },   set(newVal) {       console.log(`Setting price to: ${newVal}`)       internalValue = newVal   } }) total = data.price * data.quantity //       data.price = 20 //      

Maintenant que le getter et le setter fonctionnent comme ils devraient fonctionner, que pensez-vous qu'il y aura dans la console lorsque ce code sera exécuté? Jetez un œil à la figure suivante.


Sortie de données vers la console

Donc, nous avons maintenant un mécanisme qui vous permet de recevoir des notifications lors de la lecture des valeurs de propriété et lorsque de nouvelles valeurs leur sont écrites. Maintenant, après avoir un peu retravaillé le code, nous pouvons équiper les getters et les setters de toutes les propriétés de l'objet de data . Ici, nous utiliserons la méthode Object.keys() , qui retourne un tableau de clés de l'objet qui lui est passé.

 let data = { price: 5, quantity: 2 } Object.keys(data).forEach(key => { //        data   let internalValue = data[key]   Object.defineProperty(data, key, {       get() {           console.log(`Getting ${key}: ${internalValue}`)           return internalValue       },       set(newVal) {           console.log(`Setting ${key} to: ${newVal}`)           internalValue = newVal       }   }) }) let total = data.price * data.quantity data.price = 20 

Maintenant, toutes les propriétés de l'objet de data ont des getters et des setters. C'est ce qui apparaît dans la console après avoir exécuté ce code.


Sortie de données vers la console par les getters et les setters

Assemblage du système de réactivité


Lorsqu'un fragment de code comme total = data.price * data.quantity et total = data.price * data.quantity valeur de la propriété price , nous avons besoin de la propriété price pour «se souvenir» de la fonction anonyme correspondante ( target dans notre cas). Par conséquent, si la propriété price est modifiée, c'est-à-dire définie sur une nouvelle valeur, cela conduira à un appel à cette fonction pour répéter les opérations qu'elle effectue, car elle sait qu'une certaine ligne de code en dépend. En conséquence, les opérations effectuées dans les getters et setters peuvent être imaginées comme suit:

  • Getter - vous devez vous souvenir de la fonction anonyme, que nous appellerons à nouveau lorsque la valeur changera.
  • Setter - il est nécessaire d'exécuter la fonction anonyme stockée, ce qui entraînera une modification de la valeur résultante correspondante.

Si vous utilisez la classe Dep déjà connue dans cette description, vous obtenez les éléments suivants:

  • Lors de la lecture d'une valeur de propriété, dep.depend() est appelé pour enregistrer la fonction target actuelle.
  • Lorsqu'une valeur est écrite dans une propriété, dep.notify() est appelé pour redémarrer toutes les fonctions stockées.

Maintenant, nous allons combiner ces deux idées et, enfin, nous arriverons au code qui nous permet d'atteindre notre objectif.

 let data = { price: 5, quantity: 2 } let target = null //  -    ,     class Dep {   constructor () {       this.subscribers = []   }   depend () {       if (target && !this.subscribers.includes(target)){           this.subscribers.push(target)       }   }   notify () {       this.subscribers.forEach(sub => sub())   } } //      ,  //      Object.keys(data).forEach(key => {   let internalValue = data[key]   //         //   Dep   const dep = new Dep()   Object.defineProperty(data, key, {       get() {           dep.depend() //    target           return internalValue       },       set(newVal) {           internalValue = newVal           dep.notify() //           }   }) }) //   watcher   dep.depend(), //        function watcher(myFunc){   target = myFunc   target()   target = null } watcher(() => {   data.total = data.price * data.quantity }) 

Essayons avec ce code dans la console du navigateur.


Expériences de code prêt

Comme vous pouvez le voir, cela fonctionne exactement comme nous en avons besoin! Les propriétés de price et de quantity sont devenues réactives! Tout le code responsable de la génération du total lorsque le price ou la quantity change est exécuté à plusieurs reprises.

Maintenant, après avoir écrit notre propre système de réactivité, cette illustration de la documentation Vue vous semblera familière et compréhensible.


Système de réactivité Vue

Voir ce beau cercle violet qui dit contenant des getters et setters? Maintenant, il devrait vous être familier. Chaque instance du composant a une instance de la méthode observateur (cercle bleu), qui collecte les dépendances sur les getters (ligne rouge). Lorsque, plus tard, le setter est appelé, il notifie la méthode observateur, ce qui conduit au re-rendu du composant. Voici le même schéma, fourni avec des explications le reliant à notre développement.


Diagramme de réactivité Vue avec explications

Nous pensons que maintenant, après avoir écrit notre propre système de réactivité, ce schéma n'a pas besoin d'explications supplémentaires.

Bien sûr, dans Vue, tout cela est plus compliqué, mais maintenant vous devez comprendre le mécanisme sous-jacent aux systèmes de réactivité.

Résumé


Après avoir lu ce matériel, vous avez appris ce qui suit:

  • Comment créer une classe Dep qui collecte des fonctions à l'aide de la méthode depend et, si nécessaire, les appelle à nouveau à l'aide de la méthode notify .
  • Comment créer une fonction d' watcher qui vous permet de contrôler le code que nous exécutons (c'est la fonction target ), que vous devrez peut-être enregistrer dans l'objet de la classe Dep .
  • Comment utiliser la méthode Object.defineProperty() pour créer des getters et des setters.

Tout cela, compilé dans un seul exemple, a conduit à la création d'un système de réactivité en JavaScript pur, en comprenant que vous pouvez comprendre les caractéristiques du fonctionnement de tels systèmes utilisés dans les frameworks web modernes.

Chers lecteurs! Si, avant de lire ce matériel, vous avez mal imaginé les caractéristiques des mécanismes des systèmes de réactivité, dites-moi, avez-vous maintenant réussi à y faire face?

Source: https://habr.com/ru/post/fr418633/


All Articles