Zum Inhalt springen
CASOON

Web Components in Astro: Praktischer Einstieg mit Beispiel-Repository

Vom ersten Custom Element bis zur produktionsreifen Komponente – mit vollständigem Code zum Nachbauen

18 Minuten
Web Components in Astro: Praktischer Einstieg mit Beispiel-Repository
#Web Components #Astro #Custom Elements #Shadow DOM
SerieWeb Components
Teil 2 von 4

Im vorherigen Teil ging es um die Theorie: Was sind Web Components, warum sind sie relevant, wie ordnen sie sich in die Komponenten-Landschaft ein. Jetzt wird es praktisch.

Dieser Artikel zeigt Schritt für Schritt, wie man Web Components in einem Astro-Projekt einsetzt – von der einfachsten Komponente bis zur produktionsreifen Lösung mit State, Events und Styling.

Was wir bauen:

  • Eine Produkt-Seite mit interaktiven Web Components
  • ProductCard mit Add-to-Cart-Funktion
  • FilterBar für Produktfilterung
  • ShoppingCart mit Badge-Counter

Projektsetup

Neues Astro-Projekt erstellen

npm create astro@latest astro-webcomponents-example
cd astro-webcomponents-example
npm install

Bei den Setup-Fragen:

  • Template: Empty
  • TypeScript: Strict (optional, aber empfohlen)
  • Dependencies installieren: Yes

Projektstruktur

astro-webcomponents-example/
├── src/
│   ├── components/
│   │   ├── wc/                    # Web Components
│   │   │   ├── product-card.js
│   │   │   ├── filter-bar.js
│   │   │   └── shopping-cart.js
│   │   └── ui/                    # Astro-Komponenten
│   │       └── Layout.astro
│   ├── pages/
│   │   └── index.astro
│   ├── data/
│   │   └── products.json
│   └── styles/
│       └── global.css
├── public/
│   └── images/
├── astro.config.mjs
└── package.json

Warum diese Trennung?

  • components/wc/ – Web Components (Vanilla JS, framework-unabhängig)
  • components/ui/ – Astro-Komponenten (Layout, Seiten-Struktur)

Diese Trennung macht deutlich: Web Components sind portable Bausteine, Astro-Komponenten sind projekt-spezifische Struktur.

Die erste Web Component: ProductCard

Minimale Version

Beginnen wir mit der einfachsten möglichen Web Component:

// src/components/wc/product-card.js

class ProductCard extends HTMLElement {
  connectedCallback() {
    const name = this.getAttribute('name') || 'Produkt';
    const price = this.getAttribute('price') || '0.00';
    const image = this.getAttribute('image') || '/images/placeholder.jpg';
    
    this.innerHTML = `
      <article class="product-card">
        <img src="${image}" alt="${name}" />
        <h3>${name}</h3>
        <p class="price">${price} €</p>
        <button class="add-to-cart">In den Warenkorb</button>
      </article>
    `;
  }
}

customElements.define('product-card', ProductCard);

Einbindung in Astro

---
// src/pages/index.astro
import Layout from '../components/ui/Layout.astro';
---

<Layout title="Produkte">
  <h1>Unsere Produkte</h1>
  
  <div class="product-grid">
    <product-card 
      name="Astro T-Shirt" 
      price="29.99" 
      image="/images/tshirt.jpg">
    </product-card>
    
    <product-card 
      name="Web Components Hoodie" 
      price="49.99" 
      image="/images/hoodie.jpg">
    </product-card>
  </div>
  
  <script src="../components/wc/product-card.js"></script>
</Layout>

Das funktioniert – aber es gibt Probleme:

  1. Kein Shadow DOM – Styles sind nicht gekapselt
  2. Keine Events – Der Button tut nichts
  3. Kein Schutz vor XSS – Attribute werden direkt eingefügt

ProductCard mit Shadow DOM

Die minimale Version funktioniert, hat aber Schwächen: keine Style-Kapselung, keine Events, kein XSS-Schutz. Für eine produktionsreife Komponente brauchen wir mehr.

Shadow DOM aktivieren

Der erste Schritt ist die Aktivierung von Shadow DOM im Constructor:

class ProductCard extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
  }
}

attachShadow({ mode: 'open' }) erstellt einen Shadow Root, der Styles und DOM vollständig kapselt. Der Mode open erlaubt externen JavaScript-Zugriff auf element.shadowRoot – bei closed wäre das nicht möglich.

