Jan 23, 2020 Javascript

Maîtriser Javascript : web components (partie 1)

Au niveau du développement frontend la mode est aux composants. Les principaux frameworks comme React, Vue ou Angular les plébiscitent. La bonne question à se poser c’est de savoir si on a vraiment actuellement besoin de ces frameworks. Il existe une technologie propre à l’API des navigateurs qui permet de créer des composants : les web components. Cette technologie n’est pas nouvelle, et c’est plutôt un ensemble de technologies associées, mais ce qui est nouveau c’est que la majorité des navigateurs arrivent maintenant à les digérer si on oublie l’indécrottable IE et quelques petits navigateurs.

Un autre aspect intéressant même si on est attaché à un framework spécifique c’est que ces web components sont désormais facilement reconnus et peuvent coexister avec les composants créés avec les frameworks. Il y donc plus plus trop de raison de ne pas adopter cette technologie qui semble dessiner les contours de ce que sera le futur du développement des interfaces.

Globalement on utilise 4 technologies distinctes :

  • Custom Elements : pour créer de nouvelles balises HTML reconnues par le navigateur
  • HTML templates : squelette de HTML
  • Shadow DOM : pour isoler le CSS et le Javascript d’un composant
  • ES modules : pour organiser le code

On n’est pas obligé d’utiliser toutes ces technologies mais elles se combinent bien comme on va le voir dans cet article.

Custom Elements

Nous allons voir en premier concrètement comment on crée un nouveau composant reconnu par le navigateur en prenant un exemple simple. Supposons que nous voulons créer un bouton compteur. On veut pouvoir choisir une valeur de départ et un incrément et à chaque clic on incrémente le compteur qu’on affiche sur le bouton.

Une exemple classique

Voyons dans un premier temps comment on réaliserait ça de façon classique :

<!doctype html>
<html lang="fr">

<head>
  <meta charset="utf-8">
  <title>Les web components</title>
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <link rel="stylesheet" href="https://unpkg.com/papercss/dist/paper.min.css">
  <style>
    button:focus { outline: 0; }
  </style>
</head>

<body>
  <br>
  <div class="paper container">
    <button data-value="10" data-increment="10"></button>
  </div>
  <script>
    const button = document.querySelector('button');
    const increment = parseInt(button.dataset.increment);
    let value = parseInt(button.dataset.value);    
    const onButtonClick = () => {
      value += increment;
      button.textContent = value;
    };
    button.textContent = value;
    button.addEventListener('click', () => onButtonClick());
  </script>
</body>

</html>

On sait qu’on peut ajouter des attributs personnalisés à une balise HTML en utilisant la syntaxe data-mon_attribut. On peut donc définir les deux attributs du bouton ainsi :

<button data-value="10" data-increment="10"></button>

Ensuite on sélectionne le bouton de façon classique :

const button = document.querySelector('button');

On définit deux variables pour mémoriser l’incrément et la valeur actuelle en lisant les attributs personnalisés :

const increment = parseInt(button.dataset.increment);
let value = parseInt(button.dataset.value);

On définit une méthode pour gérer l’événement click du bouton :

const onButtonClick = () => {
  value += increment;
  button.textContent = value;
};

On incrémente la valeur et on met à jour le texte du bouton.

Ensuite on initialise le texte du bouton :

button.textContent = value;

Pour finir on met en place l’écoute du click du bouton :

button.addEventListener('click', () => onButtonClick());

Version custom element

Voyons maintenant comment faire la même chose avec un custom element. Voici le code complet que je vais détailler :

See the Pen
Web Components 1
by bestmomo (@bestmomo)
on CodePen.

<!doctype html>
<html lang="fr">

<head>
  <meta charset="utf-8">
  <title>Les web components</title>
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <link rel="stylesheet" href="https://unpkg.com/papercss/dist/paper.min.css">
  <style>
    button:focus { outline: 0; }
  </style>
</head>

<body>
  <br>
  <div class="paper container">
    <momo-button-counter value="10" increment="10"></momo-button-counter>
  </div>
  <script>
    class ButtonCounter extends HTMLElement {

      connectedCallback() {
          this._value = parseInt(this.getAttribute('value'));
          this._increment = parseInt(this.getAttribute('increment'));
          this.innerHTML = `<button>${ this._value }</button>`;
          this.addEventListener('click', () => this.onButtonClick());
      }

      onButtonClick() {
        this._value += this._increment;
        this.querySelector('button').textContent = this._value;
      }
    }

    if (!customElements.get('momo-button-counter')) {
      customElements.define('momo-button-counter', ButtonCounter);
    }
  </script>
