[Tutoriel] Créer son premier Web Component

Les web components sont un sujet à la mode depuis quelques temps. Les développeurs front imaginent les pages HTML du futur plus légères et lisibles grâce à la magie combinée des gabarits, des éléments personnalisés, des imports et du DOM de l’ombre. Si les spécifications sont encore rarement supportées par les navigateurs, il existe de nombreux polyfills qui permettent à des pionniers d’utiliser les web components dès aujourd’hui en production – tant que la compatibilité avec IE9 ou moins n’est pas un prérequis. Polymer (Google) et X-Tag (Mozilla) sont les plus célèbres d’entre eux.

Ce petit tuto est un voyage dans le futur. Actuellement, il ne fonctionne que sous les versions les plus récentes de Chrome, mais il vous donne un aperçu de ce que pourra être le développement avec les web components dans quelques années, sans les polyfills (pour l’utilisation desquels il existe déjà des tutoriels en Français).

Créer son premier composant

La première chose indispensable pour créer une page avec des composants, c’est les custom elements. Il est en effet possible de créer sa propre balise HTML avec un peu de JavaScript.

var monProto = Object.create(HTMLElement.prototype, {
  createdCallback: { 
    value: function() {
      var monTexte = document.createTextNode("Hello world!"); 
      this.appendChild(monTexte);
    }
  }
});
document.registerElement('hello-world', {
  prototype: monProto
});

Maintenant, nous pouvons utiliser notre balise dans notre page.

<hello-world></hello-world>

Notre navigateur affichera alors “Hello world!” à l’emplacement de cette balise.

Attention au nommage de notre balise. Pour qu’elle soit correctement interprétée par le navigateur, il ne faut pas qu’il existe une autre balise de même nom définie ailleurs. Concernant les custom elements, c’est de notre responsabilité, nous savons ce que nous mettons sur notre page. Par contre, si nous décidons de créer un élément pour faire défiler les photos et que nous le nommons slider, rien ne nous prouve que les navigateurs ne supporteront jamais une balise slider. C’est pourquoi il existe une règle impérative dans le nommage de notre balise personnelle : il faut qu’elle comporte un tiret. Et là, nous sommes certains que jamais aucune balise officielle ne portera le même nom que notre élément personnalisé.

Nous avons utilisé, pour créer notre balise la fonction createdCallback, qui comme son nom l’indique, est appelée à la création. Il est possible d’utiliser également attachedCallback et detachedCallback pour gérer l’insertion et le détachement du DOM, ainsi que attributeChangedCallback(attrName, oldValue, newValue), appelée quand un attribut est modifié.

Si on veut quelque chose d’un peu plus classe qu’un Hello world!, on n’aura pas forcément envie d’écrire tout le HTML en JavaScript. C’est là qu’intervient la balise template. Celle-ci permet d’encapsuler du code HTML, des styles CSS et des scripts qui ne sont pas pris en compte par le navigateur.

<template id="generateur">
    <style scoped>
        div {
            color: blue;
        }
    </style>
    <button id="bouton">Générer le scénario sans shadow dom</button>
    <div id="resultat"> </div>
</template>

Si on se contente d’insérer cela dans notre navigateur, il ne se passe rien. Comment faire alors pour afficher notre bouton ? On va tout simplement modifier le code de création du custom element pour charger notre template.

var proto = Object.create(HTMLElement.prototype, {
      createdCallback: { 
        value: function() {
          var template = document.querySelector('#generateur'); 
          var clone = document.importNode(template.content, true);
          this.appendChild(clone);
        }
      }
});

Pourquoi ce scoped au niveau de la balise style ? Cet attribut permet de limiter les effets du style à l’élément du DOM dans lequel il est déclaré (enfants compris). Il ne sert actuellement à rien, car il n’est pris en compte que par Firefox, et nous ne pouvons tester les web components que sous Chrome… Mais comme je l’ai dit plus haut, nous regardons vers le futur !

retour-futur-2015

