Introduction

Avec le temps, les frameworks frontend se multiplient. Ceci entraîne parfois une diversification de la stack technique Frontend, ce qui limite la possibilité de réutiliser du code, car il n’y a pas d’interopérabilité entre les différents frameworks.

On pourrait se dire qu’une solution simple est d’être cohérent au niveau de la stack technique. Si un projet “A” a mis à disposition une bibliothèque de composants fait en React, tout nouveau Front n’a qu’à utiliser React. Malheureusement, ce n’est pas si simple. On peut avoir par exemple un legacy en AngularJS qu’il faut continuer à maintenir.

Les Web Components sont une solution à ce problème d’interopérabilité.

 Write once, run anywhere

 Les Web Components sont directement interprétés par les navigateurs, ce qui permet de créer une bibliothèque de composants et de les utiliser dans n’importe quel framework.

Une particularité des Web Components est que cela reste des éléments du DOM classique. Cela veut dire que les attributs d’un élément sont forcément sous la forme d’une string. Ceci pose problème car nous sommes souvent amenés à passer des données plus complexes à nos composants Frontend (tel que des tableaux par exemple).

 Est-ce que ça veut dire que je dois convertir mon tableau/objet en string pour pouvoir le passer en attribut ? 

 C’est une possibilité, mais ce n’est pas pratique et complexifie grandement l’utilisation des Web Components. Heureusement, les bibliothèques/frameworks fournissent des moyens pour pallier ce problème.

Que sont les Web Components ?

Les Web Components sont un ensemble d’APIs qui permettent de créer des éléments du DOM avec un comportement personnalisé, réutilisable et encapsulé. Cet ensemble est composé de 4 APIs :

  • Custom Elements : fournit un moyen de définir des éléments du DOM personnalisés.
  • Shadow DOM : fournit un moyen d’encapsuler le style et le markup.
  • HTML Template : fournit un moyen de définir un fragment de markup HTML et de contrôler le moment où ce fragment est affiché.
  • ES Module : fournit un moyen standard de livrer ou d’intégrer des packages

Ce sont donc ces APIs qui permettent de créer nos propres éléments HTML avec un comportement personnalisé.

Les différents type de custom elements

On peut distinguer 2 types de custom elements :

Les “Autonomous custom element”

Ce type correspond à un custom element héritant de HTMLElement.

class MyComponent extends HTMLElement { ... }
customElements.define("my-component", MyComponent)

On peut ensuite l’insérer comme un élément du DOM natif :

// sous forme de markup HTML
<my-component></my-component>
// avec du javascript
const myComponent = document.createElement("my-component");
document.body.appendChild(myComponent);

Les “Customized built-in elements”

class MyButton extends HTMLButtonElement { ... }
customElements.define("my-button", MyButton, { extends: "button" });

On l’utilise dans un document HTML comme ceci :

// sous forme de markup HTML
<button is="my-button">Clique ici</button>
// avec du javascript
const myButton = document.createElement("button", { is: "my-button" })
document.body.appendChild(myButton);

Le cycle de vie d’un Custom Element

constructor

Le constructor est le premier hook appelé lors de l’instanciation d’un Custom Element. Il doit toujours commencer par appeler le constructeur de la classe qu’il étend avec super() afin d’initialiser les propriétés de l’élément parent (spécifications).

Comme à ce stade du cycle de vie l’élément n’est pas attaché au DOM, les attributs et enfants de l’élément ne sont pas encore définis. Il faut donc éviter de les valoriser dans le constructeur et déléguer cette tâche au hook connectedCallback.

connectedCallback

Ce hook est déclenché quand l’élément est ajouté au DOM. L’élément ayant maintenant un contexte, on peut valoriser ses attributs et ses éléments enfants. À noter que ce callback peut être appelé plusieurs fois pour une même instance.

const chart = new ChartCard();

document.querySelector("#an-id").appendChild(chart); // connectedCallback est déclenché une première fois
document.querySelector("#another-id").appendChild(chart); // connectedCallback est déclenché une deuxième fois

disconnectedCallback

Ce hook est déclenché lorsque l’élément est retiré du DOM. Il peut être utilisé pour libérer les ressources de l’instance.

Par exemple, cela peut permettre de :

  • supprimer les écouteurs d’événements (removeEventListener())
  • nettoyer les tâches répétées (clearInterval())

attributeChangedCallback

Ce hook permet de réagir aux changements d’attributs du Custom Element. Afin d’éviter d’être submergé d’appels, ce hook n’observe que les changements de certains attributs définis par le getter static observedAttributes.

class ChartCard extends HTMLElement {
	static get observedAttributes() {
	    return ["value"];
	  }

