Fermer

février 22, 2024

JavaScript Vanilla, les bibliothèques et la quête du rendu DOM avec état —

JavaScript Vanilla, les bibliothèques et la quête du rendu DOM avec état —


Il est bien établi que le Web est confronté à de nombreux problèmes d’utilisation et de performances, allant de modèles d’interface utilisateur hostiles aux utilisateurs et des résultats de recherche tordus entraînant des performances lentes et une surcharge de la batterie. Bien que les forces sous-jacentes puissent échapper au contrôle des développeurs, des choix technologiques douteux jouent généralement un rôle important, souvent dans le domaine du JavaScript côté client. Dans le but d’approfondir notre compréhension collective de ce bourbier auto-infligé, examinons un aspect petit mais significatif sur lequel les développeurs prennent les rênes : peindre des pixels sur l’écran.

Dans son article fondateur «Le marché des citrons», le célèbre webman Alex Russell expose les innombrables échecs de notre industrie, en se concentrant sur les conséquences désastreuses pour les utilisateurs finaux. Cette indignation est tout à fait appropriée selon le statuts de notre média.

Les frameworks jouent un rôle important dans cette équation, mais il peut aussi y avoir de bonnes raisons pour que les développeurs front-end choisissent un framework, ou bibliothèque d’ailleurs : la mise à jour dynamique des interfaces Web peut être délicate de manière non évidente. Enquêteons en commençant par le début et en revenant aux premiers principes.

Catégories de balisage

Tout sur le Web commence par le balisage, c’est-à-dire le HTML. Les structures de balisage peuvent être grossièrement divisées en trois catégories :

  1. Des pièces statiques qui restent toujours les mêmes.
  2. Parties variables définies une fois lors de l’instanciation.
  3. Parties variables mises à jour dynamiquement au moment de l’exécution.

Par exemple, l’en-tête d’un article pourrait ressembler à ceci :

<header>
  <h1>«Hello World»</h1>
  <small>«123» backlinks</small>
</header>

Les parties variables sont ici entourées de « guillemets » : « Hello World » est le titre respectif, qui ne change qu’entre les articles. Le compteur de backlinks, cependant, peut être continuellement mis à jour via des scripts côté client ; nous sommes prêts à devenir viraux dans la blogosphère. Tout le reste reste identique dans tous nos articles.

L’article que vous lisez maintenant se concentre ensuite sur la troisième catégorie : le contenu qui doit être mis à jour au moment de l’exécution.

Navigateur de couleurs

Imaginez que nous construisons un simple navigateur de couleurs : un petit widget pour explorer un ensemble prédéfini de couleurs nommées, présenté sous forme de liste qui associe un échantillon de couleur à la valeur de couleur correspondante. Les utilisateurs doivent pouvoir rechercher des noms de couleurs et basculer entre les codes de couleur hexadécimaux et les triplets rouge, bleu et vert (RVB). Nous pouvons créer un squelette inerte avec juste un peu de HTML et CSS :

