Der dritte Teil der Serie dreht sich um Alpine.js: wie man es in Symfony strukturiert, wie es neben htmx lebt und wo es Situationen aus dem ersten und zweiten Teil ergänzt.
Prinzipien
Alpine.js hält State im Browser über HTML, das der Server gerendert hat. Es rendert die Seite nicht aus Daten und braucht keine API. Die Aufteilung mit htmx ist einfach:
- Daten und Entscheidungen gehören dem Server, in den Browser bringt sie htmx als HTML.
- State, der den Server nicht interessiert, hält Alpine: ein offenes Menü, der gewählte Tab, eine Feldmaske, eine Berechnung vor dem Absenden, ein Ladezustand.
- Was der Browser selbst kann, überlassen Sie ihm:
<details>zum Aufklappen,<dialog>für ein modales Fenster. Alpine schaltet nur ihren Zustand um.
Direktiven, Magic Properties und Plugins zählen wir nur auf, beschrieben sind sie in der Dokumentation von Alpine.js:
- Direktiven:
x-data,x-init,x-show,x-if,x-for,x-bind,x-on,x-model,x-modelable,x-text,x-html,x-effect,x-ref,x-cloak,x-transition,x-teleport,x-id,x-ignore - Magic Properties:
$el,$root,$refs,$store,$watch,$dispatch,$nextTick,$id,$data - offizielle Plugins: Mask, Intersect, Resize, Persist, Focus, Collapse, Anchor, Sort, Morph
- Erweiterungen:
Alpine.data(),Alpine.store(),Alpine.directive(),Alpine.magic()
Per x-show versteckte Elemente bekommen x-cloak mit der Regel [x-cloak] { display: none !important; }, sonst blitzen sie vor dem Start von Alpine auf.
Start neben htmx
import htmx from 'htmx.org'
import 'htmx.org/dist/ext/hx-preload.js'
import 'htmx.org/dist/ext/hx-alpine-compat.js'
import 'htmx.org/dist/ext/hx-head.js'
import Alpine from 'alpinejs'
// Hierher gehören Alpine.data() und Alpine.store(), sie müssen existieren, bevor Alpine den DOM durchgeht.
window.Alpine = Alpine
Alpine.start()
window.Alpine = Alpine ist nicht nur zum Debuggen da. hx-alpine-compat sucht Alpine genau auf window, ohne diese Zeile würde die Extension stillschweigend nichts tun.
Die Extension hx-alpine-compat ist beim Morph Pflicht. Ohne sie überschreibt Morph den DOM unter Alpine: x-text bleibt leer und ein angefangener Wert in x-model geht verloren. Mit ihr übersteht der State von Alpine den Morph.
Strukturierung des Codes
State hat drei Ebenen, und der Wechsel auf eine höhere sollte einen Grund haben:
x-dataim Template für ein paar Werte an einem Element:x-data="{ open: false }".Alpine.data()in einer Datei, sobald die Komponente Methoden oder mehr als zwei Zeilen Logik hat. Das lässt sich besser testen und lesen als Code im Attribut.Alpine.store()nur für State, der wirklich der ganzen Seite gemeinsam ist. Typischerweise reicht ein einziger: welches modale Fenster offen ist.
Jede Komponente kommt in eine eigene Datei, die sie registriert, und in der Einstiegsdatei rufen Sie nur die Registrierung vor Alpine.start() auf:
// assets/js/menu.js
export default function registerMenu(Alpine) {
Alpine.data('menu', () => ({
open: false,
section: null,
toggle() {
this.open = !this.open
},
}))
}
// assets/app.js
import registerMenu from './js/menu.js'
registerMenu(Alpine)
window.Alpine = Alpine
Alpine.start()
<nav x-data="menu">
<button type="button" x-on:click="toggle()" x-bind:aria-expanded="open">Menü</button>
</nav>
Daten aus Twig
Werte vom Server fügen Sie nicht in den Ausdruck von x-data ein, er bricht am ersten Anführungszeichen. Übergeben Sie sie über data-*, Twig kodiert sie beim Autoescaping sicher:
<div x-data="priceForm" data-plans="{{ plans|json_encode }}">
Alpine.data('priceForm', () => ({
plans: [],
init() {
this.plans = JSON.parse(this.$el.dataset.plans)
},
}))
Worauf Sie achten sollten
$elin einer Methode ist nicht die Wurzel der Komponente. Eine Methode, die perx-on:clickan einem Button aufgerufen wird, bekommt$eldieses Buttons und würde nur darin suchen. Die Wurzel istthis.$root,$elist nur ininit()die Wurzel.- Custom Properties in
x-bind:styleschreiben Sie als String. Bei einem Objekt merkt sich Alpine vor der Änderung den vorherigen Wert überel.style['--x'], was bei Custom Properties immerundefinedist. Nach der Rückkehr bliebe am Element--x: undefinedstehen. - Events schreiben Sie als
x-on:click, nicht@click. Der Grund steht unten beim Zusammenspiel mit htmx. $focus.next()aus dem Plugin Focus sucht innerhalb des Elements, an dem der Ausdruck steht. Am Button selbst tut es nichts, es gehört an den Wrapper.
Twig-Komponenten
- Entscheidungen und Berechnungen gehören in die PHP-Klasse der Komponente, das Template rendert nur. Alpine bekommt fertige Werte über
data-*. - Das Aussehen trägt ARIA, Alpine nur den State. Der Button hat
x-bind:aria-expanded="open"und der Stil richtet sich nach dem Attribut (in Tailwind die Variantearia-expanded:). Barrierefreiheit und Aussehen können so nicht auseinanderlaufen. - Den Ausgangszustand rendert der Server. Filter, Tab oder Aufklappen haben auch ohne JavaScript die richtige Form, Alpine schaltet nur um. Ein Link bleibt ein Link mit Parameter in der URL.
Versteckten Inhalt wählen Sie danach, was an ihm teuer ist:
- Teuer in der Beschaffung auf dem Server (Abfragen, Berechnungen, API-Aufrufe) und zugleich nicht kritisch für SEO, zum Beispiel die Bestellhistorie oder ein Preisverlaufsdiagramm in einem eingeklappten Abschnitt: laden Sie ihn erst beim Aufklappen per htmx, siehe Lazy Loading in Teil 1.
x-ifhilft hier nicht, den Inhalt im<template>rendert der Server bei jedem Aufruf der Seite. - Teuer nur für den Browser (große Inline-SVGs, Tausende Elemente): fügen Sie ihn per
x-ifein, nicht perx-show. Perx-showversteckte Elemente bleiben im DOM und belasten jede Neuberechnung der Styles, auch wenn sie niemand sieht. Vorsicht beim Morph, siehe unten. - Wichtig für SEO: lassen Sie ihn im HTML, etwa in einem eingeklappten
<details>. Was erst beim Aufklappen geladen wird, sieht der Crawler nicht.
Zusammenspiel mit htmx
x-on: statt @
Morph kopiert Attribute aus der neuen Seite per setAttribute(). WebKit lehnt den Namen @click ab, obwohl der HTML-Parser ihn akzeptiert. Morph bricht dann mittendrin ab, und auf der Seite bleibt eine Mischung aus neuem und altem Inhalt. Ohne View Transitions erscheint in der Konsole eine Meldung über einen ungültigen Namen, mit ihnen verschluckt htmx den Fehler und Sie sehen nichts. In Chrome und Firefox tritt der Fehler nicht auf.
Der Fehler zeigt sich nur bei Wechseln, bei denen Morph im neuen Inhalt auf ein Element mit @ stößt. Typischerweise also auf manchen Seiten, während andere funktionieren, was dazu verleitet, die Ursache im Inhalt der Seite zu suchen. Die Lösung ist, überall x-on: zu schreiben. x-bind: und die Kurzform : haben das Problem nicht.
htmx-Events in Alpine
<nav x-data="menu" x-on:htmx:before:swap.window="open = false; section = null">
htmx-Events haben die Form htmx:phase:aktion. Die Form mit Bindestrich htmx:before-swap existiert nicht, und ein Listener darauf wird stillschweigend nie ausgelöst.
Was den Morph übersteht
Der State von Alpine übersteht den Morph, und das ist Vorteil und Falle zugleich.
Menüs, Dropdowns und andere geöffnete Elemente bleiben nach dem Wechsel offen. Schließen Sie sie in htmx:before:swap und setzen Sie auch den untergeordneten State zurück, sonst zeigt sich das Menü beim nächsten Öffnen direkt im Untermenü. Die Klasse am <body> (gesperrtes Scrollen unter dem Menü) setzen Sie per $watch mit document.body?.classList.
Inhalt aus x-if und x-for löscht Morph. Der Server schickt nur <template>, Morph entfernt danach die gerenderten Elemente und Alpine bringt sie nicht zurück, bis sich der State ändert. Typisch ist ein Assistent, in dem der Besucher seine Situation wählt und daneben per x-if eine Illustration erscheint: Nach einem Klick auf das Logo oder einem Sprachwechsel bleibt die Auswahl hervorgehoben und statt der Illustration eine leere Fläche, ohne Fehler in der Konsole. Es gibt zwei Lösungen:
hx-morph-skip-childrenam Wrapper des Templates. State und Inhalt bleiben, aber der Server aktualisiert den Inhalt des Wrappers nicht mehr.- Die Komponente beim Swap auf den Anfang zurücksetzen, nur wenn der Swap die Komponente enthält:
resetOnSwap(event) {
const isSwapped = event.detail.tasks?.some((task) => task.target?.contains(this.$root))
if (!isSwapped) return
this.selected = null
}
<section x-data="guide" x-on:htmx:before:swap.window="resetOnSwap($event)">
Gleiche innere id unter verschiedenen Containern (siehe Teil 1) übertragen auch die alten Knoten der Komponente in die neue Seite. Das Ergebnis ist ein ReferenceError und ein leeres x-text.
Fremde Bibliotheken
Ein Widget, das seinen DOM selbst verwaltet (Karte, Editor), löscht Morph, weil laut Server nichts darin ist. An die Wurzel des Widgets setzen Sie hx-morph-skip (friert Element und Attribute ein) oder hx-morph-skip-children (aktualisiert die Attribute der Wurzel, lässt die Kindelemente).
Diagramme lassen sich einfacher abbauen und neu erstellen. Morph kann das <canvas> behalten, und über den neuen Daten bliebe die alte Instanz:
document.addEventListener('htmx:before:swap', destroyCharts)
htmx.onLoad(() => initCharts())
initCharts() ermittelt eine bestehende Instanz über die Bibliothek, nicht über ein Attribut, das Morph löschen würde.
hx-live
Die Extension hx-live bringt reaktive Ausdrücke in htmx, ähnlich wie Alpine. Sind beide auf der Seite, gehört die Kurzform :text Alpine, und hx-live wertet sie nicht aus, sondern gibt nur eine Warning aus. Für hx-live neben Alpine schreiben Sie hx-live:text. Auf einer Website sollten Sie sich aber an eine der beiden halten.
Wo Alpine htmx ergänzt
Lazy Loading eines fremden Dienstes erst nach Zustimmung. Eine Karte eines Drittanbieters soll nicht von selbst laden, weil sie Daten des Besuchers senden würde. Alpine zeigt eine Überlagerung mit dem Hinweis, an wen die Daten gehen, und löst nach dem Klick ein Event aus, auf das ein Skript die Karte erst lädt:
<div x-data="{ shown: false }">
<div x-show="!shown">
<p>Die Karte stellt Google bereit.</p>
<button type="button" x-on:click="shown = true; $dispatch('map-requested')">Karte anzeigen</button>
</div>
<div data-map hx-morph-skip></div>
</div>
Rückmeldung im Formular vor dem Absenden. Feldmaske (x-mask="99/99/9999", $money), Zeichenzähler, Felder abhängig von einer Auswahl (x-show nach x-model). Die Validierung läuft weiter auf dem Server und liefert 422 (Teil 2), Alpine spart nur unnötiges Absenden.
Berechnung vor dem Absenden. Der Preis nach den gewählten Optionen wird sofort berechnet. Der Server berechnet ihn beim Speichern erneut, dem Browser vertraut er nicht.
Download einer Datei mit echtem Fortschritt. Statt einer geschätzten Zeit lädt Alpine die Datei per fetch, der Button zeigt den Zustand, bis die Antwort da ist, und speichert die Datei über einen temporären Link mit download. Den Namen nimmt es aus Content-Disposition.
Filter ohne Request. Für eine kleine Liste, die schon auf der Seite steht. Den Ausgangszustand rendert der Server nach dem Parameter in der URL, die Chips sind Links und Alpine schaltet den State ohne Laden der Seite um. Die kanonische Adresse ist die Liste ohne Parameter.
Event vom Server. Nach dem Hinzufügen zum Warenkorb genügt es, dass der Server die Anzahl der Artikel im Header HX-Trigger sendet, und Alpine liest sie aus dem Event:
$response->headers->set('HX-Trigger', json_encode(['cart-updated' => ['count' => $cart->count()]]));
<span x-data="{ count: {{ cart.count }} }"
x-on:cart-updated.window="count = $event.detail.count"
x-text="count">{{ cart.count }}</span>
Ohne Daten (HX-Trigger: cart-updated) kommt das Event mit leerem detail. Wenn sich HTML ändert, ist hx-swap-oob aus Teil 2 direkter.
Modale Fenster
Der Nutzer erwartet, dass Zurück ein offenes Fenster schließt. htmx würde stattdessen aber die Seite neu laden. Und weil der State von Alpine den Morph übersteht, bliebe ein Fenster mit State im eigenen x-data auch auf der nächsten Seite offen. Deshalb:
- Welches Fenster offen ist, hält ein Store. Es ist höchstens eines gleichzeitig offen, und mit der History ist eine einzige Stelle verknüpft.
- Das Fenster ist ein natives
<dialog>. Modale Ebene, Escape und Focus Trap liefert der Browser, der Store wird nur darauf abgebildet.
<button type="button" x-on:click="$store.modal.open('client')">Detail</button>
<dialog x-effect="$store.modal.isOpen('client') ? ($el.open || $el.showModal()) : $el.close()"
x-on:close="$store.modal.close()">…</dialog>
Store und Verknüpfung mit der History stehen in einer eigenen Datei, die genauso registriert wird wie Komponenten, also registerModal(Alpine) vor Alpine.start():
export default function registerModal(Alpine) {
let entryPath = null
const currentPath = () => location.pathname + location.search
Alpine.store('modal', {
current: null,
open(name) {
if (entryPath === null) {
history.pushState({ modal: name }, '')
entryPath = currentPath()
} else {
history.replaceState({ modal: name }, '')
}
this.current = name
},
close() {
if (this.current === null) return
this.current = null
if (entryPath !== null) history.back()
},
isOpen(name) {
return this.current === name
},
})
const modal = Alpine.store('modal')
// Zurück vom Eintrag des Fensters schließt nur das Fenster, die Seite wird nicht neu geladen.
document.addEventListener('htmx:before:history:restore', (event) => {
const isLeavingEntry = entryPath === event.detail.path
entryPath = null
modal.current = null
if (isLeavingEntry) event.preventDefault()
})
// Vorwärts öffnet das Fenster wieder.
window.addEventListener('popstate', (event) => {
if (!event.state?.modal) return
entryPath = currentPath()
modal.current = event.state.modal
})
// Link aus dem Fenster: die neue Seite ersetzt den Eintrag des Fensters, sonst würde Zurück nur die Adresse zurückbringen.
document.addEventListener('htmx:before:history:update', (event) => {
if (entryPath !== null) event.detail.history.type = 'replace'
entryPath = null
modal.current = null
})
}
- Den Fokus nach dem Schließen geben Sie dem Element zurück, das ihn vor dem Öffnen hatte, aber erst nach dem Schritt in der History. Die Navigation API verschiebt ihn nach dem Schritt auf
<body>. Warten Sie aufnavigatesuccess, in Firefox aufpopstate. - Das Modul für Scroll Restoration aus Teil 1 muss Einträge mit derselben Adresse überspringen, sonst würde eine ausstehende Scrollposition erst beim nächsten Swap springen.
Formulare im Fenster
- Rendern Sie das Formular direkt in die Seite (
{% include %}im Dialog), laden Sie es nicht lazy. Probleme mit dem Timing, der Verarbeitung versteckter Elemente und mit$refsentfallen. Der Preis ist, dass das CSRF-Token auf einer lange geöffneten Seite ablaufen kann, setzen Sie deshalb für diesen Fall eine verständliche Meldung übercsrf_messageund übersetzen Sie sie in der Domainvalidators. - Das Absenden swappt nur den Inhalt des Fensters:
hx-postmithx-targetauf den Inhalt des Dialogs undhx-statusaus Teil 2 (422 swappen, die übrigen4xxund5xxnicht). - Nach Erfolg wechseln Sie entweder per
HX-LocationoderHX-Redirectwoandershin (der Wechsel schließt das Fenster), oder Sie schließen das Fenster mit einem Event vom Server und aktualisieren die Seite perhx-swap-oob:
$response->headers->set('HX-Trigger', 'modal-close');
<body x-data x-on:modal-close.window="$store.modal.close()">
Testen
Fehler aus diesem Teil zeigen sich erst nach einem Wechsel zwischen Seiten und oft nur in einem Browser, manuelles Durchklicken findet sie nicht.
- Wechsel zwischen allen Seiten. Ein Skript geht Seitenpaare durch, wechselt per htmx von der einen auf die andere und vergleicht den Hauptinhalt mit einem frischen Laden der Zielseite.
- Auch in WebKit. Unter Linux ist am günstigsten
WebKitWebDrivermitMiniBrowseraus den Paketen der Distribution und ein einfacher Client für das WebDriver-Protokoll. - Mit und ohne View Transitions. Mit ihnen werden Fehler verschluckt, ohne sie erscheinen sie wenigstens in der Konsole.
- State nach einem Wechsel auf dieselbe Seite (Logo, Sprachwechsel): offenes Menü, gewählte Option, Inhalt aus
x-if.
Checkliste
hx-alpine-compatgeladen, Komponenten und Store vorAlpine.start()registriert- Events nur
x-on:, kein@ - Logik in
Alpine.data()oder in PHP, Daten aus Twig überdata-* - Aussehen über
aria-*, Ausgangszustand vom Server gerendert,x-cloakan versteckten Elementen - htmx-Listener in der Form
htmx:phase:aktion - Menü und untergeordneter State werden bei
htmx:before:swapgeschlossen und zurückgesetzt x-ifundx-fordurchhx-morph-skip-childrengeschützt, oder die Komponente beginnt beim Swap neu- Fremde Widgets unter
hx-morph-skip, Diagramme abgebaut und neu erstellt - Modale Fenster nur über einen Store und
<dialog>, Zurück schließt das Fenster - Formulare im Fenster in die Seite gerendert, verständliche Meldung bei abgelaufenem CSRF
- Wechsel in WebKit getestet, mit und ohne View Transitions