Třetí díl seriálu je o Alpine.js: jak ho v Symfony strukturovat, jak žije vedle htmx a kde doplní situace z prvního a druhého dílu.
Principy
Alpine.js drží stav v prohlížeči nad HTML, které vykreslil server. Nevykresluje stránku z dat a nepotřebuje API. Rozdělení s htmx je jednoduché:
- Data a rozhodnutí patří serveru, do prohlížeče je přenáší htmx jako HTML.
- Stav, o který server nestojí, drží Alpine: otevřené menu, zvolená záložka, maska pole, výpočet před odesláním, stav načítání.
- Co umí prohlížeč sám, nechte jemu:
<details>pro rozbalení,<dialog>pro modální okno. Alpine jen přepíná jejich stav.
Direktivy, magic properties a pluginy jen vyjmenujeme, popis je v dokumentaci Alpine.js:
- direktivy:
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 - oficiální pluginy: Mask, Intersect, Resize, Persist, Focus, Collapse, Anchor, Sort, Morph
- rozšíření:
Alpine.data(),Alpine.store(),Alpine.directive(),Alpine.magic()
Prvky skryté přes x-show dostanou x-cloak s pravidlem [x-cloak] { display: none !important; }, jinak před startem Alpine bliknou.
Start vedle 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'
// Sem patří Alpine.data() a Alpine.store(), musí existovat dřív, než Alpine projde DOM.
window.Alpine = Alpine
Alpine.start()
window.Alpine = Alpine není jen pro ladění. hx-alpine-compat hledá Alpine právě na window, bez tohoto řádku by tiše nedělala nic.
Extension hx-alpine-compat je při morphu povinná. Bez ní morph přepíše DOM pod Alpine: x-text zůstane prázdné a rozepsaná hodnota v x-model se ztratí. S ní stav Alpine morph přežije.
Strukturování kódu
Stav má tři úrovně a přechod na vyšší má mít důvod:
x-datav šabloně pro pár hodnot u jednoho prvku:x-data="{ open: false }".Alpine.data()v souboru, jakmile má komponenta metody nebo víc než dva řádky logiky. Testuje se a čte líp než kód v atributu.Alpine.store()jen pro stav, který je opravdu společný celé stránce. Typicky stačí jediný: které modální okno je otevřené.
Každou komponentu dejte do vlastního souboru, který ji zaregistruje, a ve vstupním souboru jen zavolejte registraci před Alpine.start():
// 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">Menu</button>
</nav>
Data z Twigu
Hodnoty ze serveru nevkládejte do výrazu x-data, rozbije se na první uvozovce. Předejte je přes data-*, Twig je při autoescapingu bezpečně zakóduje:
<div x-data="priceForm" data-plans="{{ plans|json_encode }}">
Alpine.data('priceForm', () => ({
plans: [],
init() {
this.plans = JSON.parse(this.$el.dataset.plans)
},
}))
Na co si dát pozor
$elv metodě není kořen komponenty. Metoda volaná zx-on:clickna tlačítku dostane$eltoho tlačítka a hledala by jen v něm. Kořen jethis.$root,$elje kořenem jen vinit().- Custom property v
x-bind:stylepište jako řetězec. U objektu si Alpine před změnou pamatuje předchozí hodnotu přesel.style['--x'], což je u custom properties vždyundefined. Po návratu by na prvku zůstalo--x: undefined. - Události pište
x-on:click, ne@click. Důvod je u soužití s htmx níže. $focus.next()z pluginu Focus hledá uvnitř prvku, na kterém je výraz. Na samotném tlačítku nic neudělá, patří na obal.
Twig komponenty
- Rozhodování a výpočty patří do PHP třídy komponenty, šablona jen vykresluje. Alpine dostane hotové hodnoty přes
data-*. - Vzhled nese ARIA, Alpine jen stav. Tlačítko má
x-bind:aria-expanded="open"a styl se řídí atributem (v Tailwindu variantaaria-expanded:). Přístupnost a vzhled tak nemohou rozejít. - Výchozí stav vykreslí server. Filtr, záložka nebo rozbalení mají správnou podobu i bez JavaScriptu, Alpine ho jen přepíná. Odkaz zůstane odkazem s parametrem v URL.
Skrytý obsah volte podle toho, co je na něm drahé:
- Drahý na získání na serveru (dotazy, výpočty, volání API) a zároveň nekritický pro SEO, například historie objednávek nebo graf vývoje ceny ve sbaleném oddílu: načtěte ho až při rozbalení přes htmx, viz lazy loading v dílu 1.
x-iftu nepomůže, obsah uvnitř<template>server vykreslí při každém zobrazení stránky. - Drahý jen pro prohlížeč (velká inline SVG, tisíce prvků): vložte ho přes
x-if, nex-show. Prvky skryté přesx-showzůstávají v DOM a zatěžují každý přepočet stylů, i když je nikdo nevidí. Pozor na morph, viz níže. - Důležitý pro SEO: nechte ho v HTML, třeba ve sbaleném
<details>. Co se načítá až při rozbalení, robot neuvidí.
Soužití s htmx
x-on: místo @
Morph kopíruje atributy z nové stránky přes setAttribute(). WebKit odmítne název @click, přestože ho parser HTML přijme. Morph pak skončí v půlce a na stránce zůstane směs nového a starého obsahu. Bez View Transitions se v konzoli objeví hláška o neplatném názvu, s nimi htmx chybu spolkne a nevidíte nic. V Chromu ani Firefoxu se chyba neprojeví.
Chyba se ukáže jen u přechodů, při kterých morph narazí na prvek s @ v novém obsahu. Typicky tedy na některých stránkách, zatímco jiné fungují, což svádí hledat příčinu v obsahu stránky. Řešením je psát všude x-on:. x-bind: a zkratka : problém nemají.
Události htmx v Alpine
<nav x-data="menu" x-on:htmx:before:swap.window="open = false; section = null">
Události htmx mají tvar htmx:fáze:akce. Pomlčkový tvar htmx:before-swap neexistuje a listener na něm se tiše nikdy nespustí.
Co přežije morph
Stav Alpine přežije morph, a to je výhoda i past.
Menu, nabídky a jiné otevřené prvky zůstanou po přechodu otevřené. Zavřete je v htmx:before:swap a vynulujte i podřízený stav, jinak se po dalším otevření menu ukáže rovnou v podmenu. Třídu na <body> (zamčený posun pod menu) nastavujte přes $watch s document.body?.classList.
Obsah z x-if a x-for morph smaže. Server posílá jen <template>, morph podle něj vykreslené prvky odstraní a Alpine je nevrátí, dokud se stav nezmění. Typicky průvodce, ve kterém návštěvník volí svou situaci a vedle se přes x-if objeví ilustrace: po kliknutí na logo nebo přepnutí jazyka zůstane volba zvýrazněná a místo ilustrace prázdné místo, bez chyby v konzoli. Řešení jsou dvě:
hx-morph-skip-childrenna obalu šablony. Stav i obsah zůstanou, ale server obsah obalu už neobnoví.- Vrátit komponentu při swapu na začátek, jen když swap komponentu obsahuje:
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)">
Stejná vnitřní id pod různými kontejnery (viz díl 1) přenesou do nové stránky i staré uzly komponenty. Výsledkem je ReferenceError a prázdné x-text.
Cizí knihovny
Widget, který si DOM spravuje sám (mapa, editor), morph smaže, protože podle serveru v něm nic není. Na kořen widgetu dejte hx-morph-skip (zmrazí prvek i atributy), nebo hx-morph-skip-children (aktualizuje atributy kořene, potomky nechá).
Grafy je jednodušší zrušit a vytvořit znovu. Morph může <canvas> zachovat a nad novými daty by zůstala stará instance:
document.addEventListener('htmx:before:swap', destroyCharts)
htmx.onLoad(() => initCharts())
initCharts() zjišťuje existující instanci přes knihovnu, ne přes atribut, který by morph smazal.
hx-live
Extension hx-live přidává reaktivní výrazy do htmx, podobně jako Alpine. Když jsou na stránce obě, zkrácené :text patří Alpine a hx-live ho nevyhodnotí, jen vypíše warning. Pro hx-live vedle Alpine pište hx-live:text. Na jednom webu se ale držte jedné z nich.
Kde Alpine doplní htmx
Lazy loading cizí služby až na souhlas. Mapa od třetí strany se nemá načíst sama, protože by poslala data návštěvníka. Alpine ukáže překryv s informací, komu se data pošlou, a po kliknutí vyšle událost, na kterou skript mapu teprve stáhne:
<div x-data="{ shown: false }">
<div x-show="!shown">
<p>Mapu poskytuje Google.</p>
<button type="button" x-on:click="shown = true; $dispatch('map-requested')">Zobrazit mapu</button>
</div>
<div data-map hx-morph-skip></div>
</div>
Zpětná vazba ve formuláři před odesláním. Maska pole (x-mask="99/99/9999", $money), počet znaků, pole zobrazená podle volby (x-show podle x-model). Validace dál běží na serveru a vrací 422 (díl 2), Alpine jen šetří zbytečné odeslání.
Výpočet před odesláním. Cena podle zvolených možností se spočítá hned. Server ji při uložení spočítá znovu, prohlížeči nevěří.
Stažení souboru se skutečným průběhem. Místo odhadu času stáhne Alpine soubor přes fetch, tlačítko ukazuje stav, dokud odpověď nedorazí, a soubor uloží přes dočasný odkaz s download. Název vezme z Content-Disposition.
Filtr bez requestu. Pro malý seznam, který už na stránce je. Výchozí stav vykreslí server podle parametru v URL, chipy jsou odkazy a Alpine přepíná stav bez načtení stránky. Kanonická adresa je výpis bez parametru.
Událost ze serveru. Po přidání do košíku stačí, aby server poslal počet položek hlavičkou HX-Trigger, a Alpine ho přečte z události:
$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>
Bez dat (HX-Trigger: cart-updated) událost přijde s prázdným detail. Když se mění HTML, je přímočařejší hx-swap-oob z dílu 2.
Modální okna
Uživatel čeká, že Zpět zavře otevřené okno. htmx by ale místo toho znovu stáhlo stránku. A protože stav Alpine přežije morph, okno se stavem ve vlastním x-data by zůstalo otevřené i na další stránce. Proto:
- Které okno je otevřené, drží jeden store. Najednou je otevřené nejvýš jedno a s historií je provázané jediné místo.
- Okno je nativní
<dialog>. Modální vrstvu, Escape i focus trap dává prohlížeč, store se do něj jen promítá.
<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 i provázání s historií jsou v samostatném souboru, který se registruje stejně jako komponenty, tedy registerModal(Alpine) před 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')
// Zpět z položky okna jen zavře okno, stránka se znovu nestahuje.
document.addEventListener('htmx:before:history:restore', (event) => {
const isLeavingEntry = entryPath === event.detail.path
entryPath = null
modal.current = null
if (isLeavingEntry) event.preventDefault()
})
// Vpřed okno znovu otevře.
window.addEventListener('popstate', (event) => {
if (!event.state?.modal) return
entryPath = currentPath()
modal.current = event.state.modal
})
// Odkaz z okna: nová stránka položku okna nahradí, jinak by Zpět vrátil jen adresu.
document.addEventListener('htmx:before:history:update', (event) => {
if (entryPath !== null) event.detail.history.type = 'replace'
entryPath = null
modal.current = null
})
}
- Fokus po zavření vraťte na prvek, který ho měl před otevřením, ale až po kroku v historii. Navigation API ho po kroku přesune na
<body>. Počkejte nanavigatesuccess, ve Firefoxu napopstate. - Modul pro scroll restoration z dílu 1 musí položky se stejnou adresou přeskočit, jinak by čekající posun skočil až při dalším swapu.
Formuláře v okně
- Formulář vykreslete rovnou do stránky (
{% include %}v dialogu), nenačítejte ho lazy. Odpadnou potíže s časováním, se zpracováním skrytých prvků a s$refs. Cenou je, že CSRF token na dlouho otevřené stránce může expirovat, proto pro ten případ nastavte srozumitelnou hlášku přescsrf_messagea přeložte ji v doméněvalidators. - Odeslání swapuje jen obsah okna:
hx-postshx-targetna obsah dialogu ahx-statusz dílu 2 (422 swapnout, ostatní4xxa5xxne). - Po úspěchu buď přejděte jinam přes
HX-LocationneboHX-Redirect(přechod okno zavře), nebo okno zavřete událostí ze serveru a stránku aktualizujte přeshx-swap-oob:
$response->headers->set('HX-Trigger', 'modal-close');
<body x-data x-on:modal-close.window="$store.modal.close()">
Testování
Chyby z tohoto dílu se projeví až po přechodu mezi stránkami a často jen v jednom prohlížeči, ruční proklikání je nenajde.
- Přechody mezi všemi stránkami. Skript projde dvojice stránek, přejde z jedné na druhou přes htmx a porovná hlavní obsah s čerstvým načtením cílové stránky.
- I ve WebKitu. Na Linuxu je nejlevnější
WebKitWebDriversMiniBrowserz balíčků distribuce a jednoduchý klient protokolu WebDriver. - S View Transitions i bez nich. S nimi se chyby polykají, bez nich se aspoň objeví v konzoli.
- Stav po přechodu na stejnou stránku (logo, přepnutí jazyka): otevřené menu, vybraná volba, obsah z
x-if.
Kontrolní seznam
hx-alpine-compatnačtené, komponenty a store registrované předAlpine.start()- Události jen
x-on:, žádné@ - Logika v
Alpine.data()nebo v PHP, data z Twigu přesdata-* - Vzhled přes
aria-*, výchozí stav vykreslený serverem,x-cloaku skrytých prvků - Listenery htmx ve tvaru
htmx:fáze:akce - Menu a podřízený stav se při
htmx:before:swapzavřou a vynulují x-ifax-forchráněnéhx-morph-skip-children, nebo komponenta při swapu začne znovu- Cizí widgety pod
hx-morph-skip, grafy zrušené a vytvořené znovu - Modální okna jen přes jeden store a
<dialog>, Zpět okno zavře - Formuláře v okně vykreslené do stránky, srozumitelná hláška při expirovaném CSRF
- Přechody otestované ve WebKitu, s View Transitions i bez nich