</body>

</html>

Dans la page on pose notre composant avec ses attributs :

<momo-button-counter value="10" increment="10"></momo-button-counter>

Il y a une règle de nommage : il faut au moins un tiret dans le nom pour différencier ces éléments personnalisés de ceux de base du navigateur. Ensuite on peut définir tous les attributs qu’on veut.

Au niveau du Javascript pour ajouter un nouvel élément en général on crée une classe qui étend HTMLElement :

class ButtonCounter extends HTMLElement {

Ensuite il y a une méthode pratique, connectedCallback, qui se déclenche lorsque l’élément est raccordé au DOM. C’est donc le lieu idéal pour faire des initialisations. On crée deux propriétés qu’on initialise avec les deux attributs :

this._value = parseInt(this.getAttribute('value'));
this._increment = parseInt(this.getAttribute('increment'));

On définit le contenu HTML de notre élément :

this.innerHTML = `<button>${ this._value }</button>`;

Et on met en place l’écoute du click sur le bouton :

this.addEventListener('click', () => this.onButtonClick());

On a donc une méthode appelée au click :

onButtonClick() {
  this._value += this._increment;
  this.querySelector('button').textContent = this._value;
}

Ici on incrémente la valeur et on actualise le texte du bouton.

Il ne reste plus qu’à expliquer au navigateur qu’on lui ajoute notre élément :

if (!customElements.get('momo-button-counter')) {
  customElements.define('momo-button-counter', ButtonCounter);
}

La précaution de tester que le nom n’est pas déjà utilisé est peut-être un peu exagérée mais bon…

On obtient ainsi exactement le même fonctionnement du bouton mais maintenant notre code est mieux organisé et isolé.

Attributs et propriétés

Au niveau du HTML une balise peut avoir des propriétés pour lesquelles on déclare une valeur. Au niveau de Javascript on peut modifier aussi ces valeurs mais alors on a affaire à des propriétés d’objets du DOM. En général les deux sont synchronisés, ce qui est logique. Quand on crée un web component et qu’on expose ses attributs il vaut mieux aussi rendre disponibles des propriétés et s’arranger pour que les deux soient synchronisés. Parce qu’on ne sait pas comment le composant peut être utilisés. On appelle aussi cela la réflexion.

On va améliorer le bouton compteur en pilotant ses deux attributs à partir de zones de saisie :

See the Pen
Web components 2
by bestmomo (@bestmomo)
on CodePen.

<!doctype html>
<html lang="fr">

<head>
  <meta charset="utf-8">
  <title>Les web components</title>
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <link rel="stylesheet" href="https://unpkg.com/papercss/dist/paper.min.css">
  <style>
    button:focus { outline: 0; }
  </style>
</head>

<body>
  <br>
  <div class="paper container">
    <div class="row">
      <div class="form-group sm-4 col">
        <label for="value">Valeur de départ :</label>
        <input type="text" id="value" value="10">
      </div>
      <div class="form-group sm-4 col">
        <label for="increment">Incrément :</label>
        <input type="text" id="increment" value="10">
      </div>
      <momo-button-counter 
        class="sm-4 col" 
        value="10" 
        increment="10"
      ></momo-button-counter>
    </div>
  </div>
  <script>
    class ButtonCounter extends HTMLElement {

      connectedCallback() {
        this.innerHTML = `<button class="btn-large">${ this.value }</button>`;
        this.addEventListener('click', () => this.onButtonClick());
      }

      static get observedAttributes() {
        return ['value'];
      }

      get value() {
        return parseInt(this.getAttribute('value'));
      }

      set value(val) {
        this.setAttribute('value', val);
      }

      get increment() {
        return parseInt(this.getAttribute('increment'));
      }

      set increment(val) {
        this.setAttribute('increment', val);
      }

      attributeChangedCallback(name, oldval, newval) {
        if(this.innerHTML !== '') {
          this.updateButton();
        }
      }

