Utiliser des éléments personnalisés

Introduction

Le Web manque énormément d'expression. Pour bien comprendre, jetez un coup d'œil à une application Web "moderne" telle que Gmail:

Gmail

La soupe <div> n'est pas moderne. Et pourtant, c'est ainsi que nous créons des applications Web. C'est triste. Ne devrions-nous pas demander plus à notre plate-forme ?

Balisage sexy. Entrons dans le vif du sujet

Le langage HTML est un excellent outil pour structurer un document, mais son vocabulaire se limite aux éléments définis par la norme HTML.

Que se passe-t-il si le balisage pour Gmail n'était pas atroce ? Et si c'était beau:

<hangout-module>
    <hangout-chat from="Paul, Addy">
    <hangout-discussion>
        <hangout-message from="Paul" profile="profile.png"
            profile="118075919496626375791" datetime="2013-07-17T12:02">
        <p>Feelin' this Web Components thing.
        <p>Heard of it?
        </hangout-message>
    </hangout-discussion>
    </hangout-chat>
    <hangout-chat>...</hangout-chat>
</hangout-module>

C'est rafraîchissant ! Cette application aussi est tout à fait logique. Elles sont significatives, faciles à comprendre et, surtout, gérables. À l'avenir, vous saurez exactement ce qu'il fait simplement en examinant son backbone déclaratif.

Premiers pas

Les éléments personnalisés permettent aux développeurs Web de définir de nouveaux types d'éléments HTML. La spécification est l'une des nouvelles primitives d'API incluses dans les composants Web, mais elle est sans doute la plus importante. Les composants Web n'existent pas sans les fonctionnalités débloquées par des éléments personnalisés:

  1. Définir de nouveaux éléments HTML/DOM
  2. Créer des éléments qui s'étendent à partir d'autres éléments
  3. Regrouper logiquement des fonctionnalités personnalisées dans une seule balise
  4. Étendre l'API des éléments DOM existants

Enregistrer de nouveaux éléments

Les éléments personnalisés sont créés à l'aide de document.registerElement():

var XFoo = document.registerElement('x-foo');
document.body.appendChild(new XFoo());

Le premier argument de la méthode document.registerElement() correspond au nom de la balise de l'élément. Le nom doit contenir un tiret (-). Ainsi, par exemple, <x-tags>, <my-element> et <my-awesome-app> sont des noms valides, contrairement à <tabs> et <foo_bar>. Cette restriction permet à l'analyseur de distinguer les éléments personnalisés des éléments standards, mais garantit également la compatibilité ascendante lorsque de nouvelles balises sont ajoutées au code HTML.

Le deuxième argument est un objet (facultatif) décrivant l'prototype de l'élément. C'est à cet endroit que vous pouvez ajouter des fonctionnalités personnalisées (par exemple, des propriétés et méthodes publiques) à vos éléments. Nous y reviendrons plus tard.

Par défaut, les éléments personnalisés héritent de HTMLElement. L'exemple précédent équivaut donc à:

var XFoo = document.registerElement('x-foo', {
    prototype: Object.create(HTMLElement.prototype)
});

Un appel à document.registerElement('x-foo') permet d'apprendre au navigateur le nouvel élément et de renvoyer un constructeur que vous pouvez utiliser pour créer des instances de <x-foo>. Vous pouvez également utiliser les autres techniques d'instanciation d'éléments si vous ne souhaitez pas utiliser le constructeur.

Extension d'éléments

Les éléments personnalisés vous permettent d'étendre des éléments HTML existants (natifs), ainsi que d'autres éléments personnalisés. Pour étendre un élément, vous devez transmettre à registerElement() le nom et l'élément prototype de l'élément dont vous souhaitez hériter.

Extension des éléments natifs

Supposons que vous n'êtes pas satisfait de Joe <button>. Vous aimeriez optimiser ses capacités en adoptant un "méga bouton". Pour étendre l'élément <button>, créez un élément qui hérite du prototype de HTMLButtonElement et extends du nom de l'élément. Dans ce cas, "button":