Voir le stylo [Color Browser (inert) [forked]](https://codepen.io/smashingmag/pen/RwdmbGd) par FND.

Voir le stylo Navigateur de couleurs (inerte) [forked] par FND.

Rendu côté client

Nous avons décidé à contrecœur d’utiliser le rendu côté client pour la version interactive. Pour nos besoins ici, peu importe que ce widget constitue une application complète ou simplement un île autonome intégré dans un document HTML autrement statique ou généré par le serveur.

Compte tenu de notre prédilection pour le JavaScript vanilla (cf. premiers principes et tout), nous commençons par les API DOM intégrées au navigateur :

function renderPalette(colors) {
  let items = [];
  for(let color of colors) {
    let item = document.createElement("li");
    items.push(item);

    let value = color.hex;
    makeElement("input", {
      parent: item,
      type: "color",
      value
    });
    makeElement("span", {
      parent: item,
      text: color.name
    });
    makeElement("code", {
      parent: item,
      text: value
    });
  }

  let list = document.createElement("ul");
  list.append(...items);
  return list;
}

Note:
Ce qui précède s’appuie sur une petite fonction utilitaire pour une création d’éléments plus concise :

function makeElement(tag, { parent, children, text, ...attribs }) {
  let el = document.createElement(tag);

  if(text) {
    el.textContent = text;
  }

  for(let [name, value] of Object.entries(attribs)) {
    el.setAttribute(name, value);
  }

  if(children) {
    el.append(...children);
  }

  parent?.appendChild(el);
  return el;
}

Vous avez peut-être également remarqué une incohérence stylistique : au sein du items boucle, les éléments nouvellement créés s’attachent à leur conteneur. Plus tard, nous inverseons les responsabilités, car list le conteneur ingère les éléments enfants à la place.

Voilà : renderPalette génère notre liste de couleurs. Ajoutons un formulaire pour l’interactivité :

function renderControls() {
  return makeElement("form", {
    method: "dialog",
    children: [
      createField("search", "Search"),
      createField("checkbox", "RGB")
    ]
  });
}

Le createField la fonction utilitaire encapsule les structures DOM requises pour les champs de saisie ; c’est un petit composant de balisage réutilisable :

function createField(type, caption) {
  let children = [
    makeElement("span", { text: caption }),
    makeElement("input", { type })
  ];
  return makeElement("label", {
    children: type === "checkbox" ? children.reverse() : children
  });
}

Il ne nous reste plus qu’à combiner ces éléments. Enveloppons-les dans un élément personnalisé :

import { COLORS } from "./colors.js"; // an array of `{ name, hex, rgb }` objects

customElements.define("color-browser", class ColorBrowser extends HTMLElement {
  colors = [...COLORS]; // local copy

  connectedCallback() {
    this.append(
      renderControls(),
      renderPalette(this.colors)
    );
  }
});

Désormais, un <color-browser> élément n’importe où dans notre HTML générera toute l’interface utilisateur ici. (J’aime y penser comme un macro s’étendant sur place.) Cette implémentation est quelque peu déclarative1les structures DOM étant créées en composant une variété de générateurs de balisage simples, des composants clairement délimités, si vous préférez.

1 L’explication la plus utile que j’ai rencontrée des différences entre la programmation déclarative et impérative se concentre sur les lecteurs. Malheureusement, cette source particulière m’échappe, alors je paraphrase ici : le code déclaratif décrit le quoi tandis que le code impératif décrit le comment. Une conséquence est que le code impératif nécessite un effort cognitif pour parcourir séquentiellement les instructions du code et construire un modèle mental du résultat respectif.

Interactivité

À ce stade, nous recréons simplement notre squelette inerte ; il n’y a pas encore d’interactivité réelle. Les gestionnaires d’événements à la rescousse :

class ColorBrowser extends HTMLElement {
  colors = [...COLORS];
  query = null;
  rgb = false;

  connectedCallback() {
    this.append(renderControls(), renderPalette(this.colors));
    this.addEventListener("input", this);
    this.addEventListener("change", this);
  }

  handleEvent(ev) {
    let el = ev.target;
    switch(ev.type) {
    case "change":
      if(el.type === "checkbox") {
        this.rgb = el.checked;
      }
      break;
    case "input":
      if(el.type === "search") {
        this.query = el.value.toLowerCase();
      }
      break;
    }
  }
}

Note:
handleEvent signifie que nous n’avons pas à le faire vous inquiétez de la liaison de fonction. Il est également livré avec divers avantages. D’autres modèles sont disponibles.

Chaque fois qu’un champ change, nous mettons à jour la variable d’instance correspondante (parfois appelée liaison de données unidirectionnelle). Hélas, changer cet état interne2 n’est reflété nulle part dans l’interface utilisateur jusqu’à présent.

2 Dans la console développeur de votre navigateur, cochez document.querySelector("color-browser").query après avoir saisi un terme de recherche.

Notez que ce gestionnaire d’événements est étroitement couplé à renderControls internes car il attend respectivement une case à cocher et un champ de recherche. Ainsi, tout changement correspondant à renderControls – peut-être en passant aux boutons radio pour les représentations des couleurs – il faut maintenant prendre en compte cet autre morceau de code : agir à distance! Élargir le contrat de ce composant pour inclure
noms de champs pourrait atténuer ces inquiétudes.

Nous sommes désormais confrontés à un choix entre :

  1. Accéder à notre DOM précédemment créé pour le modifier, ou
  2. Le recréer tout en incorporant un nouvel état.

Le rendu

Puisque nous avons déjà défini notre composition de balisage en un seul endroit, commençons par la deuxième option. Nous allons simplement réexécuter nos générateurs de balisage, en leur fournissant l’état actuel.

class ColorBrowser extends HTMLElement {
  // [previous details omitted]

  connectedCallback() {
    this.#render();
    this.addEventListener("input", this);
    this.addEventListener("change", this);
  }

  handleEvent(ev) {
    // [previous details omitted]
    this.#render();
  }

  #render() {
    this.replaceChildren();
    this.append(renderControls(), renderPalette(this.colors));
  }
}