Reaktive Attribute definieren

Web Components können auf Attributänderungen reagieren. Dafür definieren wir observedAttributes:

static get observedAttributes() {
  return ['name', 'price', 'image', 'product-id'];
}

attributeChangedCallback(name, oldValue, newValue) {
  if (oldValue !== newValue && this.shadowRoot.innerHTML) {
    this.render();
  }
}

Nur Attribute, die in observedAttributes gelistet sind, lösen den Callback aus. Das ist eine bewusste Performance-Entscheidung: Der Browser beobachtet nicht alle Attribute.

XSS-Schutz einbauen

Bevor wir Attribute ins HTML einfügen, müssen wir sie escapen:

escapeHtml(text) {
  const div = document.createElement('div');
  div.textContent = text;
  return div.innerHTML;
}

Diese simple Funktion nutzt den Browser selbst: textContent interpretiert nichts als HTML, innerHTML gibt den escaped String zurück. Ein Attributwert wie <script>alert('XSS')</script> wird zu &lt;script&gt;....

Styles im Shadow DOM

Im Shadow DOM definierte Styles gelten nur dort. Das ist das Killer-Feature für Komponenten:

this.shadowRoot.innerHTML = `
  <style>
    :host {
      display: block;
    }

    .product-card {
      background: white;
      border-radius: 12px;
      box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
    }

    /* Diese Regeln beeinflussen NUR diese Komponente */
    h3 { margin: 0; }
    .price { color: #2563eb; }
  </style>
  <!-- HTML hier -->
`;

:host ist der Selektor für das Custom Element selbst. Externe CSS-Regeln wie h3 { color: red; } haben keinen Einfluss auf das <h3> im Shadow DOM.

Custom Events für Kommunikation

Der Button soll ein Event auslösen, das außerhalb der Komponente empfangen werden kann:

setupEventListeners() {
  const button = this.shadowRoot.querySelector('.add-to-cart');
  
  button.addEventListener('click', () => {
    this.dispatchEvent(new CustomEvent('add-to-cart', {
      bubbles: true,
      composed: true,
      detail: {
        productId: this.getAttribute('product-id'),
        name: this.getAttribute('name'),
        price: parseFloat(this.getAttribute('price'))
      }
    }));
  });
}

Zwei Optionen sind entscheidend:

  • bubbles: true – Das Event steigt im DOM-Baum auf und kann auf document gefangen werden
  • composed: true – Das Event durchbricht die Shadow DOM Boundary. Ohne diese Option bleibt das Event im Shadow DOM gefangen

Das detail-Objekt transportiert die Payload. Außerhalb der Komponente ist event.detail.productId verfügbar.

Visuelles Feedback

Nach dem Klick soll der Button kurz bestätigen, dass das Produkt hinzugefügt wurde:

button.textContent = 'Hinzugefügt ✓';
button.classList.add('added');

setTimeout(() => {
  button.textContent = 'In den Warenkorb';
  button.classList.remove('added');
}, 1500);

Die CSS-Klasse .added ändert die Hintergrundfarbe zu Grün. Nach 1,5 Sekunden wird der ursprüngliche Zustand wiederhergestellt.

Event-Handling in Astro

Globaler Event-Listener

---
// src/pages/index.astro
import Layout from '../components/ui/Layout.astro';
import products from '../data/products.json';
---

<Layout title="Produkte">
  <header>
    <h1>Unsere Produkte</h1>
    <shopping-cart></shopping-cart>
  </header>
  
  <div class="product-grid">
    {products.map(product => (
      <product-card 
        product-id={product.id}
        name={product.name} 
        price={product.price} 
        image={product.image}>
      </product-card>
    ))}
  </div>
  
  <script>
    // Web Components laden
    import '../components/wc/product-card.js';
    import '../components/wc/shopping-cart.js';
    
    // Globaler Event-Handler für Add-to-Cart
    document.addEventListener('add-to-cart', (event) => {
      const { productId, name, price } = event.detail;
      
      // Shopping Cart aktualisieren
      const cart = document.querySelector('shopping-cart');
      cart.addItem({ productId, name, price });
    });
  </script>
</Layout>

<style>
  header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-bottom: 2rem;
  }
  
  .product-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
    gap: 1.5rem;
  }
</style>

Produktdaten

