Front-end angularforall.com

- Angular Elements : exposer vos composants en Web Components

Angular Web Components Angular Elements Interop Frontend
Angular Elements : exposer vos composants en Web Components

Réutilisez vos composants Angular hors de l'écosystème grâce à Angular Elements : convertissez vos composants en Web Components conformes aux standards du web.

Standard Web Components et Custom Elements API

Les Web Components sont un ensemble de standards W3C qui permettent de créer des éléments HTML réutilisables encapsulés. Angular Elements s'appuie sur la Custom Elements API (partie de ces standards) pour enregistrer des composants Angular comme de vrais éléments HTML natifs reconnus par le navigateur.

StandardDescriptionSupport navigateurs
Custom Elements v1Enregistrer de nouveaux éléments HTML (customElements.define())Chrome, Firefox, Safari, Edge (tous modernes)
Shadow DOMEncapsulation CSS et DOM (arbre DOM isolé)Chrome, Firefox, Safari, Edge
HTML Templates<template> — fragments HTML inertes clonablesTous navigateurs modernes
ES ModulesImport/export natif pour les Custom ElementsTous navigateurs modernes
// Custom Elements API — ce qu'Angular Elements fait en interne
// Un Custom Element est une classe qui étend HTMLElement
class CounterElement extends HTMLElement {
    connectedCallback() {  // équivalent ngOnInit — appelé quand ajouté au DOM
        this.innerHTML = `<span>${this.getAttribute('value') ?? '0'}</span>`;
    }
    attributeChangedCallback(name, oldVal, newVal) {  // observedAttributes a changé
        if (name === 'value') this.querySelector('span').textContent = newVal;
    }
    static get observedAttributes() { return ['value']; }
}

customElements.define('my-counter', CounterElement);
// Maintenant <my-counter value="5"></my-counter> fonctionne dans n'importe quelle page

// Angular Elements fait tout cela automatiquement via createCustomElement()

Installation et structure du projet

# Option 1 : Ajouter à un projet Angular existant
ng add @angular/elements
# → ajoute @angular/elements dans package.json

# Option 2 : Workspace dédié (recommandé pour distribuer des widgets)
ng new widget-workspace --create-application=false
cd widget-workspace
ng generate application af-counter-widget --standalone
ng generate application af-chart-widget --standalone
# → un build séparé par widget, versions indépendantes

# Structure recommandée pour un widget distribué
widget-workspace/
  projects/
    af-counter-widget/
      src/
        app/
          counter.component.ts    # le composant Angular standard
          counter.component.spec.ts
        main.ts                   # createCustomElement + customElements.define
      package.json               # version du widget
    af-chart-widget/
      ...
  dist/                           # builds générés
    af-counter-widget.js          # bundle auto-suffisant
    af-chart-widget.js

Créer et enregistrer un Web Component

// counter.component.ts — composant Angular standard
import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy } from '@angular/core';

@Component({
    selector: 'app-counter',
    standalone: true,
    changeDetection: ChangeDetectionStrategy.OnPush,  // important pour les Web Components
    template: `
        <div class="af-counter">
            <button class="af-counter__btn" (click)="decrement()" [disabled]="value <= min">−</button>
            <span class="af-counter__value">{{ value }}</span>
            <button class="af-counter__btn" (click)="increment()" [disabled]="value >= max">+</button>
        </div>
    `,
    styles: [`
        :host { display: inline-flex; font-family: system-ui, sans-serif; }
        .af-counter { display: flex; align-items: center; gap: 8px; }
        .af-counter__value { min-width: 40px; text-align: center; font-weight: bold; font-size: 1.2em; }
        .af-counter__btn { width: 32px; height: 32px; border: 1px solid #ccc; border-radius: 4px;
                           background: #f5f5f5; cursor: pointer; font-size: 1.2em; }
        .af-counter__btn:disabled { opacity: 0.4; cursor: not-allowed; }
    `]
})
export class CounterComponent {
    @Input() value = 0;
    @Input() min = 0;
    @Input() max = 100;
    @Input() step = 1;
    @Output() valueChange = new EventEmitter<number>();

    increment(): void {
        if (this.value + this.step <= this.max) {
            this.value += this.step;
            this.valueChange.emit(this.value);
        }
    }

    decrement(): void {
        if (this.value - this.step >= this.min) {
            this.value -= this.step;
            this.valueChange.emit(this.value);
        }
    }
}
// main.ts — point d'entrée, crée le Custom Element
import { createApplication } from '@angular/platform-browser';
import { provideZonelessChangeDetection } from '@angular/core';
import { createCustomElement } from '@angular/elements';
import { CounterComponent } from './app/counter.component';