var MegaButton = document.registerElement('mega-button', {
    prototype: Object.create(HTMLButtonElement.prototype),
    extends: 'button'
});

Les éléments personnalisés qui héritent des éléments natifs sont appelés éléments personnalisés de l'extension de type. Ils héritent d'une version spécialisée de HTMLElement pour dire que "l'élément X est un Y".

Exemple :

<button is="mega-button">

Étendre un élément personnalisé

Pour créer un élément <x-foo-extended> qui étend l'élément personnalisé <x-foo>, il vous suffit d'hériter de son prototype et d'indiquer la balise dont vous héritez:

var XFooProto = Object.create(HTMLElement.prototype);
...

var XFooExtended = document.registerElement('x-foo-extended', {
    prototype: XFooProto,
    extends: 'x-foo'
});

Pour en savoir plus sur la création de prototypes d'éléments, consultez la section Ajouter des propriétés et des méthodes JS ci-dessous.

Comment les éléments sont-ils mis à niveau ?

Vous êtes-vous déjà demandé pourquoi l'analyseur HTML ne permettait pas d'utiliser des tags non standards ? Par exemple, nous déclarons <randomtag> sur la page pour obtenir le meilleur résultat. Conformément à la spécification HTML:

Désolé <randomtag> ! Vous n'êtes pas standard et vous héritez de HTMLUnknownElement.

Il en va de même pour les éléments personnalisés. Les éléments associés à des noms d'éléments personnalisés valides héritent de HTMLElement. Pour le vérifier, lancez la console Ctrl + Shift + J (ou Cmd + Opt + J sur Mac) et collez les lignes de code suivantes, qui renvoient true :

// "tabs" is not a valid custom element name
document.createElement('tabs').__proto__ === HTMLUnknownElement.prototype

// "x-tabs" is a valid custom element name
document.createElement('x-tabs').__proto__ == HTMLElement.prototype

Éléments non résolus

Étant donné que les éléments personnalisés sont enregistrés par script à l'aide de document.registerElement(), ils peuvent être déclarés ou créés avant que leur définition ne soit enregistrée par le navigateur. Par exemple, vous pouvez déclarer <x-tabs> sur la page, mais finir par appeler document.registerElement('x-tabs') beaucoup plus tard.

Avant que les éléments ne soient mis à niveau vers leur définition, ils sont appelés éléments non résolus. Il s'agit d'éléments HTML associés à un nom d'élément personnalisé valide, mais qui n'ont pas été enregistrés.

Ce tableau peut vous aider à clarifier les choses:

Nom Hérite de Exemples
Élément non résolu HTMLElement <x-tabs>, <my-element>
Élément inconnu HTMLUnknownElement <tabs>, <foo_bar>

Instanciation d'éléments

Les techniques courantes de création d'éléments s'appliquent toujours aux éléments personnalisés. Comme pour tout élément standard, ils peuvent être déclarés en HTML ou créés dans le DOM à l'aide de JavaScript.

Instancier des tags personnalisés

Déclarez-les:

<x-foo></x-foo>

Créez un DOM dans JS:

var xFoo = document.createElement('x-foo');
xFoo.addEventListener('click', function(e) {
    alert('Thanks!');
});

Utilisez l'opérateur new:

var xFoo = new XFoo();
document.body.appendChild(xFoo);

Instanciation des éléments d'extension de type

L'instanciation d'éléments personnalisés de type extension de type est particulièrement proche des balises personnalisées.

Déclarez-les:

<!-- <button> "is a" mega button -->
<button is="mega-button">

Créez un DOM dans JS:

var megaButton = document.createElement('button', 'mega-button');
// megaButton instanceof MegaButton === true

Comme vous pouvez le constater, il existe désormais une version surchargée de document.createElement() qui utilise l'attribut is="" comme deuxième paramètre.

Utilisez l'opérateur new:

var megaButton = new MegaButton();
document.body.appendChild(megaButton);

Jusqu'à présent, nous avons appris à utiliser document.registerElement() pour signaler une nouvelle balise au navigateur, mais cela ne fait pas grand-chose. Ajoutons des propriétés et des méthodes.

