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 attributeChangedCallback sur 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-items via 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 !