	attributeChangedCallback(attributeName: string, oldValue: string, newValue: string) {
		if(attributeName === "value") {
			// traitements...
		} else if(attributeName === "not-observed-attribute") {
			// on ne rentrera jamais ici car l'attribut "not-observed-attribute" n'est pas dans `observedAttributes`
		}
	}
	// [...]
}

Le hook possède 3 paramètres :

  • attributeName : le nom de l’attribut qui a changé
  • oldValue : l’ancienne valeur de l’attribut
  • newValue : la nouvelle valeur de l’attribut

Notre Web Component

Pour l’expérimentation, nous avons à notre disposition un web component simple qui ressemble à ceci :

Le composant se nomme chart-card, il se compose d’un simple graphique sans axe. On doit fournir les données du graphique avec la possibilité d’ajouter ce que l’on veut au-dessus.

Pour utiliser ce Web Component, il faut l’enregistrer :

import { ChartCard } from "@bsyoann/chartcard";

customElements.define("chart-card", ChartCard);

On peut l’utiliser de cette façon :

<chart-card
	class="balance-card"
	serie="12, 30, 15, 50, 35, 54, 12, 65, 35, 15" 
	background-color="#191919"
	line-color="#2220a4"
	chart-width="200"
	chart-height="50">
	<h2 class="balance-card__title">Balance:</h2>
	<div class="balance-card__amount">15 €</div>
</chart-card>

Ces attributs peuvent aussi être manipulés en tant que propriété javascript comme ceci :

const balanceCard = document.createElement("chart-card");
// ici, pas de problème, on peut passer notre série de données en tant que propriété Javascript sous forme de Array
balanceCard.serie = [12, 30, 15, 50, 35, 54, 12, 65, 35, 15];
balanceCard.backgroundColor = "#191919";
balanceCard.lineColor = "#2220a4";
balanceCard.chartWidth = 200;
balanceCard.chartHeight = 50;
balanceCard.innerHTML = `
<h2 class="balance-card__title">Balance:</h2>
<div class="balance-card__amount">15 €</div>
`;

document.body.appendChild(balanceCard);

Les éléments enfants de chart-card sont ajoutés au dessus du graphique grâce a la fonctionnalité des Web Components, les slots :

// chartcard.ts
const template = document.createElement("template");
template.innerHTML = /*html*/`
<!-- [...] -->
<div id="card-root" class="card">
  <div class="header">
    <slot></slot> <!-- ici, s'ajoutent les éléments enfants du custom element -->
  </div>
  <div id="chart-container" class="chart"></div>
</div>
`;

class ChartCard extends HTMLElement {
  // [...]
	constructor() {
	  super();
		// ajoute un ShadowDom à notre élément HTML
	  this.attachShadow({ mode: "open" });
		// insère notre fragment HTML dans le ShadowDOM
	  this.shadowRoot.appendChild(template.content.cloneNode(true));
	}
	// [...]
}

La partie la plus intéressante pour nous de ce Web Component est l’attribut serie qui est de type number[] et donc se convertit difficilement en string qui est le type d’un attribut.

Intégrer un Web Component dans les frameworks actuels

Voyons maintenant comment on peut utiliser notre Web Component dans les frameworks les plus populaires.

Vue

Configuration

Pour que Vue ne tente pas de compiler les Custom Elements comme des composants Vue. Il faut configurer isCustomElement dans les compilerOptions.

// vite.config.js
import vue from '@vitejs/plugin-vue'

export default {
  plugins: [
    vue({
      template: {
        compilerOptions: {
          // traite tous les tags commençant par "ce-" comme un custom element
          isCustomElement: tag => tag.startsWith("ce-")
        }
      }
    })
  ]
}

Utilisation

Il faut enregistrer notre Custom Element en respectant la configuration au-dessus pour le nommage du tag.

import { ChartCard } from "@bsyoann/chartcard";

customElements.define("ce-chart-card", ChartCard);

On peut ensuite l’utiliser comme un composant natif.

<template>
	<ce-chart-card
    class="balance-card"
    :serie.prop="serie"
    :background-color="bgColor"
    :line-color="lineColor"
    :chart-width="width"
    :chart-height="height"
  >
    <h2 class="balance-card__title">Balance:</h2>
    <div class="balance-card__amount">{{ currentBalance }} €</div>
  </ce-chart-card>
</template>

<script lang="ts" setup>
import { computed, ref } from "vue";

const width = ref(200);
const height = ref(100);

const lineColor = ref("#2220a4");
const bgColor = ref("#191919");

const serie = ref<Array<number | string>>(generateRandomArray());

const currentBalance = computed(() => {
  const length = serie.value.length;
  return length > 0 ? serie.value[length - 1] : 0;
});
</script>