      onButtonClick() {
        this.value += this.increment;
        this.updateButton();
      }

      updateButton() {
        this.querySelector('button').textContent = this.value;
      }
    }

    if (!customElements.get('momo-button-counter')) {
      customElements.define('momo-button-counter', ButtonCounter);
    }

    document.querySelector('#value').addEventListener('keydown', (e) => {
      if(e.key == 'Enter') {
        document.querySelector('momo-button-counter').value = e.target.value;
      }
    });
  
    document.querySelector('#increment').addEventListener('change', (e) => {
        document.querySelector('momo-button-counter').increment = e.target.value;
    });

  </script>
</body>

</html>

La déclaration dans le HTML est identique (à part une classe pour le positionnement) :

<momo-button-counter 
  class="sm-4 col" 
  value="10" 
  increment="10"
></momo-button-counter>

On a donc nos deux attributs accessible et modifiables avec SetAttribute. Mais j’ai dit ci-dessus qu’on devait aussi définir des propriétés synchronisées. On les déclare dans la calsse du composant avec setter et getter :

get value() {
  return parseInt(this.getAttribute('value'));
}

set value(val) {
  this.setAttribute('value', val);
}

get increment() {
  return parseInt(this.getAttribute('increment'));
}

set increment(val) {
  this.setAttribute('increment', val);
}

On peut alors mettre en place les deux écoutes pour les zones de saisie en utilisant ces deux propriétés :

document.querySelector('#value').addEventListener('keydown', (e) => {
  if(e.key == 'Enter') {
    document.querySelector('momo-button-counter').value = e.target.value;
  }
});

document.querySelector('#increment').addEventListener('change', (e) => {
    document.querySelector('momo-button-counter').increment = e.target.value;
});

Je choisis de détecter la touche Entrée pour la valeur de départ et pas le changement parce qu’on doit pouvoir relancer avec la même valeur de départ.

On utilise encore connectedCallback pour initialiser le composant :

connectedCallback() {
  this.innerHTML = `<button class="btn-large">${ this.value }</button>`;
  this.addEventListener('click', () => this.onButtonClick());
}

On crée le bouton et on met en place l’écoute du clic. On peut alors écrire le code pour l’actualisation du compteur :

onButtonClick() {
  this.value += this.increment;
  this.updateButton();
}

updateButton() {
  this.querySelector('button').textContent = this.value;
}

Reste à traité la modification des attributs par les zones de saisie. Pour l’incrément il n’y a rien de particulier à faire parce qu’il n’y a pas d’actualisation à prévoir.

Pour la valeur de départ on doit faire quelque chose parce qu’il faut alors changer la valeur du compteur. le composant n’écoute pas par défaut le changement des attributs pour des raison sde performances. Il faut donc lui indiquer de façon explicite les attributs qu’on doit surveiller avec observedAttributes :

static get observedAttributes() {
  return ['value'];
}

Du coup on peut utiliser la méthode attributeChangedCallback :

attributeChangedCallback(name, oldval, newval) {
  if(this.innerHTML !== '') {
    this.updateButton();
  }
}

Dans les paramètres on a :

  • le nom de l’attribut, pratique si on en écoute plusieurs
  • l’ancienne valeur
  • la nouvelle valeur

Ici on veut juste actualiser la valeur du compteur sur le bouton. On peut se demander pourquoi on teste si le bouton est déjà créé. Pour le comprendre il faut savoir que cet événement est déclenché avant connectedCallback, au départ les attributs passent de rien à la valeur déclarée, du coup au premier appel le bouton n’existe pas encore dans le DOM.

Plusieurs composants

Dans une application on va avoir évidemment plusieurs composants à faire cohabiter ou à imbriquer. Il faut alors un peu organiser le code. Comme illustration je vous propose de créer une liste de fiches avec titre et contenu. J’ai créé un plunk pour rassembler le code et avoir une démo en ligne.

Au départ on a juste un formulaire :

On utilise ce formulaire pour créer des fiches :

On a un bouton pour supprimer les fiches de façon individuelle.

On a deux composants :