Nous avons déplacé toute la logique de rendu dans une méthode dédiée3que nous invoquons non seulement une fois au démarrage, mais à chaque fois que l’état change.

3 Tu pourrais vouloir éviter les propriétés privéessurtout si d’autres pourraient s’appuyer sur votre implémentation.

Ensuite, nous pouvons tourner colors dans un getter pour renvoyer uniquement les entrées correspondant à l’état correspondant, c’est-à-dire la requête de recherche de l’utilisateur :

class ColorBrowser extends HTMLElement {
  query = null;
  rgb = false;

  // [previous details omitted]

  get colors() {
    let { query } = this;
    if(!query) {
      return [...COLORS];
    }

    return COLORS.filter(color => color.name.toLowerCase().includes(query));
  }
}

Note:
J’ai un faible pour le modèle de videur.
Le basculement des représentations de couleurs est laissé comme exercice au lecteur. Tu pourrais passer this.rgb dans renderPalette puis remplissez <code> soit color.hex ou color.rgben utilisant peut-être cet utilitaire :

function formatRGB(value) {
  return value.split(",").
    map(num => num.toString().padStart(3, " ")).
    join(", ");
}

Cela produit maintenant un comportement intéressant (vraiment ennuyeux):

Voir le stylo [Color Browser (defective) [forked]](https://codepen.io/smashingmag/pen/YzgbKab) par FND.

Voir le stylo Navigateur de couleurs (défectueux) [forked] par FND.

Saisir une requête semble impossible car le champ de saisie perd le focus après une modification, laissant le champ de saisie vide. Cependant, la saisie d’un caractère peu courant (par exemple « v ») indique clairement que quelque chose se passe : la liste des couleurs change effectivement.

La raison en est que notre approche actuelle du bricolage est assez rudimentaire : #render efface et recrée le DOM en gros à chaque changement. La suppression des nœuds DOM existants réinitialise également l’état correspondant, y compris la valeur, le focus et la position de défilement des champs de formulaire. Ce n’est pas bon!

Rendu incrémental

La section précédente interface utilisateur basée sur les données Cela semblait être une bonne idée : les structures de balisage sont définies une seule fois et restituées à volonté, sur la base d’un modèle de données représentant clairement l’état actuel. Pourtant l’état explicite de notre composant est clairement insuffisant ; nous devons le réconcilier avec l’état implicite du navigateur lors du nouveau rendu.

Bien sûr, nous pourrions essayer de faire cela implicite État explicite et l’incorporer dans notre modèle de données, comme inclure le champ value ou checked propriétés. Mais cela laisse encore beaucoup de choses sans prise en compte, notamment la gestion de la mise au point, la position de défilement et une myriade de détails nous n’y avons probablement même pas pensé (souvent, cela signifie des fonctionnalités d’accessibilité). D’ici peu, nous recréons effectivement le navigateur !

Nous pourrions plutôt essayer d’identifier quelles parties de l’interface utilisateur doivent être mises à jour et laisser le reste du DOM intact. Malheureusement, c’est loin d’être anodin, et c’est là que des bibliothèques comme React sont entrées en jeu il y a plus de dix ans : en apparence, elles fournissaient un moyen plus déclaratif de définir les structures DOM.4 (tout en encourageant également la composition par composants, en établissant une source unique de vérité pour chaque modèle d’interface utilisateur individuel). Sous le capot, ces bibliothèques ont introduit des mécanismes5 pour fournir des mises à jour DOM granulaires et incrémentielles au lieu de recréer les arborescences DOM à partir de zéro — à la fois pour éviter les conflits d’état et pour améliorer les performances6.

4 Dans ce contexte, cela signifie essentiellement écrire quelque chose qui ressemble à du HTML, qui, selon votre système de croyance, est soit essentiel, soit révoltant. L’état des modèles HTML était quelque peu désastreux à l’époque et reste médiocre dans certains environnements.
5 « » de Nolan LawsonApprenons comment fonctionnent les frameworks JavaScript modernes en en créant un»fournit de nombreuses informations précieuses sur ce sujet. Pour encore plus de détails, documentation du développeur de lit-html vaut la peine d’être étudié.
6 Nous avons appris depuis que quelques de ces mécanismes sont en fait extrêmement cher.

L’essentiel : Si nous voulons encapsuler les définitions de balisage puis dériver notre interface utilisateur à partir d’un modèle de données variables, nous devons en quelque sorte nous appuyer sur une bibliothèque tierce pour la réconciliation.

Acte commandé

À l’autre extrémité du spectre, nous pourrions opter pour des modifications chirurgicales. Si nous savons quoi cibler, notre code d’application peut accéder au DOM et modifier uniquement les parties qui nécessitent une mise à jour.

Malheureusement, cette approche conduit généralement à un couplage désastreux et étroit, avec une logique interdépendante répartie dans toute l’application tandis que des routines ciblées violent inévitablement l’encapsulation des composants. Les choses deviennent encore plus compliquées lorsque l’on considère des permutations de plus en plus complexes de l’interface utilisateur (pensez aux cas extrêmes, aux rapports d’erreurs, etc.). Ce sont précisément ces problèmes que les bibliothèques susmentionnées espéraient éradiquer.

Dans le cas de notre navigateur de couleurs, cela signifierait rechercher et masquer les entrées de couleur qui ne correspondent pas à la requête, sans parler du remplacement de la liste par un message de remplacement s’il ne reste aucune entrée correspondante. Nous devrons également échanger les représentations de couleurs en place. Vous pouvez probablement imaginer comment le code résultant finirait par dissoudre toute séparation des préoccupations, perturbant des éléments qui appartenaient à l’origine exclusivement à renderPalette.

class ColorBrowser extends HTMLElement {
  // [previous details omitted]

  handleEvent(ev) {
    // [previous details omitted]

    for(let item of this.#list.children) {
      item.hidden = !item.textContent.toLowerCase().includes(this.query);
    }
    if(this.#list.children.filter(el => !el.hidden).length === 0) {
      // inject substitute message
    }
  }

  #render() {
    // [previous details omitted]

    this.#list = renderPalette(this.colors);
  }
}