Pour l’allègement de la page, nous n’y sommes pas encore complètement toutefois, puisque notre HTML, même encapsulé dans un template, est toujours présent ! C’est là qu’intervient l’import : il permet d’aller chercher un fichier HTML et de l’utiliser. Voici comment l’écrire.

<link rel="import" href="generateur-sans-shadow.html">

Le côté très sympathique des imports, c’est qu’ils peuvent s’imbriquer. Chaque fichier importé peut lui-même importer un ou plusieurs fichiers. Le navigateur gère les dépendances de telle manière que chaque fichier ne soit importé qu’une seule fois, même s’il est appelé par plusieurs fichiers importés, ce qui évitera de rencontrer des problèmes de performance.

Grâce à l’import, nous pouvons migrer notre template et le code JavaScript associé sur une nouvelle page HTML (dans laquelle on insère directement le code dont nous avons besoin, sans head ni body), et notre page principale n’aura plus qu’à importer cette page et insérer notre balise à tous les endroits où nous voulons l’utiliser.

    <link rel="stylesheet" type="text/css" href="style.css">
    <link rel="import" href="generateur-sans-shadow.html">
    <synopsis-generateur-sans></synopsis-generateur-sans>
    <synopsis-generateur-sans></synopsis-generateur-sans>

Voici maintenant le code complet de notre fichier generateur-sans-shadow.html :

<template id="generateur">
    <style scoped>
        div {
            color: blue;
        }
    </style>
    <button id="bouton">Générer le scénario sans shadow dom</button>
    <div id="resultat"> </div>
</template>
<script>
  (function() {
    var myDoc = document.currentScript.ownerDocument;
    var proto = Object.create(HTMLElement.prototype, {
      createdCallback: { 
        value: function() {
          var template = myDoc.querySelector('#generateur'); 
          var clone = document.importNode(template.content, true);
          clone.getElementById('bouton').addEventListener('click', function() {
            document.getElementById('resultat').innerText = tirerSynopsis();
          }, false);
          this.appendChild(clone);
        }
      }
    });
    document.registerElement('synopsis-generateur-sans', {
      prototype: proto
    });
  })();
</script>

Comme nous voulons pouvoir insérer d’autres web components dans notre page, nous avons utilisé quelques astuces pour éviter qu’ils se marchent sur les pieds. Nous avons tout d’abord inséré le JavaScript dans une fonction anonyme, parce qu’en bons codeurs sans imagination, nous avons l’habitude d’appeler nos prototypes proto, monProto ou monPrototype, et que quitte à mettre partout les mêmes noms de variables, il vaut mieux les utiliser de façon locale. Nous avons également utilisé ce code :

var myDoc = document.currentScript.ownerDocument;

Il nous permet de manipuler directement le DOM interne de notre fichier, au lieu de celui de tout le document. Et ça aussi, c’est vital, car quasiment tous les templates que j’ai pu voir dans les exemples avaient le même id : “template”. Là, nous seront sûrs que ce sera le bon qui sera pris en compte !

Ce code n’est cependant pas sans nous poser un petit problème : le HTML de notre template est inséré dans le DOM du document complet. Il devient modifiable par notre document lui-même : il va prendre ses propriétés CSS, par exemple. Et si nous utilisons deux fois le même composant dans la même page, les identifiants uniques ne seront plus uniques ! Dans l’exemple que je vous ai donné, le second bouton modifie le div du premier composant, ce qui n’est pas le comportement désiré. C’est pour cela qu’intervient le DOM de l’ombre.

Le shadow DOM

L’intérêt que nous pouvons trouver à utiliser un composant personnalisé, c’est de pouvoir l’utiliser comme une balise à part entière, sans nous soucier de ce qu’elle contient, et de pouvoir l’utiliser plusieurs fois dans une page sans provoquer d’interférences. C’est là que le shadow DOM surgit hors de l’ombre et d’un geste, il nous  permet de rendre invisible une partie du DOM (avec ses CSS) par rapport au reste du document. La magie du shadow DOM nous permet d’utiliser l’attribut id dans notre web component sans compromettre son unicité.

Voici le code pour créer notre composant sous forme de shadow DOM :