Ajouter des propriétés et des méthodes JS

L'avantage des éléments personnalisés est que vous pouvez regrouper des fonctionnalités personnalisées avec l'élément en définissant des propriétés et des méthodes sur la définition de l'élément. Considérez cela comme un moyen de créer une API publique pour votre élément.

Voici un exemple complet:

var XFooProto = Object.create(HTMLElement.prototype);

// 1. Give x-foo a foo() method.
XFooProto.foo = function() {
    alert('foo() called');
};

// 2. Define a property read-only "bar".
Object.defineProperty(XFooProto, "bar", {value: 5});

// 3. Register x-foo's definition.
var XFoo = document.registerElement('x-foo', {prototype: XFooProto});

// 4. Instantiate an x-foo.
var xfoo = document.createElement('x-foo');

// 5. Add it to the page.
document.body.appendChild(xfoo);

Bien sûr, il existe des milliers de façons de construire une prototype. Si vous n'êtes pas fan de la création de prototypes comme celui-ci, voici une version plus condensée de la même chose:

var XFoo = document.registerElement('x-foo', {
  prototype: Object.create(HTMLElement.prototype, {
    bar: {
      get: function () {
        return 5;
      }
    },
    foo: {
      value: function () {
        alert('foo() called');
      }
    }
  })
});

Le premier format permet d'utiliser ES5 Object.defineProperty. La seconde permet d'utiliser get/set.

Méthodes de rappel de cycle de vie

Les éléments peuvent définir des méthodes spéciales pour exploiter les moments intéressants de leur existence. Ces méthodes sont nommées de manière appropriée rappels de cycle de vie. Chacun a un nom et un objectif spécifiques:

Nom du rappel Appelé quand
createdCallback une instance de l'élément est créée
attachedCallback une instance a été insérée dans le document.
detachedCallback une instance a été supprimée du document
attributeChangedCallback(attrName, oldVal, newVal) Un attribut a été ajouté, supprimé ou mis à jour.

Exemple:Définir createdCallback() et attachedCallback() sur <x-foo>:

var proto = Object.create(HTMLElement.prototype);

proto.createdCallback = function() {...};
proto.attachedCallback = function() {...};

var XFoo = document.registerElement('x-foo', {prototype: proto});

Tous les rappels de cycle de vie sont facultatifs, mais vous pouvez les définir si cela est judicieux. Par exemple, supposons que votre élément soit suffisamment complexe et qu'il ouvre une connexion à IndexedDB dans createdCallback(). Avant de le supprimer du DOM, effectuez le travail de nettoyage nécessaire dans detachedCallback(). Remarque:Ne vous fiez pas à cette méthode si l'utilisateur ferme l'onglet, par exemple, mais considérez-la comme une possible optimisation.

Les rappels de cycle de vie dans un autre cas d'utilisation permettent de configurer des écouteurs d'événements par défaut sur l'élément:

proto.createdCallback = function() {
  this.addEventListener('click', function(e) {
    alert('Thanks!');
  });
};

Ajouter un balisage

Nous avons créé <x-foo> à partir d'une API JavaScript, mais il est vide. Devons-nous lui donner du code HTML à afficher ?

Les rappels de cycle de vie sont utiles dans ce cas. En particulier, nous pouvons utiliser createdCallback() pour ajouter du code HTML par défaut à un élément:

var XFooProto = Object.create(HTMLElement.prototype);

XFooProto.createdCallback = function() {
    this.innerHTML = "**I'm an x-foo-with-markup!**";
};

var XFoo = document.registerElement('x-foo-with-markup', {prototype: XFooProto});

L'instanciation de cette balise et l'inspection dans les outils de développement (clic droit, sélectionnez "Inspecter l'élément") devraient afficher:

▾<x-foo-with-markup>
  **I'm an x-foo-with-markup!**
</x-foo-with-markup>

Encapsuler les composants internes dans Shadow DOM

En soi, le Shadow DOM est un outil puissant pour encapsuler du contenu. Utilisez-le en conjonction avec des éléments personnalisés pour créer des effets magiques !

