:: Enseignements :: ESIPE :: E3INFO :: 2022-2023 :: Programmation Web avec JavaScript ::
[LOGO]

Librarie Réactive


Le but de ce TP est d'écrire une version simplifiée d'une bibliothèque réactive (comme react ou hyperapp) de manipulation du DOM.

Voilà le code de la page HTML, virtualdom.html, que l'on veut afficher
<!DOCTYPE html>
<html>
 <head>
   <meta charset="utf8"/>
   <script src="virtualdom.js" defer></script>
 </head>
 <body>
  <div id = "my_node">
  </div>
 </body>
</html>
ainsi que le fichier virtualdom.js
'use strict';
// Ecrire le reste du code ici !

render({
  state: 0,
  view: state =>
    v("div", {}, [
      v("h1", { class: "red" }, state),
      v("button", { onclick: state => state - 1 }, "subtract"),
      v("button", { onclick: state => state + 1 }, "add")
    ]),
  node: document.getElementById("my_node")
});

Exercice 1 - Virtual DOMination

Une application réactive est une application qui change son affichage en fonction du changement de valeur d'un objet particulier qui correspond à l'état de l'application. Par exemple, une application simple qui affiche une valeur avec un bouton + et - pour incrémenter ou décrémenter la valeur aura pour état un simple entier tandis que des applications plus compliquées utiliseront des listes ou des objets comme état.
Une application réactive est composée de 3 valeurs différentes, l'état dont nous venons de parler, la vue qui permet à partir de l'état de générer un arbre DOM qui sera affiché (une fonction donc) et enfin un objet DOM de la page HTML qui va être remplacé par l'arbre DOM généré par la vue.
Donc de façon rudimentaire, l'application réactive qui incrémente et décrémente un entier peut se décrire par l'object JavaScript suivant :
    {
      state: 0,
      view: state => ... ,
      node: document.getElementById("my_node")
    }
   