// src/data/products.json
[
  {
    "id": "1",
    "name": "Astro T-Shirt",
    "price": "29.99",
    "image": "/images/tshirt.jpg",
    "category": "clothing"
  },
  {
    "id": "2",
    "name": "Web Components Hoodie",
    "price": "49.99",
    "image": "/images/hoodie.jpg",
    "category": "clothing"
  },
  {
    "id": "3",
    "name": "JavaScript Mug",
    "price": "14.99",
    "image": "/images/mug.jpg",
    "category": "accessories"
  },
  {
    "id": "4",
    "name": "Developer Stickers",
    "price": "4.99",
    "image": "/images/stickers.jpg",
    "category": "accessories"
  }
]

ShoppingCart Component

Der Warenkorb ist komplexer: Er verwaltet State (die Produkte), hat ein Dropdown-Menü und reagiert auf Events von außen.

State in Web Components

Web Components haben keinen eingebauten State-Mechanismus wie React. Wir nutzen einfach Klassen-Properties:

class ShoppingCart extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.items = [];  // Das ist unser State
  }
}

Der State lebt in this.items. Bei Änderungen rufen wir manuell this.render() auf – kein Virtual DOM, kein Diffing, einfach neu rendern.

Public API für externe Aufrufe

Der Warenkorb braucht Methoden, die von außen aufgerufen werden können:

addItem(item) {
  const existing = this.items.find(i => i.productId === item.productId);
  
  if (existing) {
    existing.quantity++;
  } else {
    this.items.push({ ...item, quantity: 1 });
  }
  
  this.render();
  this.animateBadge();
}

removeItem(productId) {
  this.items = this.items.filter(i => i.productId !== productId);
  this.render();
}

Von außen kann man document.querySelector('shopping-cart').addItem({...}) aufrufen. Die Komponente ist ein normales DOM-Element mit zusätzlichen Methoden.

Computed Properties mit Getters

Für berechnete Werte wie Gesamtanzahl und Gesamtpreis eignen sich Getter:

get totalItems() {
  return this.items.reduce((sum, item) => sum + item.quantity, 0);
}

get totalPrice() {
  return this.items.reduce((sum, item) => sum + (item.price * item.quantity), 0);
}

Im Template nutzen wir sie wie Properties: ${this.totalItems} und ${this.totalPrice.toFixed(2)}.

Badge-Animation

Ein kleines Detail, das die UX verbessert – der Badge hüpft bei Änderungen:

animateBadge() {
  const badge = this.shadowRoot.querySelector('.badge');
  badge.classList.add('bounce');
  setTimeout(() => badge.classList.remove('bounce'), 300);
}

Die CSS-Animation:

@keyframes bounce {
  0%, 100% { transform: scale(1); }
  50% { transform: scale(1.3); }
}

.badge.bounce {
  animation: bounce 0.3s ease;
}

Das Dropdown soll sich schließen, wenn man außerhalb klickt:

setupEventListeners() {
  const button = this.shadowRoot.querySelector('.cart-button');
  const dropdown = this.shadowRoot.querySelector('.dropdown');

  button.addEventListener('click', () => {
    dropdown.classList.toggle('open');
  });

  // Click Outside Handler
  document.addEventListener('click', (e) => {
    if (!this.contains(e.target)) {
      dropdown.classList.remove('open');
    }
  });
}

this.contains(e.target) prüft, ob der Klick innerhalb der Komponente war. Wenn nicht, schließt das Dropdown.

FilterBar Component

Die FilterBar zeigt ein wichtiges Pattern: Wie übergibt man komplexe Daten an Web Components?

JSON-Attribute parsen

HTML-Attribute sind immer Strings. Für komplexe Daten wie Arrays nutzen wir JSON:

get categories() {
  try {
    return JSON.parse(this.getAttribute('categories') || '[]');
  } catch {
    return [];
  }
}

In Astro übergeben wir das Array als JSON-String:

<filter-bar categories={JSON.stringify(categories)}></filter-bar>

Der Getter categories parst den String und gibt ein Array zurück. Der try/catch-Block fängt ungültiges JSON ab.

Buttons dynamisch rendern

Im Template iterieren wir über die Kategorien:

${categories.map(cat => `
  <button class="filter-btn" data-category="${cat.id}">
    ${this.escapeHtml(cat.name)}
  </button>
`).join('')}

Das .join('') ist wichtig – ohne es würde JavaScript Kommas zwischen die Buttons setzen.

Active State verwalten

Bei Klick wird der aktive Button gewechselt:

setupEventListeners() {
  this.shadowRoot.querySelectorAll('.filter-btn').forEach(btn => {
    btn.addEventListener('click', () => {
      // Alle Buttons deaktivieren
      this.shadowRoot.querySelectorAll('.filter-btn').forEach(b => 
        b.classList.remove('active')
      );
      // Geklickten Button aktivieren
      btn.classList.add('active');

      // Event nach außen senden
      this.dispatchEvent(new CustomEvent('filter-change', {
        bubbles: true,
        composed: true,
        detail: { category: btn.dataset.category }
      }));
    });
  });
}

Das Event filter-change transportiert die gewählte Kategorie. Die Seite reagiert darauf und filtert die Produkte.

Integration in die Seite

---
// src/pages/index.astro
import Layout from '../components/ui/Layout.astro';
import products from '../data/products.json';

const categories = [
  { id: 'clothing', name: 'Kleidung' },
  { id: 'accessories', name: 'Zubehör' }
];
---

<Layout title="Produkte">
  <header>
    <h1>Unsere Produkte</h1>
    <shopping-cart></shopping-cart>
  </header>
  
  <filter-bar categories={JSON.stringify(categories)}></filter-bar>
  
  <div class="product-grid" id="product-grid">
    {products.map(product => (
      <product-card 
        product-id={product.id}
        name={product.name} 
        price={product.price} 
        image={product.image}
        data-category={product.category}>
      </product-card>
    ))}
  </div>
  
  <script>
    import '../components/wc/product-card.js';
    import '../components/wc/shopping-cart.js';
    import '../components/wc/filter-bar.js';
    
    // Add-to-Cart Handler
    document.addEventListener('add-to-cart', (event) => {
      const cart = document.querySelector('shopping-cart');
      cart.addItem(event.detail);
    });
    
    // Filter Handler
    document.addEventListener('filter-change', (event) => {
      const { category } = event.detail;
      const products = document.querySelectorAll('product-card');
      
      products.forEach(product => {
        if (category === 'all' || product.dataset.category === category) {
          product.style.display = 'block';
        } else {
          product.style.display = 'none';
        }
      });
    });
  </script>
</Layout>

Best Practices

1. Konsistente Namenskonvention

// Gut: Klarer Präfix, beschreibender Name
customElements.define('shop-product-card', ProductCard);
customElements.define('shop-filter-bar', FilterBar);
customElements.define('shop-cart', ShoppingCart);

// Vermeiden: Generische Namen
customElements.define('card', Card); // Zu generisch
customElements.define('my-element', MyElement); // Nicht beschreibend

2. Defensive Programmierung

class ProductCard extends HTMLElement {
  connectedCallback() {
    // Fallbacks für alle Attribute
    const name = this.getAttribute('name') || 'Unbekanntes Produkt';
    const price = parseFloat(this.getAttribute('price')) || 0;
    
    // Validierung
    if (isNaN(price) || price < 0) {
      console.warn('ProductCard: Ungültiger Preis', this);
    }
    
    this.render();
  }
}

3. Lazy Loading

// Komponenten erst laden, wenn sie gebraucht werden
const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      import('./heavy-component.js');
      observer.disconnect();
    }
  });
});

observer.observe(document.querySelector('#heavy-component-container'));

4. TypeScript-Integration (optional)

// src/components/wc/product-card.ts

interface ProductCardAttributes {
  name: string;
  price: string;
  image: string;
  'product-id': string;
}

class ProductCard extends HTMLElement {
  private _name: string = '';
  private _price: number = 0;

  static get observedAttributes(): (keyof ProductCardAttributes)[] {
    return ['name', 'price', 'image', 'product-id'];
  }

  get name(): string {
    return this._name;
  }

  set name(value: string) {
    this._name = value;
    this.setAttribute('name', value);
  }

  // ... Rest der Implementierung
}

// Type Declaration für JSX
declare global {
  namespace JSX {
    interface IntrinsicElements {
      'product-card': React.DetailedHTMLProps<
        React.HTMLAttributes<HTMLElement> & ProductCardAttributes,
        HTMLElement
      >;
    }
  }
}

5. CSS Custom Properties für Theming