La particularité se trouve dans l’attribut serie, qui contrairement aux autres attributs, est un type de donnée complexe. :serie.prop="serie" sert à dire à Vue que, plutôt que passer serie en tant qu’attribut, il est passé en tant que propriété javascript. Vue propose une notation plus courte : .serie="serie".

Si on veut que l’utilisation du Web Component soit comme un Composant Vue, on peut faire un wrapper :

// ChartCardWrapper.vue
<template>
  <ce-chart-card
    .serie="serie"
    :background-color="backgroundColor"
    :line-color="lineColor"
    :chart-width="chartWidth"
    :chart-height="chartHeight"
  >
    <slot></slot>
  </ce-chart-card>
</template>

<script lang="ts" setup>
defineProps<{
  serie: number[];
  backgroundColor?: string;
  lineColor?: string;
  chartWidth?: number | string;
  chartHeight?: number | string;
}>();
</script>
// App.vue
<template>
	<ChartCardWrapper
	  class="balance-card"
	  :serie="serie"
	  :background-color="bgColor"
	  :line-color="lineColor"
	  :chart-width="width"
	  :chart-height="height"
	>
	  <h2 class="balance-card__title">Balance:</h2>
	  <div class="balance-card__amount">{{ currentBalance }} €</div>
	</ChartCardWrapper>
</template>

React

Configuration

Avec React 17, toutes les données passées à un Custom Element sont passées sous forme d’attributs. Comme on peut le voir ici, React ne gère pas encore bien les custom elements.

Cependant, un merge a été effectué pour résoudre ce défaut, on peut suivre l’avancement du sujet dans cette RFC : https://github.com/facebook/react/issues/11347#issuecomment-988970952. Ce merge est disponible sous le flag @experimental. Il devrait être disponible sans flag dans React 18 (ou 19).

Pour l’utiliser il faut donc installer react et react-dom avec le flag experimental :

npm install react@experimental react-dom@experimental

Et c’est tout, rien de compliqué.

Utilisation

Son utilisation est aussi simple qu’un composant react classique :

function App() {
  const [serie, setSerie] = useState<number[]>(generateRandomArray());
  const [bgColor, setBgColor] = useState("#191919")
  const [lineColor, setLineColor] = useState("#2220a4")
  const [width, setWidth] = useState<number | string>(200);
  const [height, setHeight] = useState<number | string>(50);

  const balance = useMemo(() => {
    const length = serie.length;
    return length > 0 ? serie[length - 1] : 0;
  }, [serie])

  return (
    <div className="App">
      <chart-card
        className="balance-card"
        serie={serie}
        background-color={bgColor}
        line-color={lineColor}
        chart-width={width}
        chart-height={height}
      >
        <h2 className="balance-card__title">Balance:</h2>
        <div className="balance-card__amount">{balance} €</div>
      </chart-card>
    </div>
  )
}

React détermine au runtime s’il doit passer les données en tant qu’attribut ou propriété. Si une propriété est déjà définie sur l’instance de l’élément, React utilisera une propriété javascript, sinon un attribut.

Svelte

Configuration

Bonne nouvelle, Svelte gère déjà très bien les custom elements et il n’y a pas de configuration spéciale a faire.

Utilisation

De la même manière que pour React, on utilise le Custom Element de manière semblable à un composant Svelte. Si une propriété est définie dans l’instance de l’élément, celle-ci est utilisée pour passer la donnée, sinon elle est passée en attribut.

<script lang="ts">
  function generateRandomArray(): number[] {
    const arrayLength = 20;
    return Array.from({ length: arrayLength }, () =>
      Math.floor(Math.random() * 100)
    );
  }

  let serie: number[] = generateRandomArray();
  let bgColor = "#191919"
  let lineColor = "#2220a4"
  let width: number | string = 200;
  let height: number | string = 50;

  $: balance = serie.length > 0 ? serie[serie.length - 1] : 0;
</script>

<main>
  <chart-card
    class="balance-card"
    serie={serie}
    background-color={bgColor}
    line-color={lineColor}
    chart-width={width}
    chart-height={height}
  >
    <h2 class="balance-card__title">Balance:</h2>
    <div class="balance-card__amount">{balance} €</div>
  </chart-card>
</main>

Angular

Configuration

Pour la configuration, il faut déclarer le schéma CUSTOM_ELEMENTS_SCHEMA du package @angular/core dans chaque composant ou module où on utilise notre Web Component.