Reste à savoir comment créer l'arbre DOM correspondant à l'état (les ... dans l'exemple, ci-dessus), on se propose pour cela d'utiliser ce que l'on appelle un virtual DOM qui est un arbre comme l'arbre DOM, mais géré en JavaScript (contrairement à un arbre DOM qui est géré en C++ et visible en JavaScript).
Voici un exemple de virtual DOM
    v("div", {}, [
      v("h1", { class: "red" }, state),
      v("button", { onclick: state => state - 1 }, "subtract"),
      v("button", { onclick: state => state + 1 }, "add")
    ])
   
La fonction v prend 3 paramètres, un nom d'élément (ici, div, h1 ou button), un ensemble de propriétés qui sont les propriétés de l'élément (par exemple la class de l'élément h1 ou la propriété onclick) et enfin une valeur qui peut être soit un élément, soit un tableau d'éléments soit une valeur qui va être transformée en string pour être affichée.
Pour notre implantation, la fonction v va renvoyer une instance de la classe Element ayant les attributs name (le nom de l'élément), properties un objet contenant les propriétés de l'élément et textOrChildren qui contient soit un élément, un tableau d'éléments ou une valeur.
De plus, la classe Element va posséder une méthode toDOM qui permet à partir d'un élément du virtual-dom de transformer celui-ci en élément réel du DOM, créé en utilisant les méthodes habituelles document.createElement(), document.createTextNode(), element.appendChild() et element.append().
On peut noter que la fonction prise en paramètre par onclick n'est pas la fonction classique prise par la propriété onclick sur l'arbre DOM, en effet, cette fonction est ajoutée sur le virtual dom pas sur le DOM généré. La fonction classique onclick enregistrer lors de la génération de l'arbre DOM par la méthode toDOM va appeler la fonction du virtual dom avec en paramétre l'état de l'application, puis stocker le résultat de l'appel dans le champs state et enfin appeler la fonction render pour changer l'affichage graphique.
Cette astuce permet d'être sur que lorsque l'on clique sur un bouton, l'état de l'application est mis à jour et que l'affichage graphique est rafraîchi.

Si on met les différentes parties ensemble, on obtient le code suivant :
render({
  state: 0,
  view: state =>
    v("div", {}, [
      v("h1", { class: "red" }, state),
      v("button", { onclick: state => state - 1 }, "subtract"),
      v("button", { onclick: state => state + 1 }, "add")
    ]),
  node: document.getElementById("my_node")
});
   
Lors de l'affichage de la page, la fonction render prend l'objet correspondant à l'application, récupère la valeur de l'état, appelle la fonction view pour créer l'arbre d'éléments (le virtual dom, appelle la méthode toDOM sur l'arbre d'élément pour créer l'arbre DOM correspondant puis remplace l'objet node par le nœud racine de l'arbre DOM créé (le remplacement se fait en demandant le parentNode de node puis en appelant la méthode replaceChild() sur le parent (attention, les arguments de replaceChild() sont inversés !)).

Le code JavaScript doit utiliser la version JavaScript 2015 (classes, fonctions flèches, etc).

Les deux dernières questions optionnelles sont indépendantes.

  1. Ecrire la classe Element et son constructeur. Ecrire la fonction v qui renvoie une nouvelle instance de la classe Element à chaque appel.
  2. Ecrire dans la classe Element la méthode toDOM et vérifier avec un exemple sans gestion des évènements que l'arbre DOM généré est correct.
    Note: Array.isArray permet de savoir si un objet est un tableau et value instanceof Element permet de savoir si la variable value est un Element.
  3. Ecrire la fonction render et vérifier que l'affichage de la page HTML affiche bien l'arbre DOM généré.
  4. Ajouter la gestion des évènements de telle sorte à ce que l'exemple où l'on incrémente et décremente une valeur ci-dessus fonctionne.
    Note: value instanceof Function permet de savoir si la variable value est une fonction.
  5. Optionnellement, le problème du design actuel est qu'il n'y a pas de notion de composant avec leur état propre, on a une seule valeur d'état (state) pour toute l'application, donc les différentes parties de l'application (composants) ne se composent pas bien. Par exemple, si on veut deux compteurs, il va falloir avoir deux champs counter1 et counter2 en tant que state et dupliquer le code de visualisation des deux compteurs.
    render({
      state: { counter1: 0, counter2: 0 },
      view: state =>
        v("div"), {} [
          v("div", {}, [
            v("h1", { class: "red" }, state),
            v("button", { onclick: state => { state..., counter1: state.counter1 - 1 } }, "subtract"),
            v("button", { onclick: state => { state..., counter1: state.counter1 + 1 } }, "add")
          ]),
          v("div", {}, [
            v("h1", { class: "red" }, state),
            v("button", { onclick: state => { state..., counter2: state.counter2 - 1 } }, "subtract"),
            v("button", { onclick: state => { state..., counter2: state.counter2 + 1 } }, "add")
          ])
        ])
      node: document.getElementById("my_node")
    });
         
    Donc ce n'est pas très beau.
    On se propose de faire une nouvelle implantation avec une notion de composant et d'état d'un composant. Pour cela, on va créer une classe Reagent qui prend le nœud du DOM à modifier à la création et qui possède deux méthodes useState() et view().
    useState(default_value) permet de créer une nouvelle case mémoire avec une valeur par défaut et renvoie un tableau avec une fonction getter et une fonction setter sur la case mémoire.
    view(node, refresh) créée une méthode update de mise à jour qui, appel refresh(), transforme l'élément renvoyé en objet DOM (avec toDOM()) puis installe ce nouvel arbre à la place du node passé en paramètre (ou de l'ancien arbre si ce n'est pas le premier appel à update()).
    Lorsque le setter renvoyé par useState est appelé, en plus de changer la valeur de la case mémoire, on appel la méthode update pour mettre à jour l'interface graphique sur l'objet Reagent courant.
    let reagent = new Reagent();
    let counterComponent = () => {
      let [getCounter, setCounter] = reagent.useState(0);
      return () =>
           v("div", {}, [
             v("h1", { class: "red" }, getCounter()),
             v("button", { onclick: () => setCounter(getCounter() - 1) }, "subtract"),
             v("button", { onclick: () => setCounter(getCounter() + 1) }, "add")
           ]);
    };
    let counter1 = counterComponent();
    let counter2 = counterComponent();
    reagent.view(
        document.getElementById("my_node"),
        () => v("div", {}, [
                counter1(),
                counter2()
              ]));
         
    Implanter la classe Reagent ainsi que ses méthodes useState et view tel que le code ci-dessus fonctionne.
    Note: la classe Element est plus exactement la même, donc il est plus simple de commenter l'ancien code et d'en créer un nouveau.
  6. Optionnellement, au lieu de remplacer tout le DOM à chaque changement, on peut utiliser une technique que l'on appelle le DOM diffing pour ne remplacer que les nœuds du DOM qui ont changé.
    Le diff d'un arbre consiste à parcourir un element et le nœud du DOM correspondant en même temps et modifier le nœud du DOM si le le name ou properties sont différents puis parcourir les fils et faire le diff sur les fils. De plus, si le nombre de fils de l'élément et du nœud du DOM ne sont pas identiques, il va falloir ajouter ou retirer des nœuds du DOM.
    Donc au lieu de la méthode toDOM, créer une nouvelle méthode diffDOM dans Element qui prend un nœud DOM et fait l'algorithme de DOM diffing décrit ci-dessus.