var shadow = this.createShadowRoot();
var template = myDoc.querySelector('#generateur'); 
var clone = document.importNode(template.content, true);
shadow.appendChild(clone);

La différence se voit dans la console du navigateur :

shadow-avant-apres

Est-ce que tout va pour le mieux dans le meilleur des mondes ? Non, tout n’est pas si simple, et vous allez bientôt comprendre pourquoi on ne trouve jamais de JavaScript manipulant le DOM dans les exemples trouvés sur Internet. En effet, dans notre code, notre document.getElementById va aller chercher l’élément dans le document HTML où se situe notre web component. Non seulement on risque d’affecter un autre élément de la page, mais en plus, on ne peut pas toucher à l’élément que nous avons déclaré dans notre template car il est maintenant bien caché dans le shadow DOM. Cela ne fonctionne pas non plus en appelant une fonction JavaScript dans  le template : celle-ci manipulera notre document contenant le composant, et non le shadow DOM. D’après cet article, l’étanchéité du DOM de l’ombre n’est assurée que pour le HTML et le CSS, ce qui peut compliquer toute utilisation du JavaScript.

J’ai réussi à contourner le problème d’une manière qui fonctionne. J’ignore s’il s’agit d’une bonne pratique, n’ayant trouvé actuellement aucun exemple de code sur lequel m’appuyer.

(function() {
    var myDoc = document.currentScript.ownerDocument;
    var proto = Object.create(HTMLElement.prototype, {
      createdCallback: { 
        value: function() {
          var shadow = this.createShadowRoot();
          var template = myDoc.querySelector('#generateur'); 
          var clone = document.importNode(template.content, true);
  
          clone.querySelector('#bouton').addEventListener('click', function() {
            this.parentNode.querySelector('#resultat').innerText = tirerSynopsis();
          }, false);
          shadow.appendChild(clone);
        }
      }
    });
    document.registerElement('synopsis-generateur-simple', {
      prototype: proto
    });
  })();

Cette fois, nous pouvons insérer plusieurs éléments synopsis-generateur-simple dans notre page, chaque tirage affectera l’élément div sous le bouton, et seulement celui-ci. Nous avons utilisé le shadow DOM avec succès ! Nous constatons également que nos éléments ne sont pas affectés par le style du document dans lequel ils sont insérés, et vice-versa.

Rendre son web component personnalisable

Mon composant est encapsulé dans le shadow dom, cette fois il n’y a plus guère de risque d’interférence entre la page qui l’affiche et lui. On a tout bon ?

Mais non ! Le composant est maintenant immuable !

C’est le revers de la médaille de notre encapsulation dans le shadow DOM. On a perdu toute possibilité de le modifier via la CSS ou le JavaScript, même volontairement !

En fait, concernant le style, il existe une grande souplesse pour faire communiquer notre shadow DOM avec le monde qui l’entoure. Si le sujet vous intéresse, tout est détaillé dans cet article.

Nous pouvons également laisser la possibilité à l’utilisateur de notre composant de le modifier en ajoutant du contenu ou des attributs à notre balise personnalisée. C’est le créateur du composant qui définit ce que l’utilisateur va pouvoir en faire.

Utiliser le contenu de la balise est extrêmement simple : on ajoute la balise content dans notre template à l’endroit où nous désirons que ce contenu soit affiché. Ainsi, si je souhaite que l’utilisateur puisse mettre un contenu dans le div présentant le résultat :

<template id="generateur">
    <style scoped>
        div {
            color: green;
        }
    </style>
    <button id="bouton"></button>
    <div id="resultat"><content> </content></div>
</template>

À présent, lors de l’utilisation de mon component, on peut mettre du texte dans les balises, il sera utilisé.

<synopsis-generateur>
      Votre scénario s'affichera <strong>ici</strong>.
</synopsis-generateur>

Notez bien dans l’exemple que l’on peut insérer du code HTML.

Il est possible d’utiliser les attributs, nous pourrons alors les récupérer dans le createdCallback avec un simple this.getAttribute(nom_attribut). L’avantage, c’est que l’on peut définir plusieurs attributs, alors qu’il n’y a qu’une seule place à l’intérieur des balises. Nous pouvons également définir une valeur par défaut si notre attribut n’est pas renseigné.