(async () => {
    const app = await createApplication({
        providers: [
            provideZonelessChangeDetection()  // optionnel, réduit le bundle (pas de zone.js)
        ]
    });

    // createCustomElement retourne une classe qui étend HTMLElement
    const CounterElement = createCustomElement(CounterComponent, {
        injector: app.injector
    });

    // Enregistrer dans le registre global des Custom Elements
    customElements.define('af-counter', CounterElement);
    // Maintenant <af-counter> est un vrai élément HTML

    // Enregistrer plusieurs widgets depuis le même main.ts (partage du runtime Angular)
    // const ChartElement = createCustomElement(ChartComponent, { injector: app.injector });
    // customElements.define('af-chart', ChartElement);
})();

Angular Elements avec Signals

Les Signals fonctionnent parfaitement avec Angular Elements. En combinant Signals et mode zoneless, le widget est plus léger (pas de zone.js) et se met à jour précisément.

// Widget Signals-based — plus moderne et plus efficace
import { Component, signal, computed, input, output } from '@angular/core';

@Component({
    selector: 'app-product-counter',
    standalone: true,
    template: `
        <div class="product-counter">
            <span class="product-counter__name">{{ productName() }}</span>
            <button (click)="decrement()">−</button>
            <span>{{ quantity() }}</span>
            <button (click)="increment()">+</button>
            <span class="product-counter__total">{{ total() | currency:'EUR' }}</span>
        </div>
    `
})
export class ProductCounterComponent {
    // input() signal-based — automatiquement mappé sur attribut HTML
    productName = input.required<string>();
    unitPrice = input<number>(0);

    // output() signal-based — automatiquement mappé sur CustomEvent
    quantityChange = output<number>();

    // État interne du widget
    quantity = signal(1);

    // computed() — réactif à quantity et unitPrice
    total = computed(() => this.quantity() * this.unitPrice());

    increment() {
        this.quantity.update(q => q + 1);
        this.quantityChange.emit(this.quantity());
    }

    decrement() {
        if (this.quantity() > 1) {
            this.quantity.update(q => q - 1);
            this.quantityChange.emit(this.quantity());
        }
    }
}

// Note : input.required() mappe sur l'attribut HTML "product-name" (camelCase → kebab-case)
// <af-product-counter product-name="Clavier" unit-price="59.99"></af-product-counter>

Inputs, outputs et CustomEvent

Angular Elements mappe automatiquement les @Input() / input() en attributs HTML observables et en propriétés JavaScript. Les @Output() / output() deviennent des CustomEvent du DOM.

// Utilisation depuis JavaScript/HTML vanilla
const counter = document.querySelector('af-counter');

// 1. Lire/écrire via propriétés JavaScript (types préservés — objets, tableaux, etc.)
counter.value = 5;
counter.max = 20;
console.log(counter.value);  // 5

// 2. Lire/écrire via attributs HTML (uniquement des strings)
counter.setAttribute('value', '10');
counter.getAttribute('value');  // '10' (string)
// Angular Elements convertit '10' vers number automatiquement si @Input() est number

// 3. Écouter les événements (CustomEvent)
counter.addEventListener('valueChange', (event) => {
    // event.detail contient la valeur émise par EventEmitter/output()
    console.log('Nouvelle valeur:', event.detail);
    document.querySelector('#total').textContent = `Total : ${event.detail}`;
});

// 4. Usage déclaratif dans HTML statique
// Attributs kebab-case pour les inputs camelCase
// <af-counter value="3" min="0" max="10" step="2"></af-counter>

// 5. Usage dans React (JSX) — events avec onValueChange
// <af-counter ref={counterRef} value={count} />
// counterRef.current.addEventListener('valueChange', handler);

Styles et Shadow DOM

Angular propose trois modes d'encapsulation CSS. Pour les Web Components distribués, ShadowDom garantit que les styles ne fuient ni vers l'extérieur ni vers l'intérieur.

// ViewEncapsulation.Emulated (défaut Angular)
// Angular ajoute des attributs _nghost-xxx et _ngcontent-xxx pour scoper les styles
// Styles scopés mais PAS de vrai Shadow DOM
@Component({
    encapsulation: ViewEncapsulation.Emulated,  // défaut
    // ...
})

// ViewEncapsulation.ShadowDom — vrai Shadow DOM
// Les styles externes N'ENTRENT PAS dans le widget
// Les styles du widget NE FUIENT PAS vers la page
@Component({
    encapsulation: ViewEncapsulation.ShadowDom,
    styles: [`
        /* :host = l'élément custom lui-même */
        :host { display: block; box-sizing: border-box; }

        /* Personnalisation via CSS custom properties (variables) */
        /* Les consommateurs peuvent overrider sans entrer dans le Shadow DOM */
        button {
            background: var(--af-btn-bg, #6c63ff);
            color: var(--af-btn-color, white);
            border-radius: var(--af-btn-radius, 4px);
            padding: var(--af-btn-padding, 8px 16px);
        }

        /* :host-context() — adapter les styles selon le contexte parent */
        :host-context([data-theme='dark']) { background: #1a1a2e; color: white; }
    `]
})

// Utilisation côté consommateur : personnaliser via CSS custom properties
// /* Dans la page hôte */
// af-counter { --af-btn-bg: #e91e63; --af-btn-radius: 20px; }

