Jedna prośba o zadanie, dwie architektury: co htmx usuwa z stacka CRUD
Prześledź pojedynczą prośbę o dodanie elementu w aplikacji typu React SPA oraz na stronie htmx, aby zobaczyć, które warstwy znikają, co przejmuje serwer i gdzie htmx przestaje być stosowny.
Źródła: Stop Over-Engineering CRUD Apps: Why HTMX Might Be All You Need
Większość narzędzi wewnętrznych i paneli administracyjnych wykonywa cztery podstawowe zadania: tworzenie, odczytywanie, aktualizowanie i usuwanie rekordów. Jednak standardowy sposób ich budowania polega obecnie na tworzeniu pełnych aplikacji jednostronicowych, które wykorzystują frameworki komponentów, menedżery stanu, biblioteki do pobierania danych, punkty końcowe w formacie JSON oraz kod kliencki, którego głównym zadaniem jest przekształcanie tego JSON z powrotem w HTML. W rezultacie formularz służący do dodawania jednej wiersza do bazy danych musi polegać na pół tuzina pakietów oraz dwóch narzędziach do budowania aplikacji.
Ten artykuł analizuje jeden konkretny funkcjonalność – dodawanie pozycji do listy zadań – zarówno w implementacji opartej na React, jak i htmx. Porównanie procesu realizacji żądania w obu przypadkach ułatwia ocenę, które warstwy faktycznie są potrzebne w Twojej aplikacji CRUD oraz kiedy lepszym wyborem będzie bardziej zaawansowane frameworki klienckie. Aby zapoznać się z szerszym porównaniem hypermedia wobec standardowego podejścia SPA, sprawdź htmx vs the SPA default.
Wymagania, które rzeczywiście mają większość aplikacji CRUD
Proste aplikacje biznesowe składają się z podstawowych działań wykonywanych przez użytkowników: logowania, tworzenia rekordów, edytowania ich oraz usuwania. Zazwyczaj nie ma trybu offline, brakuje współpracy w czasie rzeczywistym oraz interakcji na tyle szybkich, że wymagają natychmiastowej obsługi bez konieczności kontaktu z serwerem. Żadna z tych funkcji nie uzasadnia używania wirtualnego DOM-u, routingu na stronie klienta czy czekania nawet minuty na odbudowę jednego przycisku przez narzędzia programistyczne.
Bramki programistyczne są często wybierane z przyzwyczajenia, zanim ktokolwiek spisze dokładnie, co musi robić aplikacja. Rozpoczęcie od określenia wymagań zazwyczaj prowadzi do znacznie mniejszego zestawu narzędzi.
Jak htmx zmienia umowę z serwerem
htmx umożliwia zwykłym elementom HTML wysyłanie żądań HTTP za pomocą atrybutów. Serwer odpowiada fragmentem HTML zamiast JSON, a htmx wstawia ten fragment na stronie w określonym przez użytkownika miejscu.
Skutkiem tego jest brak kopii danych po stronie klienta, którą trzeba byłoby zarządzać. Serwer już wie, jak powinna wyglądać lista, więc renderuje gotowy kod i go wysyła. Przeglądarce pozostaje tylko umieścić go we właściwym miejscu.
To samo funkcjonalność w obu stylach
wersja React
Oto konwencjonalny komponent React, który dodaje zadanie do listy. Przechowuje tekst wprowadzony przez użytkownika oraz listę w stanie, wysyła JSON do API, parsuje odpowiedź, dodaje nowy element i usuwa pole wprowadzania.
function AddTodo() {
const [text, setText] = useState("")
const [todos, setTodos] = useState([])
const addTodo = async () => {
const res = await fetch("/api/todos", {
method: "POST",
body: JSON.stringify({ text }),
})
const todo = await res.json()
setTodos([...todos, todo])
setText("")
}
return (
<div>
<input value={text} onChange={e => setText(e.target.value)} />
<button onClick={addTodo}>Add</button>
<ul>
{todos.map(t => <li key={t.id}>{t.text}</li>)}
</ul>
</div>
)
}
Nawet ten mały przykład zawiera szczegóły, które musisz poprawnie zrealizować. Zapytanie wysyła ciało w formacie JSON bez nagłówka Content-Type, przez co wiele serwerów nie potrafi go przetworzyć jako JSON. Funkcja aktualizacji setTodos([...todos, todo]) odczytuje wartość todos z elementu, w którym nastąpiło kliknięcie, więc dwa szybkie wysyłanie zapytań może skutkować utratą jednego elementu; forma funkcjonalna setTodos(prev => [...prev, todo]) unika tego problemu. Lista zawiera jedynie elementy dodane w bieżącej sesji, ponieważ załadowanie istniejących elementów wymagałoby dodatkowego efektu i punktu końcowego.
wersja htmx
Ekwivalentem w htmx jest formularz oraz pusta lista.
<form hx-post="/todos" hx-target="#list" hx-swap="beforeend">
<input name="text" />
<button>Add</button>
</form>
<ul id="list"></ul>
Trzy atrybuty określają zachowanie. hx-post="/todos" wysyła pola formularza do tego adresu URL po jego wysłaniu. hx-target="#list" wskazuje, dokąd ma trafić odpowiedź, a hx-swap="beforeend" dodaje ją do tego elementu, po istniejących dzieciach, zamiast zastępować jego zawartość. Przycisk nie wymaga obsługi, ponieważ przycisk znajdujący się w formularzu automatycznie go wysyła.
Na serwerze ścieżka odczytuje pola formularza, przechowuje zadanie do zrobienia i renderuje jedną wiersz listy na podstawie szablonu.
@app.route("/todos", methods=["POST"])
def add_todo():
text = request.form["text"]
todo = save_todo(text)
return render_template("todo_row.html", todo=todo)
To jest cała część frontendu dla tej funkcjonalności: brak hooków, brak stanu klienta oraz brak zachowania ponownego renderowania, które mogłoby pomóc w debugowaniu późno w nocy. Szablon todo_row.html po prostu renderuje pojedynczy element <li> dla nowego wpisu. Ponieważ ten sam szablon może być używany podczas pierwszego renderowania strony, początkowa lista i nowo dodane wiersze dzielą ten sam kod markup, zamiast używać osobnego komponentu JSX oraz widoku serwerowego, które musiałyby być synchronizowane.
W rzeczywistych implementacjach obowiązują kilka praktycznych uwag. Prośba o zmianę stanu nadal wymaga ochrony CSRF, zwykle poprzez umieszczenie tokena w polu ukrytym lub w nagłówku żądania. Jeśli dodasz do formularza zwykłe atrybuty action i method, będzie on nadal funkcjonował jako zwykłe wysłanie formularza, nawet jeśli JavaScript nie zostanie pobrań.
Śledzenie dwóch cykli życia żądań
Różnica jest najwyraźniejsza, gdy rysujesz każdy krok procesu krok po kroku. Najpierw trasa SPA.
Browser Server
-------- --------
Click button
|
v
JS event handler
|
v
fetch() call -------> API endpoint
|
v
Query database
|
v
Return JSON
^ |
|__________________________|
|
v
Parse JSON
|
v
Update state
|
v
Re-render component
|
v
Diff virtual DOM
|
v
Patch real DOM
Gdy baza danych zwraca JSON, klient nadal musi go przetworzyć, zaktualizować stan, ponownie wyrenderować treść oraz dokonać porównania i wprowadzić zmiany w DOM-ie.
A teraz trasa htmx dla tego samego kliknięcia.
Browser Server
-------- --------
Click button
|
v
htmx request -------> Route handler
|
v
Query database
|
v
Render HTML piece
^ |
|__________________________|
|
v
Swap HTML into page
Kilka etapów po prostu znika: przetwarzanie JSON na stronie klienta, aktualizacje stanu, porównywanie danych oraz wszelkie próby utrzymania spójności kopii danych na stronie klienta. Baza danych za serwerem od początku posiadała te dane. Podejście htmx eliminuje konieczność utrzymywania drugiej, klientowej wersji tych danych.
Jak to wygląda w praktyce
W jednym z opisanych przypadków migracji projektu administracyjnego klienta, zastąpienie interfejsu frontendowego opartego na React szablonami renderowanymi na serwerze w połączeniu z htmx zmniejszyło rozmiar pliku JavaScript z około 340 KB do około 12 KB, a czas potrzebny na uzyskanie interaktywności na telefonie średniej klasy spadł z prawie trzech sekund do poniżej jednej sekundy. Liczba próśb o integrację zmniejszyła się, ponieważ było mniej kodu do przejrzenia, a nowi programiści mogli opanować narzędzie już po popołudniu, gdyż baza kodowa składała się głównie z HTML z kilkoma dodatkowymi atrybutami.
Rozpatruj te liczby jako ilustrację, a nie jako standard mierzenia wydajności. Pochodzą one z jednego projektu, zmierzonych nieformalnie na jednym komputerze i w jednej sieci. Kierunek tego efektu jest prawdopodobny dla każdego aplikacji, w której większość kodu klienta służy wyłącznie do przekazywania danych między JSON a DOM, ale warto zmierzyć wydajność własnej aplikacji przed i po wdrożeniu, zamiast zakładać takie same korzyści.
Kiedy htmx nie jest odpowiednim narzędziem
To nie jest argument za usuwaniem Reacta ze wszystkiego. Aplikacje z naprawdę bogatą interakcją po stronie klienta, takie jak narzędzia do projektowania, arkusze kalkulacyjne czy edytory współpracy w czasie rzeczywistym, wymagają znacznego stanu klienta oraz frameworka stworzonego do jego zarządzania. Przymuszanie ich do interakcji typu żądanie-i-zamiana byłoby ciągłą walką.
Szybkim sposobem na podjęcie decyzji jest zadanie kilku pytań dotyczących funkcjonalności, którą mamy przed sobą:
- Czy interakcja musi reagować szybciej niż czas potrzebny na jedno przejście przez sieć, np. przy przeciąganiu, rysowaniu lub pisaniu z natychmiastową informacją zwrotną?
- Czy musi działać offline lub synchronizować edycje kilku użytkowników w czasie rzeczywistym?
- Czy klient musi przechowywać i przetwarzać dużą ilość danych lokalnie?
Jeśli odpowiedzi są głównie negatywne, chodzi prawdopodobnie o formularze, tabele i przyciski, które zapisują dane do bazy danych. Ta kategoria obejmuje znacznie więcej programów niż przyjęło się przyznawać, a dodatkowe narzędzia po stronie klienta zwykle zwiększają koszty konserwacji, zamiast rozwiązywać problemy.
Podsumowanie
- Wybieraj narzędzia w oparciu o wymagania; proste rozwiązania typu CRUD rzadko wymagają stanu po stronie klienta.
- Dzięki htmx serwer renderuje fragmenty, a atrybuty decydują o ich umieszczeniu, co eliminuje konieczność przekształcania danych w JSON oraz podwójne źródło informacji.
- Zachowaj ochronę CSRF, prawdziwe atrybuty
actionoraz wspólne szablony wierszy. - Zarezerwuj pełny framework po stronie klienta na interakcje wymagające intensywnego wykorzystania zasobów po tej stronie.
Nauka Reacta nadal jest rozsądnym krokiem karierowym, a wiele projektów go wymaga. To uzasadnia znajomość tego narzędzia; nie usprawiedliwia jednak jego używania we wszystkich aplikacjach, których rzeczywistymi potrzebami są formularze i tabele.