class ProductCard extends HTMLElement {
  render() {
    this.shadowRoot.innerHTML = `
      <style>
        :host {
          --card-bg: var(--product-card-bg, white);
          --card-radius: var(--product-card-radius, 12px);
          --card-shadow: var(--product-card-shadow, 0 2px 8px rgba(0,0,0,0.1));
          --price-color: var(--product-card-price-color, #2563eb);
          --button-bg: var(--product-card-button-bg, #2563eb);
        }

        .product-card {
          background: var(--card-bg);
          border-radius: var(--card-radius);
          box-shadow: var(--card-shadow);
        }

        .price {
          color: var(--price-color);
        }

        .add-to-cart {
          background: var(--button-bg);
        }
      </style>
      <!-- ... -->
    `;
  }
}
/* Global Theming in Astro */
:root {
  --product-card-bg: white;
  --product-card-price-color: #2563eb;
}

@media (prefers-color-scheme: dark) {
  :root {
    --product-card-bg: #1e293b;
    --product-card-price-color: #60a5fa;
  }
}

Performance-Optimierungen

1. Template-Caching

// Template einmal erstellen, mehrfach klonen
const template = document.createElement('template');
template.innerHTML = `
  <style>/* ... */</style>
  <article class="product-card">
    <img class="product-image" />
    <div class="product-content">
      <h3 class="name"></h3>
      <p class="price"></p>
      <button class="add-to-cart">In den Warenkorb</button>
    </div>
  </article>
`;

class ProductCard extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    // Template klonen statt innerHTML neu parsen
    this.shadowRoot.appendChild(template.content.cloneNode(true));
  }

  connectedCallback() {
    // Nur Werte aktualisieren
    this.shadowRoot.querySelector('.name').textContent = this.getAttribute('name');
    this.shadowRoot.querySelector('.price').textContent = `${this.getAttribute('price')} €`;
    this.shadowRoot.querySelector('.product-image').src = this.getAttribute('image');
  }
}

2. Debouncing für Updates

class FilterBar extends HTMLElement {
  #updateTimeout = null;

  requestUpdate() {
    if (this.#updateTimeout) {
      clearTimeout(this.#updateTimeout);
    }
    
    this.#updateTimeout = setTimeout(() => {
      this.render();
    }, 16); // ~60fps
  }
}

3. CSS Containment

:host {
  contain: layout style;
}

contain teilt dem Browser mit, dass das Element isoliert ist – ermöglicht Rendering-Optimierungen.

Testing

Einfache Tests mit Playwright

// tests/product-card.spec.js
import { test, expect } from '@playwright/test';

test.describe('ProductCard', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('/');
  });

  test('renders product information', async ({ page }) => {
    const card = page.locator('product-card').first();
    
    await expect(card).toBeVisible();
    
    // Shadow DOM durchsuchen
    const name = card.locator('>>> h3');
    await expect(name).toHaveText('Astro T-Shirt');
  });

  test('dispatches add-to-cart event', async ({ page }) => {
    const card = page.locator('product-card').first();
    const button = card.locator('>>> .add-to-cart');
    
    // Event-Listener hinzufügen
    await page.evaluate(() => {
      window.cartEvents = [];
      document.addEventListener('add-to-cart', (e) => {
        window.cartEvents.push(e.detail);
      });
    });
    
    await button.click();
    
    const events = await page.evaluate(() => window.cartEvents);
    expect(events).toHaveLength(1);
    expect(events[0].name).toBe('Astro T-Shirt');
  });

  test('updates cart badge on add', async ({ page }) => {
    const card = page.locator('product-card').first();
    const cart = page.locator('shopping-cart');
    const button = card.locator('>>> .add-to-cart');
    
    await button.click();
    
    const badge = cart.locator('>>> .badge');
    await expect(badge).toHaveText('1');
  });
});

Zusammenfassung

Wir haben gebaut:

  1. ProductCard – Mit Shadow DOM, Events, XSS-Schutz
  2. ShoppingCart – Mit State-Management und Dropdown
  3. FilterBar – Mit Event-basierter Kommunikation

Die wichtigsten Erkenntnisse:

  • Shadow DOM kapselt Styles zuverlässig
  • Custom Events mit composed: true durchbrechen Shadow DOM
  • Template-Caching verbessert Performance
  • CSS Custom Properties ermöglichen externes Theming
  • Astro ist der ideale Host für Web Components

Repository

Der vollständige Code zu diesem Artikel findet sich im Repository:

GitHub: github.com/casoon/astro-webcomponents-tutorial

astro-webcomponents-tutorial/ ├── part1/ # Vanilla Web Components (dieser Artikel) └── part2/ # Atlas Components (Teil 2)

Ressourcen