  • un composant pour une fiche
  • un composant pour la liste des fiches

Évidemment ces deux composants doivent communiquer.

Au niveau structure on a cette organisation :

On utilise le système de modules d’ES6.

Voici le fichier index.html :

<!doctype html>
<html lang="fr">

<head>
  <meta charset="utf-8">
  <title>Les web components</title>
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <link rel="stylesheet" href="https://unpkg.com/papercss/dist/paper.min.css">
  <script type="module" src="./components/cardsList.js"></script>
</head>

<body>
  <br>
  <div class="paper container">
    <form id="cardForm"> 
      <div class="form-group">
        <label for="title">Titre</label>
        <input id="title" name="title" type="text">
      </div>
      <div class="form-group">
        <label for="content">Contenu</label>
        <textarea id="content" name="content" class="no-resize"></textarea>
      </div>
      <div class="form-group">
        <button type="submit">Ajouter</button>
      </div>
    </form>
    <cards-list></cards-list>
  </div>
  <script>
    document.querySelector('form').addEventListener('submit', e => {
      e.preventDefault();
      const formData = new FormData(document.forms.cardForm);
      document.querySelector('cards-list').add({
        title: formData.get('title'), 
        content: formData.get('content')
      });
    });
  </script>
</body>

</html>

On charge le module de base, le composant CardList :

<script type="module" src="./components/cardsList.js"></script>

On insère ce composant :

<cards-list></cards-list>

A la soumission du formulaire :

document.querySelector('form').addEventListener('submit', e => {
  e.preventDefault();
  const formData = new FormData(document.forms.cardForm);
  document.querySelector('cards-list').add({
    title: formData.get('title'), 
    content: formData.get('content')
  });
});

On utilise la méthode add du composant en lui transmettant les données.

On voit ici qu’on n’utilise pas d’attributs. Ces derniers sont parfaits pour des données simples mais doivent être évités dès qu’on doit transmettre des tableaux ou des objets. Il faut alors privilégier des propriétés ou alors comme ici une méthode spécifique.

Le composant CardsList

Le composant CardsList est le composant principal qui englobe les fiches. En voici le code complet que je vais détailler :

import CardsCard from './cardsCard.js';

export default class CardsList extends HTMLElement {

  constructor() {
    super();
    this.cards = [];
    this.addEventListener('delete', this.handleDelete);
  }
  
  add(card) {
    this.cards.push(card);
    this.render();
  }

  render() {
    let i = 0;
    this.innerHTML = `
    <div class="paper container">
      <style>
        .card { margin-bottom: 1em; }
        .card button { float: right; }
      </style>
      ${ this.cards.map(card => `
        <cards-card 
          index=${ i++ } 
          title="${ card.title }" 
          content="${ card.content }"
        ></cards-card>
      `).join('') }
    </div>`;
  }

  handleDelete(e) {
    this.cards.splice(e.detail.index, 1);
    this.render();
  }
}  

if (!customElements.get('cards-list')) {
  customElements.define('cards-list', CardsList);
}

La première chose est de charger le composant enfant :

import CardsCard from './cardsCard.js';

Ensuite on crée la classe comme on l’a vu précédemment :

export default class CardsList extends HTMLElement {

Cette fois j’utilise le constructeur pour les initialisations :

constructor() {
  super();
  this.cards = [];
  this.addEventListener('delete', this.handleDelete);
}

Il faut obligatoirement utiliser super pour respecter le prototypage. On intialise la propriété cards qui va contenir les fiches dans un tableau. Enfin on met en place une écoute de l’événement delete qui sera générée par les fiche en cas de suppression avec le bouton.

On a ensuite la méthode add pour ajouter une fiche :

add(card) {
  this.cards.push(card);
  this.render();
}

On ajoute la fiche dans cards et on appelle la méthode qui génère l’affichage :

render() {
  let i = 0;
  this.innerHTML = `
  <div class="paper container">
    <style>
      .card { margin-bottom: 1em; }
      .card button { float: right; }
    </style>
    ${ this.cards.map(card => `
      <cards-card 
        index=${ i++ } 
        title="${ card.title }" 
        content="${ card.content }"
      ></cards-card>
    `).join('') }
  </div>`;
}

Ici on parcourt le tableau des fiches et pour chacune on génère un composant CardsCard en transmettant 3 valeurs d’attributs :

