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.
| Standard | Description | Support navigateurs |
|---|---|---|
| Custom Elements v1 | Enregistrer de nouveaux éléments HTML (customElements.define()) | Chrome, Firefox, Safari, Edge (tous modernes) |
| Shadow DOM | Encapsulation CSS et DOM (arbre DOM isolé) | Chrome, Firefox, Safari, Edge |
| HTML Templates | <template> — fragments HTML inertes clonables | Tous navigateurs modernes |
| ES Modules | Import/export natif pour les Custom Elements | Tous 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)