Les Web Components promettent un web sans framework, avec des composants réutilisables basés sur des standards. Mais tiennent-ils leurs promesses face à React, Vue et Angular ? Explorons cette technologie native avec un œil critique et des exemples concrets.
Web Components : retour aux standards
L’anatomie des Web Components
Les Web Components s’appuient sur quatre technologies natives qui créent un écosystème de composants véritablement réutilisables et interopérables.
1. Custom Elements : définir de nouveaux éléments HTML
- Créent des balises HTML personnalisées (
<smart-button>,<user-profile>) - Héritent d’HTMLElement avec lifecycle callbacks
- S’enregistrent via
customElements.define()
2. Shadow DOM : encapsulation CSS et HTML
- Isolation totale : CSS/HTML du composant isolé du reste de la page
- Slots : injection de contenu depuis l’extérieur
- Mode open/closed : contrôle d’accès au shadow tree
3. HTML Templates : templates réutilisables
- Définition du markup une seule fois
- Clone efficace avec
template.content.cloneNode() - Pas de rendu jusqu’à insertion dans le DOM
4. Observed Attributes : réactivité native
- Liste d’attributs surveillés automatiquement
- Callback
attributeChangedCallbacksur chaque changement - Synchronisation bidirectionnelle properties ↔ attributes
Exemple concret : Smart Button
Fonctionnalités natives :
- État loading avec spinner automatique
- Variants visuels (primary, success, danger, warning)
- Événements custom avec données contextuelles
- Encapsulation CSS : styles n’impactent pas le reste
- API simple :
setLoading(),disable(), etc.
Utilisation :
<smart-button variant="primary" id="myButton">Click me!</smart-button>
Avantages par rapport à un bouton HTML standard :
- Réutilisable : même comportement partout
- Encapsulé : CSS isolé, pas de conflits
- Interopérable : fonctionne dans React, Vue, Angular
- Performant : rendu natif du navigateur
- Maintenable : logique centralisée dans le composant
Comparaison technique : Web Components vs Frameworks
La question centrale : quelle approche choisir selon votre contexte ? Comparons les métriques qui comptent.
Bundle Size : Web Components gagnent haut la main
Web Components :
- Runtime : 0 KB (natif navigateur)
- Overhead par composant : ~1-2 KB
- Petite app : ~15 KB total
- Grande app : ~50 KB total
React :
- Runtime : 42 KB (React + ReactDOM gzippé)
- Overhead par composant : ~0.5 KB
- Petite app : ~60 KB total
- Grande app : 150 KB+ total
Vue 3 :
- Runtime : 34 KB gzippé
- Overhead par composant : ~0.3 KB
- Petite app : ~45 KB total
- Grande app : 120 KB+ total
Performance : Native wins
Web Components :
- First Paint : excellent (pas de JS requis)
- Hydration : N/A (pas d’hydration)
- Runtime : excellent (DOM natif)
- Mémoire : très faible
React :
- First Paint : moyen (nécessite JS)
- Hydration : coûteux en SSR
- Runtime : bon (Virtual DOM overhead)
- Mémoire : modéré
Vue 3 :
- First Paint : bon (compilation optimisée)
- Hydration : moins coûteux que React
- Runtime : excellent (Proxy reactivity)
- Mémoire : faible à modéré
Developer Experience : les frameworks l’emportent
Web Components :
- Learning curve : modérée (APIs natives verbales)
- Tooling : en développement (Lit, Stencil améliore)
- Debugging : bon (DevTools natifs)
- TypeScript : manuel mais possible
React :
- Learning curve : élevée (concepts avancés)
- Tooling : excellent (écosystème mature)
- Debugging : excellent (React DevTools)
- TypeScript : excellent
Vue :
- Learning curve : faible (API intuitive)
- Tooling : très bon (Vue DevTools, Vite)
- Debugging : très bon
- TypeScript : très bon
Matrice de décision :
Performance critique → Web Components (+3), Vue (+2), React (+1) Petit projet → Vue (+3), Web Components (+2), React (+1) Réutilisation cross-framework → Web Components (+5), autres (+1) Équipe junior → Vue (+3), Web Components (+1), React (-1) Maintenance long terme → Web Components (+2), Vue (+2), React (+1)
Cas d’usage pratiques : quand choisir les Web Components
Design System cross-framework
Les Web Components sont parfaits pour les design systems d’entreprise : une seule implémentation pour toutes les équipes, tous les frameworks.
Problème résolu :
- Équipes React, Vue, Angular dans la même entreprise
- Cohérence visuelle difficile à maintenir
- Duplication de code entre frameworks
- Mise à jour des composants complexe
Solution Web Components :
Design System Button - Fonctionnalités avancées :
- Thématisation via CSS Custom Properties :
--ds-primary-500,--ds-radius-md - Variants : primary, secondary, ghost avec styles automatiques
- Sizes : small, medium, large avec espacements cohérents
- Accessibilité native : transfert automatique des attributs ARIA
- Slots : contenu flexible (texte, icônes, etc.)
Avantages techniques :
- Single Source of Truth : un bouton, un comportement
- Thématisation centralisée : variables CSS cascadent naturellement
- Bundle splitting naturel : chaque équipe charge ses composants
- Pas de breaking changes : interface HTML stable
Usage cross-framework transparent :
HTML Vanilla :
<ds-button variant="primary" size="large">Click me</ds-button>
React : événements et props fonctionnent normalement
<ds-button variant="secondary" onClick={handleClick} aria-label="Save">Save</ds-button>
Vue : directives et événements natifs
<ds-button variant="ghost" @click="handleClick" :disabled="isLoading">Cancel</ds-button>
Angular : binding et événements standard
<ds-button variant="primary" (click)="handleClick()" [attr.aria-pressed]="isPressed">Toggle</ds-button>
ROI concret d’un design system Web Components :
- Développement : 60% de temps économisé (pas de réimplémentation)
- Maintenance : un fix = fix partout
- Cohérence : 100% garantie cross-équipes
- Adoption : friction minimale (HTML standard)
Micro-frontends avec Web Components
Les Web Components offrent une solution élégante pour les micro-frontends : encapsulation complète, déploiement indépendant, communication simple.
Architecture micro-frontend :
User Profile Widget - exemple concret :
- État local isolé : user, loading, error
- API configuration : via attributs
api-url,user-id - Rendu autonome : gère ses états de loading/error
- Styling encapsulé : styles n’impactent pas la page parent
- API publique : méthode
refreshUser()pour communication
Avantages des Web Components pour micro-frontends :
Isolation totale :
- CSS/JS complètement isolés via Shadow DOM
- Pas de conflits de noms de classes ou variables globales
- Chaque widget peut utiliser sa propre stack (React, Vue, vanilla)
Déploiement indépendant :
- Chaque équipe déploie son widget séparément
- Versioning indépendant avec backward compatibility
- Rollback granulaire par composant
Communication simple :
- Événements DOM custom pour communication parent ↔ enfant
- Attributs HTML pour configuration
- APIs JavaScript publiques pour contrôle programmatique
Exemple d’intégration :
<!-- Widget utilisateur autonome -->
<user-profile-widget
user-id="123"
api-url="/api/v2"
theme="dark"
></user-profile-widget>
<!-- Widget notifications temps réel -->
<notification-widget
ws-url="wss://notifications.company.com"
max-items="5"
></notification-widget>
Communication inter-widgets :
- Événements custom :
notification-received,user-updated - Event bus centralisé : pour coordination complexe
- Shared state : via localStorage ou service dédié
Notification Widget complémentaire :
- WebSocket temps réel : connexion automatique aux notifications
- État local : gestion des notifications en cours
- Animations : slideIn pour les nouvelles notifications
- Communication : émet des événements
notification-received - Configuration :
ws-url,max-itemsvia attributs
Avantages concrets des micro-frontends Web Components :
- Scalabilité équipe : chaque équipe travaille de façon autonome
- Technology diversity : mix React/Vue/vanilla selon l’équipe
- Fault isolation : une erreur n’impacte que le widget concerné
- Performance : lazy loading des widgets selon besoin
## Outils et frameworks pour Web Components
### Lit : le framework Web Components moderne
```javascript
// Avec Lit, les Web Components deviennent plus expressifs
import { LitElement, html, css } from 'lit';
import { property, state } from 'lit/decorators.js';
class TaskList extends LitElement {
static styles = css`
:host {
display: block;
font-family: system-ui, sans-serif;
}
.task-item {
display: flex;
align-items: center;
padding: 12px;
border: 1px solid #e1e5e9;
border-radius: 6px;
margin-bottom: 8px;
background: white;
transition: background 0.2s ease;
}
.task-item:hover {
background: #f8f9fa;
}
.task-item.completed {
opacity: 0.6;
text-decoration: line-through;
}
input[type="checkbox"] {
margin-right: 12px;
}
.task-text {
flex: 1;
}
.task-actions {
display: flex;
gap: 8px;
}
button {
padding: 4px 8px;
border: 1px solid #dee2e6;
border-radius: 4px;
background: white;
cursor: pointer;
font-size: 12px;
}
button:hover {
background: #e9ecef;
}
button.danger {
color: #dc3545;
border-color: #dc3545;
}
`;
@property({ type: Array })
tasks = [];
@state()
private filter = 'all';
render() {
const filteredTasks = this.getFilteredTasks();
return html`
<div class="task-list">
<div class="filters">
<button
@click=${() => this.setFilter('all')}
class=${this.filter === 'all' ? 'active' : ''}
>
All (${this.tasks.length})
</button>
<button
@click=${() => this.setFilter('active')}
class=${this.filter === 'active' ? 'active' : ''}
>
Active (${this.getActiveTasksCount()})
</button>
<button
@click=${() => this.setFilter('completed')}
class=${this.filter === 'completed' ? 'active' : ''}
>
Completed (${this.getCompletedTasksCount()})
</button>
</div>
${filteredTasks.length === 0
? html`<p>No tasks found</p>`
: filteredTasks.map(task => this.renderTask(task))
}
</div>
`;
}
renderTask(task) {
return html`
<div class="task-item ${task.completed ? 'completed' : ''}">
<input
type="checkbox"
.checked=${task.completed}
@change=${() => this.toggleTask(task.id)}
/>
<span class="task-text">${task.text}</span>
<div class="task-actions">
<button @click=${() => this.editTask(task.id)}>
Edit
</button>
<button
class="danger"
@click=${() => this.deleteTask(task.id)}
>
Delete
</button>
</div>
</div>
`;
}
setFilter(filter) {
this.filter = filter;
}
getFilteredTasks() {
switch (this.filter) {
case 'active':
return this.tasks.filter(task => !task.completed);
case 'completed':
return this.tasks.filter(task => task.completed);
default:
return this.tasks;
}
}
getActiveTasksCount() {
return this.tasks.filter(task => !task.completed).length;
}
getCompletedTasksCount() {
return this.tasks.filter(task => task.completed).length;
}
toggleTask(taskId) {
this.tasks = this.tasks.map(task =>
task.id === taskId
? { ...task, completed: !task.completed }
: task
);
this.dispatchEvent(new CustomEvent('task-toggled', {
detail: { taskId, tasks: this.tasks }
}));
}
editTask(taskId) {
this.dispatchEvent(new CustomEvent('task-edit-requested', {
detail: { taskId }
}));
}
deleteTask(taskId) {
this.tasks = this.tasks.filter(task => task.id !== taskId);
this.dispatchEvent(new CustomEvent('task-deleted', {
detail: { taskId, tasks: this.tasks }
}));
}
}
customElements.define('task-list', TaskList);
Stencil : compilation optimisée
// Stencil compile les Web Components pour optimiser les performances
import { Component, Prop, State, Event, EventEmitter, h } from '@stencil/core';
@Component({
tag: 'data-table',
styleUrl: 'data-table.css',
shadow: true
})
export class DataTable {
@Prop() data: any[] = [];
@Prop() columns: any[] = [];
@Prop() sortable: boolean = true;
@Prop() filterable: boolean = true;
@Prop() paginated: boolean = true;
@Prop() pageSize: number = 10;
@State() currentPage: number = 1;
@State() sortColumn: string | null = null;
@State() sortDirection: 'asc' | 'desc' = 'asc';
@State() filterText: string = '';
@Event() rowClick: EventEmitter<any>;
@Event() sortChange: EventEmitter<{column: string, direction: string}>;
private get filteredData() {
let filtered = [...this.data];
// Filtrage
if (this.filterText && this.filterable) {
const searchText = this.filterText.toLowerCase();
filtered = filtered.filter(row =>
this.columns.some(col =>
String(row[col.key]).toLowerCase().includes(searchText)
)
);
}
// Tri
if (this.sortColumn && this.sortable) {
filtered.sort((a, b) => {
const aVal = a[this.sortColumn!];
const bVal = b[this.sortColumn!];
if (aVal < bVal) return this.sortDirection === 'asc' ? -1 : 1;
if (aVal > bVal) return this.sortDirection === 'asc' ? 1 : -1;
return 0;
});
}
return filtered;
}
private get paginatedData() {
if (!this.paginated) return this.filteredData;
const start = (this.currentPage - 1) * this.pageSize;
const end = start + this.pageSize;
return this.filteredData.slice(start, end);
}
private get totalPages() {
return Math.ceil(this.filteredData.length / this.pageSize);
}
private handleSort(column: string) {
if (!this.sortable) return;
if (this.sortColumn === column) {
this.sortDirection = this.sortDirection === 'asc' ? 'desc' : 'asc';
} else {
this.sortColumn = column;
this.sortDirection = 'asc';
}
this.sortChange.emit({
column: this.sortColumn,
direction: this.sortDirection
});
}
private handleRowClick(row: any) {
this.rowClick.emit(row);
}
private handlePageChange(page: number) {
this.currentPage = page;
}
render() {
return (
<div class="data-table-container">
{this.filterable && this.renderFilter()}
<table class="data-table">
<thead>
<tr>
{this.columns.map(column => (
<th
key={column.key}
onClick={() => this.handleSort(column.key)}
class={{
'sortable': this.sortable,
'sorted': this.sortColumn === column.key,
[`sort-${this.sortDirection}`]: this.sortColumn === column.key
}}
>
{column.title}
{this.sortable && this.renderSortIndicator(column.key)}
</th>
))}
</tr>
</thead>
<tbody>
{this.paginatedData.map((row, index) => (
<tr
key={index}
onClick={() => this.handleRowClick(row)}
class="clickable-row"
>
{this.columns.map(column => (
<td key={column.key}>
{column.render
? column.render(row[column.key], row)
: row[column.key]
}
</td>
))}
</tr>
))}
</tbody>
</table>
{this.paginated && this.totalPages > 1 && this.renderPagination()}
</div>
);
}
private renderFilter() {
return (
<div class="filter-container">
<input
type="text"
placeholder="Filter data..."
value={this.filterText}
onInput={(e) => this.filterText = (e.target as HTMLInputElement).value}
/>
</div>
);
}
private renderSortIndicator(column: string) {
if (this.sortColumn !== column) {
return <span class="sort-indicator">↕</span>;
}
return (
<span class="sort-indicator active">
{this.sortDirection === 'asc' ? '↑' : '↓'}
</span>
);
}
private renderPagination() {
const pages = Array.from({ length: this.totalPages }, (_, i) => i + 1);
return (
<div class="pagination">
<button
disabled={this.currentPage === 1}
onClick={() => this.handlePageChange(this.currentPage - 1)}
>
Previous
</button>
{pages.map(page => (
<button
key={page}
class={{ active: page === this.currentPage }}
onClick={() => this.handlePageChange(page)}
>
{page}
</button>
))}
<button
disabled={this.currentPage === this.totalPages}
onClick={() => this.handlePageChange(this.currentPage + 1)}
>
Next
</button>
</div>
);
}
}
Limitations et considérations
Les défis actuels des Web Components
// Analyse des limitations techniques
class WebComponentsLimitations {
constructor() {
this.limitations = {
server_side_rendering: {
problem: 'Pas de SSR natif',
impact: 'SEO et performance initiale dégradés',
workarounds: [
'Declarative Shadow DOM (support limité)',
'Prerendering avec Puppeteer',
'Progressive enhancement'
],
status: 'En cours de résolution'
},
form_participation: {
problem: 'Intégration complexe avec les formulaires HTML',
impact: 'Custom elements ne participent pas naturellement aux forms',
workarounds: [
'Form-associated custom elements (nouveau standard)',
'Hidden inputs pour compatibility',
'Bibliothèques comme Lit Form Controls'
],
status: 'En amélioration'
},
styling_limitations: {
problem: 'CSS isolation parfois trop stricte',
impact: 'Difficile de styler depuis l\'extérieur',
workarounds: [
'CSS Custom Properties pour la thématique',
'Part pseudo-element',
'Slots pour l\'injection de contenu stylé'
],
status: 'Solutions partielles disponibles'
},
bundle_splitting: {
problem: 'Pas de code splitting automatique',
impact: 'Tous les components chargés même si inutilisés',
workarounds: [
'Dynamic imports manuel',
'Lazy loading avec Intersection Observer',
'Build tools comme Rollup avec splitting'
],
status: 'Solutions manuelles'
}
};
}
// Solution pour le form participation
createFormAssociatedComponent() {
return `
class FormInput extends HTMLElement {
static formAssociated = true;
constructor() {
super();
// Attachement au FormData
this._internals = this.attachInternals();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = \`
<style>
input {
width: 100%;
padding: 8px;
border: 1px solid #ccc;
border-radius: 4px;
}
:host([disabled]) input {
background: #f5f5f5;
cursor: not-allowed;
}
:host(:invalid) input {
border-color: #dc3545;
}
</style>
<input type="text" />
\`;
this.input = this.shadowRoot.querySelector('input');
this.setupFormIntegration();
}
setupFormIntegration() {
this.input.addEventListener('input', () => {
// Synchronisation avec FormData
this._internals.setFormValue(this.input.value);
// Validation
if (this.hasAttribute('required') && !this.input.value) {
this._internals.setValidity(
{ valueMissing: true },
'This field is required',
this.input
);
} else {
this._internals.setValidity({});
}
});
}
// API FormControl
get form() { return this._internals.form; }
get name() { return this.getAttribute('name'); }
get type() { return this.localName; }
get value() { return this.input?.value || ''; }
set value(val) {
if (this.input) {
this.input.value = val;
this._internals.setFormValue(val);
}
}
// Lifecycle callbacks
formAssociatedCallback(form) {
console.log('Associated with form:', form);
}
formResetCallback() {
this.value = '';
}
formDisabledCallback(disabled) {
this.input.disabled = disabled;
}
}
customElements.define('form-input', FormInput);
`;
}
}
Conclusion
Les Web Components représentent une évolution intéressante du développement frontend, avec des avantages réels :
Forces :
- Performance : Zero runtime overhead, rendu natif
- Interopérabilité : Fonctionnent partout, compatible avec tous frameworks
- Longévité : Basés sur des standards web pérennes
- Encapsulation : Shadow DOM offre une vraie isolation
Faiblesses actuelles :
- DX : Tooling moins mature que React/Vue
- SSR : Support limité mais en amélioration
- Écosystème : Plus petit que les frameworks mainstream
- Courbe d’apprentissage : APIs natives parfois verbales
Verdict : Les Web Components sont excellents pour :
- Design systems utilisés par plusieurs équipes/frameworks
- Widgets réutilisables (players vidéo, composants interactifs)
- Micro-frontends avec forte isolation
- Projets long terme où la pérennité est critique
Pour des applications classiques, React/Vue restent plus productifs à court terme. Mais Web Components + Lit/Stencil offrent une alternative sérieuse, surtout quand performance et réutilisabilité cross-framework sont prioritaires.
L’avenir ? Probablement hybride : frameworks pour l’application logic, Web Components pour les composants réutilisables !