Voici le code avec l’utilisation d’un attribut permettant de changer le libellé du bouton :

var proto = Object.create(HTMLElement.prototype, {
  libBouton: {                 
      value: "Générer un scénario"
  },
  createdCallback: { 
    value: function() {
      var shadow = this.createShadowRoot();
      var template = myDoc.querySelector('#generateur'); 
      var clone = document.importNode(template.content, true);
  
      var ajout = this.getAttribute("ajouter");
      bouton = clone.querySelector('#bouton');
      bouton.addEventListener('click', function() {
        this.parentNode.querySelector('#resultat').innerText = tirerSynopsis();
      }, false);
          
      var libelle = this.getAttribute("libelle") || this.libBouton;
      bouton.innerText = libelle;

      shadow.appendChild(clone);
    }
  },
  attributeChangedCallback: { 
    value: function(attrName, oldValue, newValue) {
      if (attrName == "libelle")
        bouton.innerText = newValue;
    }
  }
});

Revenons un instant sur la balise content. Celle-ci peut prendre un attribut, select, qui correspond à un sélecteur CSS. À ce moment, ce n’est pas tout le texte à l’intérieur des balises qui sera affiché, mais les éléments repris par le sélecteur.

Par exemple, si nous voulons pouvoir ajouter du texte au début et à la fin de notre élément, nous pouvons écrire :

<template id="generateur">
    <style scoped>
        div {
            color: green;
        }
    </style>
    <content select=".avant"></content>
    <button id="bouton">Générer un scénario</button>
    <div id="resultat"></div>
    <content select=".apres"></content>
</template>

Et en insérant notre élément :

<synopsis-generateur-texte>
      <div class="avant">Voici mon générateur</div>
      Texte qui n'apparaitra nulle part.
      <span class="apres">Test</span>
</synopsis-generateur-texte>

On aura alors en début de composant un div contenant le texte « Voici mon générateur », à la fin un span contenant « Test ». Comme nous n’avons aucun sélecteur pour « Texte qui n’apparaitra nulle part. », celui-ci n’est pas visible, sauf si mon web component n’est pas supporté par le navigateur.

Le contenu ne peut être utilisé qu’une seule fois. Ainsi, si nous voulons utiliser « Texte qui n’apparaitra nulle part. », nous pouvons le faire simplement en ajoutant une balise content sans sélecteur à la fin, et seul ce texte apparait. Cependant, si nous plaçons ce content vide avant la fin, ce sera tout le contenu restant qui sera affiché, et il ne restera plus rien pour notre <content select=".apres"></content>, notre <span class="apres">Test</span> ayant déjà été utilisé. Pour mieux comprendre et visualiser ce qui est affiché, je vous invite à jouer avec.

Le choix entre l’utilisation du contenu ou des attributs peut être basé sur cette question : voulons-nous que ce paramètre soit affiché si notre custom element n’est pas supporté par le navigateur ? En effet, dans ce cas, il affichera tout ce qui est situé entre les balises, et rien de ce qui est dans les attributs.

Conclusion

Nous venons de créer notre premier web component sans polyfill. Les spécifications ne sont pas encore terminées, et il est possible qu’elles changent encore. Il est impossible de savoir actuellement quand les templates, les custom elements et le shadow DOM seront assez répandus sur les navigateurs pour des cas réels d’utilisation, mais nous pouvons en attendant nous intéresser aux polyfills. Ceux-ci sont accusés d’être lourds, mais il est possible avec webcomponents.js de ne prendre que la partie dont nous avons besoin.

Vous pouvez voir le code complet des exemples sur Plunker – mais il ne marchera que sous Chrome, vous êtes prévenus ! Notre composant permet de tirer aléatoirement un scénario pour le jeu de rôle Brain Soda, les textes du générateur sont reproduits ici avec l’aimable autorisation de Willy Favre.

Crédit photo

http://pixabay.com/fr/lego-blocs-de-construction-189762/

Film Retour vers le Futur 2

http://memegenerator.net