Vom ersten Custom Element bis zur produktionsreifen Komponente – mit vollständigem Code zum Nachbauen
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:
- Kein Shadow DOM – Styles sind nicht gekapselt
- Keine Events – Der Button tut nichts
- 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 <script>....
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 aufdocumentgefangen werdencomposed: 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;
}
Dropdown mit Click-Outside-Handling
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:
- ProductCard – Mit Shadow DOM, Events, XSS-Schutz
- ShoppingCart – Mit State-Management und Dropdown
- FilterBar – Mit Event-basierter Kommunikation
Die wichtigsten Erkenntnisse:
- Shadow DOM kapselt Styles zuverlässig
- Custom Events mit
composed: truedurchbrechen 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
Ressourcen
- MDN: Using Custom Elements
- MDN: Using Shadow DOM
- Lit – Library für einfachere Web Components Entwicklung
- Open Web Components – Empfehlungen und Tools