Shadow DOM fournit des éléments personnalisés:

  1. Un moyen de cacher leur courage, ce qui protège les utilisateurs des détails d'implémentation sanglants.
  2. Encapsulation du style... pour être libre.

Créer un élément à partir de Shadow DOM revient à en créer un qui affiche un balisage de base. La différence est dans createdCallback():

var XFooProto = Object.create(HTMLElement.prototype);

XFooProto.createdCallback = function() {
    // 1. Attach a shadow root on the element.
    var shadow = this.createShadowRoot();

    // 2. Fill it with markup goodness.
    shadow.innerHTML = "**I'm in the element's Shadow DOM!**";
};

var XFoo = document.registerElement('x-foo-shadowdom', {prototype: XFooProto});

Au lieu de définir la valeur .innerHTML de l'élément, j'ai créé une racine fantôme pour <x-foo-shadowdom> et je l'ai remplie avec le balisage. Lorsque le paramètre "Show Shadow DOM" (Afficher le Shadow DOM) est activé dans les outils de développement, un #shadow-root qui peut être développé s'affiche:

▾<x-foo-shadowdom>
  ▾#shadow-root
    **I'm in the element's Shadow DOM!**
</x-foo-shadowdom>

C'est la racine de l'ombre !

Créer des éléments à partir d'un modèle

Les modèles HTML constituent une autre nouvelle primitive d'API, qui s'intègre parfaitement au monde des éléments personnalisés.

Exemple d'enregistrement d'un élément créé à partir d'un <template> et d'un Shadow DOM :

<template id="sdtemplate">
  <style>
    p { color: orange; }
  </style>
  <p>I'm in Shadow DOM. My markup was stamped from a <template&gt;.
</template>

<script>
  var proto = Object.create(HTMLElement.prototype, {
    createdCallback: {
      value: function() {
        var t = document.querySelector('#sdtemplate');
        var clone = document.importNode(t.content, true);
        this.createShadowRoot().appendChild(clone);
      }
    }
  });
  document.registerElement('x-foo-from-template', {prototype: proto});
</script>

<template id="sdtemplate">
  <style>:host p { color: orange; }</style>
  <p>I'm in Shadow DOM. My markup was stamped from a <template&gt;.
</template>

<div class="demoarea">
  <x-foo-from-template></x-foo-from-template>
</div>

Ces quelques lignes de code ont beaucoup de punch. Comprenons tout ce qui se passe:

  1. Nous avons enregistré un nouvel élément en HTML: <x-foo-from-template>
  2. Le DOM de l'élément a été créé à partir d'un <template>.
  3. Shadow DOM masque les détails effrayants de l'élément
  4. Le Shadow DOM encapsule le style de l'élément (par exemple, p {color: orange;} ne fait pas passer toute la page en orange).

Génial !

Appliquer un style aux éléments personnalisés

Comme pour toute balise HTML, les utilisateurs de votre balise personnalisée peuvent lui appliquer un style à l'aide de sélecteurs:

<style>
  app-panel {
    display: flex;
  }
  [is="x-item"] {
    transition: opacity 400ms ease-in-out;
    opacity: 0.3;
    flex: 1;
    text-align: center;
    border-radius: 50%;
  }
  [is="x-item"]:hover {
    opacity: 1.0;
    background: rgb(255, 0, 255);
    color: white;
  }
  app-panel > [is="x-item"] {
    padding: 5px;
    list-style: none;
    margin: 0 7px;
  }
</style>

<app-panel>
    <li is="x-item">Do</li>
    <li is="x-item">Re</li>
    <li is="x-item">Mi</li>
</app-panel>

Appliquer un style aux éléments qui utilisent Shadow DOM

Le terrier du lapin va beaucoup plus loin quand vous intégrez le Shadow DOM. Les éléments personnalisés qui utilisent Shadow DOM héritent de ses grands avantages.