import { NgModule, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
// [...]
import { AppComponent } from './app.component';

@NgModule({
	declarations: [AppComponent],
	// [...]
	schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
export class AppModule {}

Utilisation

On peut ensuite utiliser notre Web Component comme ceci :

// app.component.html
<chart-card
  class="balance-card"
  [serie]="serie"
  [attr.background-color]="bgColor"
  [attr.line-color]="lineColor"
  [attr.chart-width]="width"
  [attr.chart-height]="height"
>
  <h2 class="balance-card__title">Balance:</h2>
  <div class="balance-card__amount">15 €</div>
</chart-card>

// app.component.ts
import { Component } from '@angular/core';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css'],
})
export class AppComponent {
  generateRandomArray() {
    const arrayLength = 20;
    return Array.from({ length: arrayLength }, () =>
      Math.floor(Math.random() * 100)
    );
  }

  width = 200;
  height = 50;
  lineColor = '#2220a4';
  bgColor = '#191919';
  serie = this.generateRandomArray();
}

Angular a fait le choix de faire l’inverse de Vue. Par défaut, le binding passe par les propriétés javascript. Si l’on veut utiliser le binding par attribut, il faut alors préfixer l’attribut par attr..

Créer un custom element avec Vue.js

Depuis la version 3.2 de Vue, la bibliothèque embarque la possibilité de créer des Custom Elements en utilisant l’API Vue classique.

Pour définir un Custom Element à partir d’un composant Vue, nous utilisons defineCustomElement :

// index.ts (point d'entrée de notre lib de Custom Element créé avec Vue)
// nom du packgage : @bsyoann/vue-component-lib
import { defineCustomElement } from "vue";
import ChartCard from "./components/VueChartCard.vue"; // un SFC classique

export const VueChartCard = defineCustomElement(ChartCard);

Après avoir build le tout, on peut utiliser notre lib de Custom Element. Par exemple dans un projet React :

// main.tsx (projet react)
import { VueChartCard } from '@bsyoann/vue-component-lib';

customElements.define("vue-chart-card", VueChartCard);
// App.tsx
<vue-chart-card
  className="balance-card"
  serie={serie}
  background-color={bgColor}
  line-color={lineColor}
  chart-width={width}
  chart-height={height}
>
  <h2 className="balance-card__title">Balance:</h2>
  <div className="balance-card__amount">{balance} €</div>
</vue-chart-card>

Le principal avantage de créer notre Custom Element avec Vue est un développement plus rapide et un code plus concis. On peut profiter de l’API de Vue pour avoir un code plus lisible et maintenable.

Quelques spécificités

Si vous utilisez des Single File Component (SFC) pour construire vos Custom Elements, l’outil @vitejs/plugin-vue version 1.4.0 (ou vue-loader version 16.5.0) et ses versions ultérieures proposent un “mode Custom Element” qui transforme le contenu de la balise <style> en string et injecte ce style dans le Shadow Dom du Custom Element. Pour rappel, le Shadow DOM est opaque. Autrement dit, du style CSS dans le DOM classique n’affecte pas les éléments du Shadow DOM et inversement, le style du Shadow DOM n’affecte pas le DOM classique. Il n’est donc pas forcément nécessaire d’utiliser la fonctionnalité Scoped CSS des SFC. Vous pouvez configurer ce mode de compilation des SFC dans la configuration du plugin Vue :

// vite.config.js
export default defineConfig({
	plugins: [
		// par défaut, le mode Custom Element est appliqué pour les SFC finissant par ".ce.vue"
		// en mettant true, le mode Custom Element s'applique à tous les *.vue
		vue({customElement: true});
	]
})

 Il faut savoir qu’il n’est pas possible d’utiliser la fonctionnalité Scoped Slots de Vue, car il est impossible d’exprimer une telle logique dans les slots natifs.

Il est aussi important de savoir que pour construire nos Custom Elements, Vue se base sur le Vue Runtime. Notre bundle est donc plus lourd d’au moins 16kb. Ce n’est pas très impactant si l’on écrit une bibliothèque entière de composant. Cependant pour un seul Custom Element, il vaudra mieux l’écrire en javascript natif ou bien faire appel à d’autres bibliothèques plus légères spécialisées dans la construction de Custom Elements tels que Hybrids ou LitElement.

Si vous voulez en savoir plus sur la construction de Custom Element avec Vue, je vous laisse vous référer à la documentation officielle.

Le mot de la fin

Je ne pense pas que les Web Components sont là pour remplacer les frameworks, mais cela peut répondre à un problème d’interopérabilité pour construire un Design System qui pourra s’intégrer dans un ensemble de projets, indépendamment de leur Stack technique.

Il y a trop de frameworks pour tous les couvrir dans cet article. Cependant, je vous invite à jeter un oeil au site “Custom Elements Everywhere”, qui teste les frameworks pour voir comment ils gèrent les Custom Elements.