  • index : pour pouvoir ensuite identifier une fiche qui demande sa suppression
  • title : le titre de la fiche
  • content : le contenu de la fiche

J’ai aussi intégré un peu de style pour les fiches.

Quand une fiche envoie l’événement de suppression on appelle la méthode handleDelete :

handleDelete(e) {
  this.cards.splice(e.detail.index, 1);
  this.render();
}

Là on supprime la fiche du tableau et on raffraichit l’affichage.

Le composant CardsCard

Le composant CardsCard a pour mission de créer et afficher une fiche, ainsi que de permettre sa suppression. En voici le code :

export default class CardsCard extends HTMLElement {
  
  connectedCallback() {
    this.innerHTML = `
      <div class="card">
        <div class="card-body">
          <h4 class="card-title">${ this.title }</h4>
          <p class="card-text">${ this.content }</p>
          <button class="btn-danger">Supprimer</button>
        </div>
      </div>`;
    this.querySelector('button').addEventListener('click', this.handleClick);
  }

  get index() {
    return this.getAttribute('index');
  }

  set index(val) {
    this.setAttribute('index', val);
  }

  get title() {
    return this.getAttribute('title');
  }

  set title(val) {
    this.setAttribute('title', val);
  }

  get content() {
    return this.getAttribute('content');
  }

  set content(val) {
    this.setAttribute('content', val);
  }

  handleClick = () => {
    this.dispatchEvent(new CustomEvent('delete', { 
      bubbles: true,
      detail: { index: this.index }
    }));
  }
}

if (!customElements.get('cards-card')) {
  customElements.define('cards-card', CardsCard);
}

On utilise connectedCallback pour l’initialisation :

connectedCallback() {
  this.innerHTML = `
    <div class="card">
      <div class="card-body">
        <h4 class="card-title">${ this.title }</h4>
        <p class="card-text">${ this.content }</p>
        <button class="btn-danger">Supprimer</button>
      </div>
    </div>`;
  this.querySelector('button').addEventListener('click', this.handleClick);
}

On crée ici le HTML et on ajoute une écoute du bouton.

Ensuite on a la création des getters et setters qui sont classiques.

Pour la génération de l’événement de suppression on a la méthode handleClick :

handleClick = () => {
  this.dispatchEvent(new CustomEvent('delete', { 
    bubbles: true,
    detail: { index: this.index }
  }));
}

On crée ici un événement personnalisé. On met la propriété bubble à true pour que l’événement se propage jusqu’au parent et dans detail on transmet l’index.

Les templates

Dans tous les exemples précédents il n’y a pas de véritable template, le code HTML est généré directement dans le script. Tant qu’il n’y a pas trop de code ça passe mais ça peut rapidement devenir assez confus.

Il y a eu une tentative de Google pour imposer l’import de HTML, mais elle n’a pas eu le succès excpmpté et a disparu des spécifications de la version 1. L’idée reviendra peut-être un jour mais pour le moment il vaut mieux l’oublier.

Par contre ce qui existe et qui a été largement adopté c’est la balise <template>. Prenons un exemple :

<template>
  <p>Mon paragraphe</p>
</template>

Si on met ça dans une page HTML le code correspondant ne sera pas affiché, mais il est bien présent :

On voit que c’est un document-fragment. Mais c’est quoi un fragment ? On peut dire que c’est du code en attente d’être inséré dans la page. Il suffit pour cela d’un peu de Javascript :

const template = document.querySelector('#paragraphe');
const clone = document.importNode(template.content, true);
document.body.appendChild(clone);

On crée un clone du contenu du template et on l’ajoute à la page. pourquoi un clone ? Parce que sinon le template disparaît et ne sert qu’une fois.

Maintenant une question se pose concernant les web components, si on veut utiliser un template où le placer ? Le mettre dans la page HTML serait contraire à l’isolation du code du composant, et on ne dispose pas d’import de HTML. On peut imaginer un chargement dynamique mais il n’y a rien de bien convaincant dans ce genre.

Dans l’optique des web components le template ne peut s’intégrer qu’en ajoutant une autre technologie : le shadow DOM, que je présenterai dans le prochain article.

Conclusion

Dans cet article j’ai présenté deux technologies distinctes qui font partie des bases des web components : custom elements et template. Dans le prochain article j’expliquerait ce qu’est le shadow DOM et en quoi il est un atout important pour le développement des web components.

 

Laisser un commentaire