Comme un homme autrefois sage a dit un jour : C’est trop de connaissances !

Les choses deviennent encore plus périlleuses avec les champs de formulaire : non seulement nous devrons peut-être mettre à jour l’état spécifique d’un champ, mais nous aurions également besoin de savoir où injecter les messages d’erreur. Tout en atteignant renderPalette C’était déjà assez grave, il faudrait ici percer plusieurs couches : createField est un utilitaire générique utilisé par renderControlsqui à son tour est invoqué par notre niveau supérieur ColorBrowser.

Si les choses deviennent compliquées même dans cet exemple minimal, imaginez avoir une application plus complexe avec encore plus de couches et d’indirections. Rester au courant de toutes ces interconnexions devient pratiquement impossible. De tels systèmes se transforment généralement en une grosse boule de boue dans laquelle personne n’ose changer quoi que ce soit de peur de casser des objets par inadvertance.

Conclusion

Il semble y avoir une omission flagrante dans les API de navigateur standardisées. Notre préférence pour les solutions JavaScript vanille sans dépendances est contrecarrée par la nécessité de mettre à jour de manière non destructive les structures DOM existantes. Cela suppose que nous valorisons une approche déclarative avec encapsulation inviolable, également connue sous le nom de « Génie logiciel moderne : les bonnes pièces ».

Dans l’état actuel des choses, mon opinion personnelle est qu’une petite bibliothèque comme lit-html ou Preact est souvent justifiée, en particulier lorsqu’elle est utilisée avec remplaçabilité à l’esprit : une API standardisée pourrait encore se produire ! De toute façon, bibliothèques adéquates ont une empreinte légère et ne présentent généralement pas beaucoup d’encombrement pour les utilisateurs finaux, en particulier lorsqu’ils sont combinés avec amélioration progressive.

Cependant, je ne veux pas vous laisser en suspens, alors j’ai trompé notre implémentation JavaScript Vanilla pour surtout faire ce que nous attendons de lui :

Voir le stylo [Color Browser [forked]](https://codepen.io/smashingmag/pen/vYPwBro) par FND.

Voir le stylo Navigateur de couleurs [forked] par FND.
Éditorial fracassant
(ouais)




Source link