Bundle — optimisation et partage du runtime

# Build du widget en production
ng build --project af-counter-widget --configuration=production --output-hashing=none

# Concaténer les chunks en un seul fichier JS (vanilla concat)
# L'ordre est important : polyfills en premier si nécessaire
cat dist/af-counter-widget/browser/polyfills.js \
    dist/af-counter-widget/browser/main.js \
    > public/af-counter-widget.js

# Taille typique d'un widget Angular standalone + zoneless (2025) :
# - Angular runtime (Ivy) : ~40 KB gzippé
# - Composant + styles + logique : ~3-10 KB gzippé
# - Sans zone.js : économie ~12 KB gzippé
# - Total widget minimal : ~43-50 KB gzippé

Partager le runtime entre plusieurs widgets

// Si plusieurs widgets Angular coexistent sur la même page, partager le runtime
// évite de le télécharger plusieurs fois

// webpack.config.js / esbuild config — externaliser le runtime Angular
module.exports = {
  externals: {
    '@angular/core': 'ngCore',
    '@angular/common': 'ngCommon',
    // etc.
  }
}

// Puis en HTML, charger le runtime partagé une seule fois :
// <script src="angular-runtime.js"></script>  ← runtime partagé
// <script src="af-counter.js"></script>        ← widget 1
// <script src="af-chart.js"></script>          ← widget 2

// Alternative moderne : Import Maps (Chrome, Firefox, Safari)
// Pas de bundler nécessaire pour les modules ES modernes

Consommer Angular Elements dans React et Vue

// ===== React =====
// Les Web Components fonctionnent dans JSX mais avec des limitations
// Les événements custom ne fonctionnent pas avec la syntaxe onEventName
// Il faut utiliser un ref + addEventListener

import { useEffect, useRef } from 'react';

function ProductPage() {
    const counterRef = useRef(null);

    useEffect(() => {
        const el = counterRef.current;
        const handleChange = (e) => setQuantity(e.detail);

        el.addEventListener('valueChange', handleChange);
        return () => el.removeEventListener('valueChange', handleChange);
    }, []);

    return (
        <div>
            {/* Les attributs en kebab-case fonctionnent normalement */}
            <af-counter
                ref={counterRef}
                value="1"
                max="10"
            />
        </div>
    );
}

// ===== Vue 3 =====
// Vue supporte mieux les Web Components — les CustomEvents fonctionnent avec @
// Il faut déclarer les custom elements pour éviter les warnings de résolution

// vite.config.js
export default {
    vue: {
        compilerOptions: {
            isCustomElement: (tag) => tag.startsWith('af-')  // 'af-*' = web components
        }
    }
}

// Template Vue :
// <af-counter :value="quantity" @value-change="onQuantityChange" />
// Vue mappe value-change sur l'événement DOM 'valueChange'

Plusieurs widgets sur la même page

// Enregistrer plusieurs Custom Elements depuis un seul bootstrap Angular
(async () => {
    const app = await createApplication({
        providers: [
            provideZonelessChangeDetection(),
            provideHttpClient(),  // si les widgets font des requêtes HTTP
        ]
    });

    // Enregistrer tous les widgets partageant le même runtime Angular
    const elements = [
        ['af-counter', CounterComponent],
        ['af-product-card', ProductCardComponent],
        ['af-search-bar', SearchBarComponent],
        ['af-cart-widget', CartWidgetComponent],
    ] as const;

    for (const [tag, component] of elements) {
        const element = createCustomElement(component as Type<unknown>, {
            injector: app.injector
        });
        if (!customElements.get(tag)) {  // éviter les re-définitions si chargé plusieurs fois
            customElements.define(tag, element);
        }
    }
})();

Quand utiliser Angular Elements

Cas d'usage pertinents

  • Partager des widgets métier complexes (tableau de données, formulaire multi-étapes, graphique) entre applications de stacks différentes
  • Intégrer des composants Angular dans un CMS (WordPress, Drupal, Contentful) ou un portail legacy sans migrer l'application
  • Exposer un design system Angular à des équipes utilisant React, Vue ou Svelte — un seul code source, plusieurs consommateurs
  • Micro-frontends : chaque équipe déploie ses widgets indépendamment, la page les assemble

Quand ne pas utiliser Angular Elements

  • Si l'application consommatrice est elle-même Angular → préférer une bibliothèque partagée dans un monorepo Nx (pas de surcoût de bundle)
  • Si les performances sont critiques et le bundle de ~50 KB gzippé est inacceptable → Web Components natifs sans framework
  • Si les composants n'ont pas besoin de logique Angular complexe (services, HTTP, routing)
Documentation de l'API du widget : Documentez systématiquement les attributs acceptés (nom, type, valeur par défaut), les CustomEvent émis (nom, type de event.detail), et les CSS custom properties de personnalisation. Un README versioned dans npm permet aux équipes consommatrices d'intégrer sans regarder le code source Angular.

Partager