Tento návod shrnuje, co jsme se naučili při stavbě několika projektů na Symfony a htmx, od obsahových webů po aplikaci s účty, nákupy a platbami. Chyby v něm nejsou teoretické: většinu z nich jsme opravdu udělali nebo je zachytila kontrola kódu.
Návod platí pro htmx 4 a Symfony 8. Spojení s klientskými knihovnami, jako je Alpine.js, popíšeme v samostatném článku.
Principy htmx
Server vrací hotové HTML a htmx ho vkládá do stránky. Není potřeba API pro frontend ani druhá vrstva stavu a validace. Šablony, formuláře a zabezpečení zůstávají v Symfony.
Každá interakce projde stejným cyklem: spouštěč → požadavek → odpověď → výběr části odpovědi → výměna v DOM → dokončení → zápis do historie. Atributy určují, co se v jednotlivých krocích stane. Jejich přehled spolu se způsoby výměny, hlavičkami a událostmi je na konci článku.
Pravidla, která je dobré znát předem
- Dědičnost je explicitní. Potomkům se předá jen atribut se sufixem
:inherited. Bez něj se odkazy neboostují a žádná chyba se neobjeví. - Rozšíření se aktivují importem. Žádný atribut na ně není potřeba.
- Události mají tvar
htmx:faze:akce, napříkladhtmx:before:swap. Posluchač na překlepnutém názvu se tiše nespustí. - Vkládá se všechno kromě 204 a 304, včetně chybových stránek.
- Historie neukládá kopie stránek. Krok zpět stránku znovu stáhne ze serveru.
- Morph je přímo v jádře. Nepotřebuje žádné rozšíření.
Instalace
npm install htmx.org@4
Vstupní soubor funguje stejně s Vite přes Symfony Reprise i s Webpack Encore:
import htmx from 'htmx.org'
// Rozšíření se registrují na globální htmx, proto až za jádrem.
import 'htmx.org/dist/ext/hx-preload.js'
import 'htmx.org/dist/ext/hx-head.js'
import registerScrollRestore from './js/scroll-restore.js'
htmx.config.transitions = !matchMedia('(prefers-reduced-motion: reduce)').matches
registerScrollRestore()
htmx.initialize()volat nemusíte, htmx se spustí samo až po vyhodnocení modulu. Rozšíření i posluchače proto registrujte hned v modulu, ne později.- Skripty dávejte do
<head>. Morph obsahu<body>by skript na konci stránky bral jako obyčejný prvek.
Navigace a prefetch
Základní nastavení
Pro většinu projektů stačí jedno nastavení na <body>:
<body hx-boost:inherited="true"
hx-swap:inherited="innerMorph"
hx-head:inherited="merge">
Controllery se nemění a dál vracejí celé stránky. Tímto způsobem může běžet i hledání s filtry a stránkováním jako obyčejné GET formuláře a odkazy, bez jediného fragmentu a bez čtení hlaviček na serveru.
hx-swap:inherited se předá i prvkům s vlastním hx-get nebo hx-post. Ty proto mají vždy mít vlastní hx-swap.
Druhou variantu použijte, když ve stránce musí zůstat něco mimo výměnu, například prvky vložené cizím skriptem. Morph celého <body> by je odstranil, protože v odpovědi nejsou.
<head hx-head="merge">
<body hx-boost:inherited="target:'#app', select:'#app', swap:'outerMorph ignoreTitle:true'">
<div id="app" hx-history-elt>
hx-head="merge"je tu nutné. Když cíl není<body>,hx-headhlavičku jen doplňuje a titulky i meta značky se hromadí.- Cíl uveďte selektorem, ne
target:this. hx-targetahx-selectnedávejte na#app, zdědili by je všichni potomci.- Bez
hx-history-eltby krok zpět vyměnil celé<body>.
Co boost převezme a co vyjmout
Boost se týká odkazů a formulářů na stejném originu. Vynechá odkazy s download, s jiným target než _self a odkazy začínající #. Klik s Ctrl, Cmd nebo Shiftem nechává na prohlížeči.
hx-boost="false" dejte na:
- odkazy na administraci nebo jinou část s vlastními assety,
- stránky s úplně jiným rozložením, například přihlášení bez hlavičky a patičky,
- přihlašovací formulář a odkazy s vedlejším účinkem,
- přepínač filtru, který řeší stav v prohlížeči.
Morph mezi nesourodými rozloženími je zbytečné riziko. Plné načtení je jednodušší a spolehlivější.
Morph a identita prvků
Morph páruje prvky podle značky a id. Prvek bez id se sloučí s jakýmkoli prvkem se stejnou značkou na stejném místě. Prvek s id se sloučí jen s prvkem se stejným id, jinak se vloží jako nový a starý zmizí. Proto záleží na tom, jaké id má hlavní kontejner stránky.
- Stejné
iddejte jen stránkám stejného typu, například dvěma detailům článku. Tam sloučení dává smysl a stav zůstane. Stránky s jinou strukturou mají mítidjiné. Jednoduchá náhrada jeidpodle routy, dvě routy se stejnou strukturou tím jen přijdou o slučování. idpatří na hlavní kontejner, ne na<body>.innerMorphslučuje jen potomky, takžeidani třídu z<body>nečte a nemění. Kontejner bezidse chová stejně jako kontejner se společnýmid.- Sloučení přenáší stav mezi nesouvisejícími prvky. Při společném
idkontejneru se text z polesearchobjeví v poliemailna další stránce a heslo se objeví čitelně v textovém poli, protože morph změnítype, ale hodnotu nechá. Obsluha tlačítka „Smazat“ pověšená přímo na prvek se spustí po kliknutí na nové tlačítko „Sdílet“. - Formuláře mají mít vlastní
id. S různýmiidformulářů se hodnota nepřenese ani pod společným kontejnerem. U přihlašovacího formuláře je to povinnost. - Posluchače věšte přes delegaci na
document, nebo je před výměnou odstraňte. - Prvek, který má přechod přežít, má na obou stránkách stejné
id. Přenese se i s hodnotou, a to i pod kontejnery s různýmiid. - Morph maže atributy, které v odpovědi nejsou. Příznak jako
data-map-initializednastavený v prohlížeči zmizí a widget se inicializuje podruhé. Přidejte ho dohtmx.config.morphIgnore(rozšiřte výchozí pole, nepřepisujte ho), nebo stav zjišťujte přes knihovnu, napříkladChart.getChart(canvas). - Morph zachovává hodnoty polí. Na reset formuláře se nehodí, viz oddíl o formulářích.
- Názvy atributů musí projít přes
setAttribute(). Morph jím kopíruje atributy a prohlížeče se starším ověřováním názvů, například ve WebKitu, odmítnou název jako@click, zkratku z Alpine.js. Sloučení skončí v půlce, na stránce zůstane směs nového a starého obsahu a htmx výjimku spolkne, takže v konzoli nic nevidíte. Parser HTML takový atribut přijme, takže v Chromu vše funguje. Použijte plný zápisx-on:click.
Prvek vyjmete atributem hx-morph-skip. hx-morph-skip-children aktualizuje jen jeho atributy.
Hlavička stránky a jazyk
Rozšíření hx-head sloučí <head> podle přesné shody značek. Titulek, popis, canonical i JSON-LD se tak po přechodu vymění.
- Analytické a další jednorázové skripty v hlavičce označte
hx-preserve="true", jinak by se při každém přechodu mazaly a spouštěly znovu. <html lang>se nemění, protože htmx vyměňuje jen obsah<body>. Po přepnutí jazyka zůstane starý jazyk pro čtečky i překladače. Přepínači dejtehx-boost="false", nebolangnastavte z odpovědi:
document.addEventListener('htmx:before:swap', (event) => {
const lang = event.detail.ctx.text?.match(/<html[^>]*\slang="([^"]*)"/i)?.[1]
if (lang) document.documentElement.lang = lang
})
- htmx zapisuje titulek až po výměně. Když máte zapnuté View Transitions, přijde zápis až po doběhnutí přechodu a druhý rychlý klik pak může v záložce nechat titulek první stránky.
ignoreTitle:truevhx-swapnechá titulek jen nahx-head, který ho nastavuje hned. - Styly nenačítejte z líně importovaného JavaScriptu. Vite je vloží do
<head>, další přechod je jako neznámé odstraní a znovu už se nepřidají. Líně načítejte jen JavaScript.
Prefetch
Rozšíření hx-preload stáhne stránku už při mousedown nebo touchstart. Po puštění tlačítka je obsah na cestě nebo hotový. Pro boostované odkazy stačí rozšíření načíst, atribut je potřeba jen pro změnu chování:
<body hx-preload:inherited="mousedown, touchstart">
Prefetch je skutečný požadavek a nepoužívá HTTP cache, takže kvůli němu nemusíte nastavovat Cache-Control. Týká se jen GET. Z toho plyne:
- GET s vedlejším účinkem musí mít
hx-boost="false". Prefetch nerozlišuje tlačítka myši. Odkaz na odhlášení přes GET by člověka odhlásil i pravým klikem nebo začátkem posunu na mobilu. Platí to pro všechno, co GET mění, například vyzvednutí flash zprávy. - Počítadla zobrazení mírně nadhodnocují, protože se započítá i rozmyšlené stisknutí. Prefetch na serveru nefiltrujte: po kliknutí bývá jediným požadavkem, takže by zmizela skoro všechna skutečná zobrazení. Na seznamu, kde gesto posunu často začíná na odkazu, zúžete spouštěče přes
hx-preload:inherited="mousedown". - Neprodlužujte platnost tam, kde je osobní stav. Výchozích 5 sekund je rozumných. Odkaz bez
idsi morph ponechá i se staženou odpovědí, takže delší platnost by po nákupu ukázala starý zůstatek. Na čistě obsahovém webu lze prodloužit, napříkladmousedown timeout:30s. - Spouštění při najetí myší jsme zavrhli. Rozšíření nemá prodlevu, takže přejetí přes menu vyrenderuje všechny stránky za ním. Vlastní prodleva vedla k dvojitým požadavkům a oproti
mousedownpřinesla jen desítky milisekund.
Přechody mezi stránkami
htmx.config.transitions = true zapne View Transitions. Stojí to čas: s výchozí délkou jsme naměřili asi 300 ms na přechod, což na webu s prefetchem působí pomaleji, než web je. Zkraťte všechny tři části, délku celého přechodu určuje skupina:
::view-transition-group(root),
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 100ms;
}
- Při omezeném pohybu přechody vůbec nezapínejte. Samotný snímek stránku na dobu přechodu zmrazí, i kdyby byla animace v CSS vypnutá.
- Stejné
view-transition-namena dvou prvcích přechod zruší. - Nepoužívejte globální
scroll-behavior: smooth. Stránka by se po každém přechodu a kroku zpět plynule odvíjela. Plynulý posun zapínejte jen u konkrétních odkazů.
Chybové stránky
htmx vloží i chybovou odpověď, takže boostovaný odkaz na neexistující stránku ukáže vaši stránku 404.
- Šablony v
templates/bundles/TwigBundle/Exception/musí mít celé rozložení. - V prostředí
devatestse nevykreslí, protože tam běžíkernel.debug. Zkoušejte je na sestavení proprod. - U varianty s
#appby odpověď bez#app(502 z brány při nasazení, fatální chyba PHP) smazala obsah a zůstala by prázdná stránka. Pomůže pojistka, která předá navigaci prohlížeči:
document.addEventListener('htmx:before:swap', (event) => {
const { ctx, tasks } = event.detail
if (ctx.target?.id !== 'app') return
const main = tasks.find((task) => task.type === 'main')
if (main && main.fragment.childElementCount === 0) {
event.preventDefault()
window.location.assign(ctx.request.action)
}
})
Expirované přihlášení
Když přihlášení expiruje, Symfony přesměruje na login a fetch přesměrování sám následuje. Co se stane dál, závisí na požadavku a na rozložení přihlašovací stránky:
- Boostovaný odkaz, login ve stejném rozložení jako zbytek webu: funguje bez úprav. Přihlašovací stránka se sloučí jako každá jiná a do historie se zapíše její adresa.
- Boostovaný odkaz, login s jiným rozložením: morph mezi nesourodými stránkami je zbytečné riziko, u varianty s
#appmůže login navíc tento prvek vůbec nemít. - Fragment s vlastním cílem: přihlašovací stránka se vloží do cíle, ať má jakékoli rozložení.
V posledních dvou případech pomůže, když AuthenticationEntryPoint u htmx vynutí plné načtení:
public function start(Request $request, ?AuthenticationException $authException = null): Response
{
$loginUrl = $this->urlGenerator->generate('app_login');
if ($request->headers->has('HX-Request')) {
return new Response('', Response::HTTP_OK, ['HX-Redirect' => $loginUrl]);
}
return new RedirectResponse($loginUrl);
}
Symfony si před tímto voláním uloží adresu požadavku jako cíl po přihlášení a požadavek htmx nepovažuje za XHR. Z kódu frameworku plyne, že po fragmentu přes GET by se člověk po přihlášení dostal na holý fragment. Pokud máte fragmenty za přihlášením, odstraňte v této větvi uložený cíl přes TargetPathTrait::removeTargetPath().
Krok zpět a vpřed
Tady se htmx 4 od běžné představy liší nejvíc. Při přechodu si neukládá kopii opuštěné stránky, takže ji krok zpět musí stáhnout ze serveru znovu. Prohlížeč to za něj neudělá: jeho back/forward cache funguje jen při přechodu mezi dokumenty a boostovaný přechod dokument nemění.
Co se děje
- V prohlížeči s Navigation API htmx navigaci přeruší, stránku stáhne a vymění. Ve Firefoxu a prohlížečích bez tohoto API stejnou práci spouští
popstate. - Požadavek nese jen
HX-History-Restore-Request. HlavičkyHX-RequestaniHX-Boostedchybí, takže server vrátí celou stránku bez jakékoli úpravy. - Výměna jde přes
outerSync, ne přes morph. Potomci se nahradí, takže stav komponent i rozepsaná pole uvnitř se ztratí. hx-headpři obnově sloučí hlavičku z odpovědi, titulek tedy sedí.
Co z toho plyne
- Stav, který má přežít krok zpět, patří do adresy. Filtr, stránka nebo hledání musí být v URL. Fragment s
hx-push-urlmusí mít adresu, na které server vrátí celou stránku. - Data jsou po návratu čerstvá, ale obsluha kroku zpět je běžný požadavek, takže rychlost závisí na serveru. Rozšíření
hx-history-cachetohle chování mění, s ním ale zkušenost nemáme. - Widgety se musí umět zrušit a znovu vytvořit. Grafy rušte v
htmx:before:swapa vytvářejte vhtmx.onLoad. - Úklid stavu mimo výměnu děláte taky v
htmx:before:swap, například odstranění třídy zamykající posun při otevřeném menu.
Posun stránky
Prohlížeč obnoví posun až po skončení přerušené navigace. Stránka se proto nejdřív vykreslí nahoře a potom skočí dolů. Firefox to má naopak: posune ještě starý obsah a po výměně se z delší stránky na kratší posun ořízne. Řešení je pamatovat si posun sám podle klíče položky historie:
const STORAGE_KEY = 'scroll-positions'
const LIMIT = 50
function load() {
try {
return JSON.parse(sessionStorage.getItem(STORAGE_KEY)) || {}
} catch (e) {
return {}
}
}
function store(positions) {
const keys = Object.keys(positions)
for (const key of keys.slice(0, Math.max(0, keys.length - LIMIT))) delete positions[key]
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(positions))
} catch (e) { /* privátní okno: posun se obnoví jen do reloadu */ }
}
export default function registerScrollRestore() {
if (!window.navigation) return
const positions = load()
let pending = null
// Stejná podmínka, za které htmx místo přerušení navigace poslouchá popstate.
if (/firefox/i.test(navigator.userAgent)) {
history.scrollRestoration = 'manual'
const isHistoryLoad = performance.getEntriesByType('navigation')[0]?.type === 'back_forward'
const target = positions[navigation.currentEntry.key]
if (isHistoryLoad && target !== undefined) window.scrollTo(0, target)
// Odchod adresním řádkem událost navigate nespustí.
window.addEventListener('pagehide', () => {
positions[navigation.currentEntry.key] = window.scrollY
store(positions)
})
}
// Musí se registrovat dřív než posluchač htmx, jinak scroll: 'manual' nepřijde včas.
navigation.addEventListener('navigate', (event) => {
positions[navigation.currentEntry.key] = window.scrollY
store(positions)
const isHistoryRestore = event.navigationType === 'traverse' && event.canIntercept && !event.hashChange
// Položky se stejnou adresou přidává modální okno: stránka se nemění a výměna nepřijde.
if (isHistoryRestore && event.destination.url === navigation.currentEntry.url) {
event.intercept({ scroll: 'manual' })
return
}
const target = positions[event.destination.key]
if (!isHistoryRestore || target === undefined) return
pending = target
event.intercept({ scroll: 'manual' })
})
const restore = () => {
if (pending === null) return
window.scrollTo(0, pending)
pending = null
}
// S View Transition přijde htmx:after:swap až po přechodu. Posun proto patří
// na konec jeho úlohy, aby už nový snímek byl posunutý správně.
document.addEventListener('htmx:before:viewTransition', (event) => {
if (pending === null) return
const task = event.detail.task
event.detail.task = async () => {
await task()
restore()
}
})
document.addEventListener('htmx:after:swap', restore)
}
Prohlížeče bez Navigation API modul vynechává. Posun tam obnovuje prohlížeč sám, i s popsaným skokem.
Modální okna
Člověk čeká, že Zpět zavře otevřené okno. htmx by ale místo toho znovu stáhlo stránku. Okno proto přidá do historie vlastní položku se stejnou adresou a stav všech oken drží jedno místo, které je s historií provázané:
let entryPath = null
const modal = {
current: null,
open(name) {
history.pushState({ modal: name }, '')
entryPath = location.pathname + location.search
this.current = name
},
}
// Krok 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. htmx takovou položku nechá být, protože v ní nenajde svůj stav.
window.addEventListener('popstate', (event) => {
if (!event.state?.modal) return
entryPath = location.pathname + location.search
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
})
- Zavření křížkem nebo Escapem volá
history.back(), aby položka v historii nezůstala. - Po kroku v historii vrátí Navigation API fokus na
<body>. Fokus na původní tlačítko proto vraťte až vnavigatesuccess, ve Firefoxu popopstate. - Na okno použijte nativní
<dialog>ashowModal(). Modální vrstvu, Escape a zachycení fokusu dostanete od prohlížeče.
Formuláře a validace
Controller
Boostovaný odkaz posílá HX-Request také. Kdo se ptá jen na tuto hlavičku, pošle odkazu fragment místo stránky. Pomocná metoda proto kontroluje obě:
private function isFragmentRequest(Request $request): bool
{
return $request->headers->has('HX-Request')
&& !$request->headers->has('HX-Boosted');
}
#[Route('/kontakt', name: 'app_contact', methods: ['GET', 'POST'])]
public function contact(Request $request): Response
{
$form = $this->createForm(ContactType::class);
$form->handleRequest($request);
$isFragment = $this->isFragmentRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
// Uložit a odeslat.
return $isFragment
? $this->render('contact/_sent.html.twig')
: $this->redirectToRoute('app_contact');
}
return $this->render(
$isFragment ? 'contact/_form.html.twig' : 'contact/index.html.twig',
['form' => $form],
);
}
AbstractController::render() nastaví stav 422 jen tehdy, když mu předáte FormInterface. S $form->createView() zůstane 200 a stav musíte nastavit sami.
Šablona
{{ form_start(form, {
action: path('app_contact'),
attr: {
id: 'contact-form',
novalidate: true,
'hx-post': path('app_contact'),
'hx-swap': 'outerHTML',
'hx-sync': 'this:drop',
},
}) }}
actionamethodnechte, bez JavaScriptu se formulář odešle běžně.hx-swap="outerHTML"a ne morph. Morph zachovává hodnoty polí, takže by formulář po odeslání zůstal vyplněný.hx-sync="this:drop"zahodí druhé odeslání.novalidate, pokud chyby hlídá server. Firefox pro Android hlášky prohlížeče nezobrazuje, takže by prázdný formulář po kliknutí tiše stál. Atributrequiredu polí nechte kvůli čtečkám.
Chybové odpovědi
Validační 422 se vloží bez konfigurace. U fragmentu ale nechcete do formuláře vložit celou chybovou stránku. hx-status hledá nejdřív přesný kód, až potom zástupný znak, takže 422 musí být uvedená zvlášť. Jinak chyby validace tiše zmizí:
<form hx-post="…" hx-target="#modal-content" hx-swap="innerHTML"
hx-status:422="swap:'innerHTML'"
hx-status:4xx="swap:'none'"
hx-status:5xx="swap:'none'">
swap:'none' nestačí. hx-head sloučí hlavičku odpovědi ještě před vyhodnocením hx-status a htmx nastaví titulek, i když nic nevymění. Chybová stránka by tak přepsala titulek a hlavičku aktuální stránky. Hlavičku a titulek proto povolte jen při přechodu na celou stránku. U varianty s #app porovnejte cíl s tímto prvkem:
const isFullPageSwap = (ctx) => ctx.target === document.body
document.addEventListener('htmx:head:before:merge', (event) => {
if (!isFullPageSwap(event.detail.ctx)) event.preventDefault()
})
document.addEventListener('htmx:before:swap', (event) => {
if (!isFullPageSwap(event.detail.ctx)) event.detail.ctx.title = null
})
Selhání nesmí zahodit text. U formuláře, kde člověk něco dlouho psal, odpověď 5xx zahoďte a pod tlačítko vložte telefon a e-mail. Totéž udělejte při výpadku sítě:
document.addEventListener('htmx:before:response', (event) => {
if (event.detail.ctx.response.status < 500) return
event.preventDefault()
event.target.addEventListener('htmx:finally:request', () => showFailure(event.target), { once: true })
})
document.addEventListener('htmx:error', (event) => {
event.target.addEventListener('htmx:finally:request', () => showFailure(event.target), { once: true })
})
Přesměrování
- Cíl mimo aktuální stránku (platební brána, jiné rozložení): vraťte 200 s
HX-Redirect. Na odpovědi 3xx htmx hlavičky nezpracovává afetchpřesměrování sám následuje. hx-posts obyčejným přesměrováním vloží celou cílovou stránku dohx-target. Celá stránka vložená do malého rámečku je typický příznak.- Boostovaný formulář přesměrování zvládne a do historie zapíše konečnou adresu. Pokud ale cíl má jiné rozložení, zvolte
hx-boost="false". To platí hlavně pro přihlášení, kde authenticator přesměrovává po úspěchu.
Modální formuláře
Formulář v okně vykreslete rovnou do stránky, nenačítejte ho líně. Vyhnete se potížím s časováním a se zpracováním skrytých prvků. Cena je možnost, že token CSRF na dlouho otevřené stránce expiruje, proto pro tento případ připravte srozumitelnou hlášku. V Symfony ji nastavíte přes csrf_message a přeložíte v doméně validators.
Přístupnost
- Po výměně je fokus často na
<body>. Po 422 ho dejte na první chybné pole, po odeslání na celé potvrzení. Oboje vhtmx:after:settle. - Při odesílání použijte na tlačítku
aria-disabled, nedisabled, který by fokus shodil na začátek stránky. - Stav odesílání ohlaste v předem vloženém prvku s
role="status".
Načítání částí obsahu
Vybírejte v tomto pořadí a nezačínejte tím posledním:
- Celá stránka přes boost. Žádný kód na serveru. Stačí pro většinu výpisů, stránkování i filtrů.
- Vlastní routa pro fragment. Jedna adresa, jedna reprezentace. Vhodné pro líné načtení.
- Tatáž adresa, dvě reprezentace. Jen když je celá stránka opravdu moc drahá.
Líné načtení
Vyplatí se u obsahu, který je velký nebo náročný na přípravu a většina lidí ho neotevře. Typicky graf vývoje ceny, historie objednávek nebo náhled dokumentu ve sbaleném oddílu. Stránka se tak nezdržuje něčím, co většina návštěvníků nepotřebuje.
<details>
<summary>Vývoj ceny</summary>
<div hx-get="{{ path('app_product_price_history', {id: product.id}) }}"
hx-trigger="toggle once from:'closest details'"
hx-swap="innerHTML"></div>
</details>
#[Route('/produkt/{id}/vyvoj-ceny', name: 'app_product_price_history', requirements: ['id' => '\d+'], methods: ['GET'])]
public function priceHistory(#[MapEntity(id: 'id')] Product $product): Response
{
$response = $this->render('product/_price_history.html.twig', ['product' => $product]);
$response->setPublic();
$response->setMaxAge(3600);
$response->headers->set('X-Robots-Tag', 'noindex');
return $response;
}
- Víceslovný selektor v
hx-triggermusí být v uvozovkách. Závorky htmx 4 neumí a chyba při zpracování zastaví zpracování zbytku stránky. - Když hodnota z adresy určuje šablonu nebo soubor, omezte ji přes
requirements, ať se přes ni nedá načíst nic jiného. - Rezervujte místo pro obsah, ať se stránka po načtení neposune.
- Fragment bez osobních údajů můžete cachovat jako veřejný.
Stránkování fragmentem
<a href="{{ path('app_blog', {page: page + 1}) }}"
hx-get="{{ path('app_blog', {page: page + 1}) }}"
hx-target="#articles"
hx-swap="innerHTML"
hx-push-url="true">Další</a>
Odkaz potřebuje href i hx-get. Bez href ho robot nenajde a nefunguje ani bez JavaScriptu. Controller rozhoduje přes isFragmentRequest():
$response = $this->render(
$this->isFragmentRequest($request) ? 'blog/_grid.html.twig' : 'blog/index.html.twig',
['articles' => $articles, 'page' => $page],
);
$response->setVary(['HX-Request', 'HX-Boosted'], false);
return $response;
Vary chrání před situací, kdy cache vrátí fragment místo celé stránky. Kdo stejný výsledek získá z celé stránky přes hx-select, řeší jednu adresu s jedinou reprezentací a server nemusí nic rozlišovat. Platí za to přenosem větší odpovědi.
Aktualizace mimo cíl
hx-swap-oob aktualizuje další místa jednou odpovědí, například počet v košíku nebo hlášku k slevovému kódu.
- Když po vyjmutí OOB prvků v odpovědi nic nezbude, htmx hlavní výměnu přeskočí. Prázdná odpověď proto nesmaže starou chybu. Pomůže
hx-swap="innerHTML swapEmpty:true". - Řádky tabulky a položky seznamu zabalte do
<template>, jinak je parser HTML zahodí.
JavaScript v načteném obsahu
- Widgety inicializujte v
htmx.onLoad(callback). Běží při prvním načtení i po každé výměně, včetně kroku zpět. htmx:after:swapse spouští na prvku, který požadavek poslal, ne na cíli. Na inicializaci nového obsahu se nehodí.- Inicializace musí být opakovatelná.
- Těžké knihovny načítejte přes
import(), až když je na stránce jejich prvek.
SEO
htmx je pro vyhledávače neviditelné, pokud platí jediné pravidlo: každá adresa vrátí bez JavaScriptu celou stránku. Robot neposílá HX-Request, takže dostane to, co po prvním načtení člověk.
curl -s https://example.com/sluzby | grep -E '<title>|rel="canonical"|<h1'
curl -sI https://example.com/neexistuje | head -1
Pravidla
- Odkazy jsou
<a href>. Robot neprocházíhx-getna tlačítku ani na<div>. - Co se má najít, je v prvním HTML. Robot neklikne, neposouvá a neotevře oddíl. Přes htmx načítejte jen doplňky, jako je graf nebo historie. Text, který má být k nalezení, nechte v HTML, i když je ve sbaleném
<details>. Řada robotů AI JavaScript vůbec nespouští. - Titulek, popis,
canonicalahreflangjsou v odpovědi každé adresy.hx-headje pak vymění i uživateli. - Skutečné stavy. 404 musí být 404 a přesun 301. Chybová stránka se stavem 200 by se dostala do indexu.
- Každá strana výpisu má vlastní
canonical. Druhá strana má adresu s?page=a titulek s číslem. Google výslovně nedoporučuje používat jako kanonickou první stranu. Do sitemapy další strany dávat nemusíte, vedou k nim odkazy. hreflangna dalších stranách výpisu závisí na obsahu. Když mají jazykové verze stejné položky ve stejném pořadí a liší se jen překladem, odkažte na stejnou stranu v každém jazyce. Když se počet položek liší,hreflangna dalších stranách vynechte, protože odkaz na neexistující stranu je horší než žádný.- Filtrované a řazené varianty výpisu, které nemají být vstupní stránkou, vyřaďte z indexu přes
noindex, jak doporučuje Google. Kanonická adresa na výpis bez parametru je pro vyhledávač jen doporučení a u stránky s jiným obsahem ji nemusí přijmout. - Stránkování má skutečné odkazy. Nekonečné načítání bez adres je pro roboty slepá ulička.
- Fragmenty nepatří do indexu. Vlastní routy dostanou
X-Robots-Tag: noindexa nejsou v sitemapě. Neblokujte je vrobots.txt, robot by hlavičku nepřečetl. - Formulářové POST cesty a výsledky hledání v sitemapě nemají co dělat.
Rychlost
Prefetch a morph zrychlují přechody, ale Core Web Vitals v Search Console stojí hlavně na prvním načtení. htmx je malé, přesto bundle hlídejte a těžké knihovny načítejte líně. Sledujte i rychlost odpovědi serveru, protože prefetch i krok zpět jdou přes něj.
Měření
htmx přechod není načtení stránky, takže analytika zobrazení sama nepočítá.
- Vlastní měření odešlete po
htmx:after:swap, jen když se změnila adresa. Otevřené okno ani odeslaný formulář se pak jako zobrazení nepočítají. - Titulek je v tu chvíli nový, protože
hx-headho sloučil už při odpovědi. Událost po zápisu do historie přijde ještě před výměnou obsahu, takže stránka v tu chvíli ukazuje starý obsah. - Google Tag Manager zvládne přechody přes spouštěč History Change. Když k němu přidáte vlastní odeslání, máte dvě zobrazení.
- Krok zpět se počítá jako zobrazení. Ověřte si, že jste to tak chtěli.
- Skript měření označte
hx-preserve="true".
Testy a nasazení
- Před každou aktualizací htmx vyzkoušejte přechod a odeslání formuláře i ve starším prohlížeči a krok zpět z modálního okna na čerstvě načtené stránce. Projděte i výskyty
htmx:v šablonách. - V end-to-end testech čekejte na
htmx:finally:requesta posluchač navěste ve stejném skriptu před klikem. Čekání na zmizení třídy.htmx-requesthned po kliknutí může skončit dřív, než se třída objeví. Po výměně si znovu načtěte DOM v testovacím klientovi. - Krok zpět testujte v Chromu i ve Firefoxu, jde tam jiným kódem. Zkoušejte s přechody i s omezeným pohybem.
- Přechody zkoušejte i ve WebKitu, nejen v Chromu. Chyba morphu tam nemusí nic vypsat, poznáte ji podle toho, že po kliknutí zůstane část staré stránky. Na Linuxu k tomu poslouží GNOME Web nebo
MiniBrowserz WebKitGTK. - Chybové stránky testujte na
proda jednou si vyzkoušejte i výpadek bez vaší šablony, například 502 z proxy. - Pozor na otevřenou stránku při nasazení.
hx-headporovnává značky doslova. Z kódu htmx plyne, že skript s novým hashem se při dalším přechodu přidá a spustí vedle starého. V provozu jsme to zatím neměřili, ale po nasazení s větší změnou to stojí za kontrolu.
Kontrolní seznam pro nový projekt
- Rozšíření
hx-headahx-preloadimportovaná za jádrem <body>shx-boost:inheriteda morphem, nebo#appshx-head="merge"ahx-history-elt- Všechny skripty v
<head>, jednorázové shx-preserve="true" hx-boost="false"na odhlášení, přihlášení, administraci, jiná rozložení a GET s vedlejším účinkem<html lang>po přepnutí jazyka odpovídá stránce- Fragment jen při
HX-Request && !HX-Boosted, u téže adresy sVary AuthenticationEntryPointvrací u htmxHX-Redirect, pokud má login jiné rozložení nebo jsou za přihlášením fragmenty- Chybové šablony s celým rozložením, u
#apppojistka na prázdný výběr - Přechody zkrácené a vypnuté při omezeném pohybu, žádný globální
scroll-behavior: smooth - Modul na obnovu posunu registrovaný před spuštěním htmx
- Stav modálních oken na jednom místě provázaném s historií
- Stav, který má přežít Zpět, je v adrese
- Formuláře:
FormInterfacedorender(),outerHTML,hx-sync,hx-status:422při zástupném4xx, ochrana textu při 5xx, fokus po výměně - Widgety v
htmx.onLoad, opakovatelná inicializace, klientské příznaky vmorphIgnore - Atributy bez
@v názvu, přechody vyzkoušené i ve WebKitu - SEO:
curlvrací celou stránku, odkazy jsou<a href>, hlavní obsah v prvním HTML, fragmenty snoindex - Měření po
htmx:after:swap, ne po změně historie - Testy kroku zpět v Chromu i ve Firefoxu, chybové stránky na
prod
Kdy po htmx nesáhnout
htmx se hodí tam, kde většinu práce dělá server: obsahové weby, formuláře, účty, administrace. Pro aplikaci, která drží hodně stavu v prohlížeči, například editor nebo práci offline, je lepší klientský framework.
Přehled atributů, hlaviček a událostí
Atributy, které budete používat
hx-get,hx-post: pošlou požadavek na danou adresu.hx-trigger: určí událost, která požadavek spustí. Výchozí je klik, u formuláře odeslání.hx-target: určí prvek, kterého se výměna týká. Výchozí je prvek sám.hx-swap: určí způsob výměny. Výchozí jeinnerHTML.hx-select: vybere z odpovědi jen část podle selektoru.hx-boost: převede obyčejný odkaz nebo formulář na požadavek htmx.hx-push-url: zapíše adresu do historie prohlížeče.hx-sync: řeší souběh požadavků, například zahození druhého odeslání.hx-status:CODE: změní chování podle stavu odpovědi, napříkladhx-status:422.hx-swap-oob: v odpovědi označí prvek, který se má vyměnit mimo hlavní cíl.hx-preserve: nechá prvek beze změny, když je v původní i nové stránce.hx-history-elt: určí prvek, který se vymění při kroku zpět. Výchozí je<body>.
Způsoby výměny
innerHTML,outerHTML: nahradí obsah nebo celý prvek. Hodí se, když se má stav zahodit.innerMorph,outerMorph: sloučí nové HTML do stávajícího DOM a zachová fokus, posun, animace i hodnoty polí.outerSync: sloučí atributy prvku a vymění jeho potomky. Používá ho htmx při kroku zpět.none: nic nevloží.
Hlavičky
HX-Request: posílá ho každý běžný požadavek htmx, tedy i boostovaný odkaz.HX-Boosted: posílá ho jen boostovaný prvek.HX-History-Restore-Request: posílá ho obnova stránky při kroku zpět.HX-Redirect(v odpovědi): vynutí plné načtení stránky. Funguje jen s odpovědí 2xx.HX-Retarget,HX-Reswap,HX-Push-Url(v odpovědi): server jimi přepíše cíl, výměnu nebo adresu.
Události
Události probublávají, takže posluchače stačí pověsit na document. Většina nese v event.detail.ctx kontext požadavku (ctx.target, ctx.request, ctx.response, ctx.text). Tam, kde je to uvedené, ruší další postup event.preventDefault(). Seznamy jsou v pořadí, v jakém události přicházejí.
Požadavek:
htmx:config:request: požadavek se teprve sestavuje. Tady se upravují hlavičky, adresa nebo tělo. Lze zrušit.htmx:confirm: jen u prvku shx-confirm. Slouží pro vlastní potvrzovací dialog místowindow.confirm, který pokračuje přesdetail.issueRequest()nebodetail.dropRequest().htmx:before:request: těsně před odesláním. Hodí se pro stav tlačítka a hlášení o odesílání. Lze zrušit.htmx:before:response: dorazily hlavičky, tělo ještě ne. Zrušením odpověď zahodíte. Lze zrušit.htmx:after:request: tělo je přečtené ahx-headuž hlavičku sloučil, výměna ještě neproběhla. Lze zrušit.htmx:response:error: stav je 400 nebo vyšší. Výměna přesto pokračuje.htmx:error: výpadek sítě nebo výjimka při zpracování.htmx:finally:request: vždy jako poslední, i po chybě a zrušení. Patří sem úklid.
Výměna obsahu:
htmx:before:swap: spouští se na prvku, který poslal požadavek. Adresa je už nová, obsah ještě starý. Vdetail.tasksjsou připravené výměny. Lze zrušit.htmx:before:viewTransition: jen se zapnutým přechodem. Přesdetail.tasklze přidat práci, která se stihne do nového snímku.htmx:before:settle,htmx:after:settle: spouští se na cíli výměny, nový obsah je v DOM. Hodí se pro nastavení fokusu.htmx:after:process: htmx zpracoval atributy v novém obsahu. Na tuto událost navazujehtmx.onLoad(), který běží i při prvním načtení.htmx:after:viewTransition: přechod doběhl.htmx:after:swap: spouští se na prvku, který poslal požadavek. S přechodem přijde až po jeho konci, tedy později nežhtmx:after:settle.htmx:finally:swap: konec výměny, i po chybě.htmx:before:cleanup,htmx:after:cleanup: spouští se na prvku, který htmx odstraňuje z DOM.
Historie (spouští se na document):
htmx:before:history:update: chystá se zápis adresy. Přesdetail.history.typelze změnitpushnareplace. Lze zrušit.htmx:after:history:push,htmx:after:history:replace,htmx:after:history:update: adresa je zapsaná. Přichází to ještě před výměnou obsahu.htmx:before:history:restore: krok zpět nebo vpřed, vdetail.pathje cílová adresa. Zrušením stránku nestáhnete znovu.
Sloučení hlavičky z rozšíření hx-head (spouští se na <body>):
htmx:head:before:merge: zrušením se hlavička z odpovědi nepoužije.htmx:head:before:add,htmx:head:before:remove: týká se jednotlivé značky, zrušením ji necháte být.htmx:head:after:merge: hlavička je sloučená.