Shadow DOM insère un élément avec une encapsulation de style. Les styles définis dans une racine d'ombre ne s'échappent pas de l'hôte et ne s'effacent pas de la page. Dans le cas d'un élément personnalisé, l'élément lui-même constitue l'hôte. Les propriétés de l'encapsulation des styles permettent également aux éléments personnalisés de définir eux-mêmes des styles par défaut.

Le style Shadow DOM est un sujet important. Pour en savoir plus, je vous recommande de lire quelques-uns de mes autres articles:

Prévention de la FOUC à l'aide de l'attribut :unresolved

Pour limiter l'effet FOUC, les éléments personnalisés spécifient une nouvelle pseudo-classe CSS, :unresolved. Utilisez-la pour cibler les éléments non résolus, jusqu'au moment où le navigateur appelle votre createdCallback() (voir Méthodes du cycle de vie). Une fois que cela se produit, l'élément n'est plus un élément non résolu. Le processus de mise à niveau est terminé, et l'élément a été transformé en sa définition.

Exemple: effectuer un fondu dans les tags "x-foo" lorsqu'ils sont enregistrés:

<style>
  x-foo {
    opacity: 1;
    transition: opacity 300ms;
  }
  x-foo:unresolved {
    opacity: 0;
  }
</style>

N'oubliez pas que :unresolved ne s'applique qu'aux éléments non résolus, et non aux éléments qui héritent de HTMLUnknownElement (consultez la section Comment les éléments sont-ils mis à niveau).

<style>
  /* apply a dashed border to all unresolved elements */
  :unresolved {
    border: 1px dashed red;
    display: inline-block;
  }
  /* x-panel's that are unresolved are red */
  x-panel:unresolved {
    color: red;
  }
  /* once the definition of x-panel is registered, it becomes green */
  x-panel {
    color: green;
    display: block;
    padding: 5px;
    display: block;
  }
</style>

<panel>
    I'm black because :unresolved doesn't apply to "panel".
    It's not a valid custom element name.
</panel>

<x-panel>I'm red because I match x-panel:unresolved.</x-panel>

Historique et navigateurs pris en charge

Détection de fonctionnalités

Pour détecter une caractéristique, il suffit de vérifier si document.registerElement() existe:

function supportsCustomElements() {
    return 'registerElement' in document;
}

if (supportsCustomElements()) {
    // Good to go!
} else {
    // Use other libraries to create components.
}

Prise en charge des navigateurs

document.registerElement() a commencé à atterrir derrière un drapeau dans Chrome 27 et Firefox ~23. Cependant, cette spécification a beaucoup évolué depuis. Chrome 31 est le premier à bénéficier d'une véritable compatibilité avec les spécifications mises à jour.

En attendant, il existe un polyfill qui est utilisé par Polymer de Google et X-Tag de Mozilla.

Qu'est-il arrivé à HTMLElementElement ?

Pour ceux qui ont suivi le travail de standardisation, vous savez qu'il y avait auparavant <element>. C'était les genoux des abeilles. Vous pouvez l'utiliser pour enregistrer de manière déclarative de nouveaux éléments:

<element name="my-element">
    ...
</element>

Malheureusement, le processus de mise à niveau, les cas d'angle et les scénarios de type Armageddon étaient trop nombreux pour résoudre le problème. Il a fallu mettre <element> sur des étagères. En août 2013, Dimitri Glazkov a annoncé sa suppression sur public-webapps, du moins pour le moment.

Notez que Polymer implémente une forme déclarative d'enregistrement d'éléments avec <polymer-element>. Comment ? Il utilise document.registerElement('polymer-element') et les techniques décrites dans la section Créer des éléments à partir d'un modèle.

Conclusion

Les éléments personnalisés nous permettent d'étendre le vocabulaire HTML, de lui apprendre de nouveaux tours et de sauter dans les mines de la plate-forme Web. En les combinant avec les autres nouvelles primitives de la plate-forme, telles que Shadow DOM et <template>, nous commençons à comprendre l'image des composants Web. Le balisage peut être à nouveau sexy !

Si vous souhaitez faire vos premiers pas avec les composants Web, je vous recommande de consulter Polymer. Il y en a assez pour vous lancer.