AJAX i snippety
W erze nowoczesnych aplikacji webowych, w których funkcjonalność często rozłożona jest między serwer a przeglądarkę, AJAX jest niezbędnym elementem łączącym. Jakie możliwości oferuje w tym obszarze Nette Framework?
- wysyłanie części szablonu, zwanych snippetami
- przekazywanie zmiennych między PHP a JavaScriptem
- narzędzia do debugowania żądań AJAX
Żądanie AJAX
Żądanie AJAX zasadniczo nie różni się od klasycznego żądania HTTP. Wywoływany jest presenter z określonymi parametrami. To do presentera należy decyzja, jak odpowiedzieć na żądanie – może zwrócić dane w formacie JSON, wysłać fragment kodu HTML, dokument XML itd.
Po stronie przeglądarki inicjujemy żądanie AJAX funkcją fetch():
fetch(url, {
headers: {'X-Requested-With': 'XMLHttpRequest'},
})
.then(response => response.json())
.then(payload => {
// przetwarzamy odpowiedź
});
Po stronie serwera żądanie AJAX rozpoznaje metoda $httpRequest->isAjax() usługi enkapsulującej żądanie HTTP. Do wykrycia używa nagłówka HTTP X-Requested-With, dlatego
kluczowe jest jego wysłanie. Wewnątrz presentera możesz użyć metody $this->isAjax().
Jeśli chcesz wysłać dane w formacie JSON, użyj metody sendJson(). Metoda ta kończy również działanie presentera.
public function actionExport(): void
{
$this->sendJson($this->model->getData());
}
Jeśli planujesz odpowiedzieć specjalnym szablonem przeznaczonym dla AJAX-a, możesz zrobić to tak:
public function handleClick($param): void
{
if ($this->isAjax()) {
$this->template->setFile('path/to/ajax.latte');
}
// ...
}
Snippety
Najpotężniejszym narzędziem, jakie Nette oferuje do łączenia serwera z klientem, są snippety. Dzięki nim zamienisz zwykłą aplikację w aplikację AJAX-ową minimalnym wysiłkiem i kilkoma wierszami kodu. Jak to wszystko działa, pokazuje przykład Fifteen, którego kod znajdziesz na GitHubie.
Snippety pozwalają aktualizować tylko części strony zamiast przeładowywać ją całą. Jest to nie tylko szybsze i wydajniejsze, ale daje też wygodniejsze doświadczenie użytkownika. Snippety mogą przypominać Ci Hotwire dla Ruby on Rails albo Symfony UX Turbo. Co ciekawe, Nette wprowadziło snippety 14 lat wcześniej.
Jak działają snippety? Przy pierwszym wczytaniu strony (żądanie nie-AJAX) wczytywana jest cała strona wraz ze wszystkimi
snippetami. Gdy użytkownik wejdzie w interakcję ze stroną (np. kliknie przycisk, wyśle formularz itd.), zamiast przeładowania
całej strony inicjowane jest żądanie AJAX. Kod w presenterze wykonuje akcję i decyduje, które snippety trzeba
zaktualizować. Nette renderuje te snippety i wysyła je jako payload JSON zawierający tablicę ze snippetami. Kod obsługujący
w przeglądarce wstawia następnie otrzymane snippety z powrotem na stronę. Przesyłany jest więc tylko kod zmienionych
snippetów, co oszczędza pasmo i przyspiesza ładowanie w porównaniu z przesyłaniem treści całej strony. Jeśli żaden
snippet nie zostanie unieważniony przez redrawControl(), Nette zwraca całą stronę nawet przy żądaniu AJAX –
snippety wysyłane są dopiero wtedy, gdy coś zostanie unieważnione.
Naja
Do obsługi snippetów po stronie przeglądarki służy biblioteka Naja. Zainstaluj ją jako pakiet Node.js (do użycia z bundlerami, takimi jak Webpack, Rollup, Vite, Parcel i inne):
npm install naja
…albo wstaw ją bezpośrednio do szablonu strony:
<script src="https://unpkg.com/naja@3/dist/Naja.min.js"></script>
Najpierw trzeba bibliotekę zainicjować:
naja.initialize();
Aby zamienić zwykły odnośnik (sygnał) albo wysyłanie formularza w żądanie AJAX, wystarczy oznaczyć odpowiedni
odnośnik, formularz albo przycisk klasą ajax:
<a n:href="go!" class="ajax">Idź</a>
<form n:name="form" class="ajax">
<input n:name="submit">
</form>
albo
<form n:name="form">
<input n:name="submit" class="ajax">
</form>
Przerysowywanie snippetów
Każdy obiekt klasy Control (w tym sam Presenter) pilnuje, czy zaszły zmiany wymagające jego
przerysowania. Służy do tego metoda redrawControl():
public function handleLogin(string $user): void
{
// po zalogowaniu trzeba przerysować odpowiednią część
$this->redrawControl();
// ...
}
Nette pozwala na jeszcze precyzyjniejszą kontrolę tego, co trzeba przerysować. Metoda może przyjąć jako argument nazwę snippetu. Można więc unieważniać (czyli wymuszać przerysowanie) na poziomie części szablonu. Jeśli unieważniony zostanie cały komponent, przerysowany zostanie również każdy snippet w jego wnętrzu:
// unieważnia snippet 'header'
$this->redrawControl('header');
Oczekujące unieważnienie możesz też anulować drugim parametrem $redraw: wywołanie
$this->redrawControl('header', redraw: false) oznacza snippet jako niewymagający przerysowania. Pełna sygnatura
to redrawControl(?string $snippet = null, bool $redraw = true).
Snippety w Latte
Używanie snippetów w Latte jest wyjątkowo łatwe. Aby zdefiniować część szablonu jako snippet, wystarczy opakować ją
tagami {snippet} i {/snippet}:
{snippet header}
<h1>Cześć ... </h1>
{/snippet}
Snippet tworzy na stronie HTML element <div> ze specjalnym wygenerowanym id. Przy przerysowaniu
snippetu aktualizowana jest zawartość tego elementu. Dlatego konieczne jest, aby przy początkowym renderowaniu strony
wyrenderowane zostały również wszystkie snippety, nawet jeśli na początku bywają puste.
Snippet możesz też utworzyć na elemencie innym niż <div>, używając n:atrybutu:
<article n:snippet="header" class="foo bar">
<h1>Cześć ... </h1>
</article>
Obszary snippetów
Nazwy snippetów mogą być również wyrażeniami:
{foreach $items as $id => $item}
<li n:snippet="item-{$id}">{$item}</li>
{/foreach}
Samo w sobie jest to niedziałający krok pośredni: wyrenderowany poza statycznym {snippet} albo
{snippetArea} dynamiczny snippet wywołuje E_USER_WARNING z komunikatem Dynamic snippets are
allowed only inside static snippet/snippetArea. Poprawimy to poniżej.
Powstaje w ten sposób kilka snippetów w rodzaju item-0, item-1 itd. Gdybyśmy unieważnili
bezpośrednio dynamiczny snippet (np. item-1), nic nie zostałoby przerysowane. Powodem jest to, że snippety
naprawdę działają jak wycinki i renderowane są bezpośrednio tylko one same. W szablonie technicznie nie ma jednak snippetu
o nazwie item-1. Powstaje on dopiero wtedy, gdy wykona się kod otaczający snippet, czyli pętla foreach. Dlatego
część szablonu, która ma zostać wykonana, oznaczamy tagiem {snippetArea}:
<ul n:snippetArea="itemsContainer">
{foreach $items as $id => $item}
<li n:snippet="item-{$id}">{$item}</li>
{/foreach}
</ul>
I żądamy przerysowania zarówno pojedynczego snippetu, jak i całego obszaru nadrzędnego:
$this->redrawControl('itemsContainer');
$this->redrawControl('item-1');
Jednocześnie warto zadbać o to, aby tablica $items zawierała tylko te elementy, które mają zostać
przerysowane.
Jeśli do głównego szablonu dołączymy tagiem {include} inny szablon zawierający snippety, trzeba dołączenie
szablonu ponownie opakować w snippetArea i unieważnić je razem ze snippetem:
{snippetArea include}
{include 'included.latte'}
{/snippetArea}
{* included.latte *}
{snippet item}
...
{/snippet}
$this->redrawControl('include');
$this->redrawControl('item');
Snippety w komponentach
Snippety możesz tworzyć wewnątrz komponentów, a Nette automatycznie je przerysuje. Jest jednak
pewne ograniczenie: aby przerysować snippety, Nette wywołuje metodę render() bez żadnych parametrów.
Przekazywanie parametrów w szablonie nie zadziała więc:
OK
{control productGrid}
nie zadziała:
{control productGrid $arg, $arg}
{control productGrid:paginator}
Wysyłanie własnych danych
Razem ze snippetami możesz wysłać do klienta dowolne dodatkowe dane. Wystarczy zapisać je do obiektu
payload:
public function actionDelete(int $id): void
{
// ...
if ($this->isAjax()) {
$this->payload->message = 'Sukces';
}
}
Przekierowywanie
Podczas żądania AJAX metody redirect() i redirectUrl() nie wysyłają przekierowania HTTP. Zamiast
tego zapisują docelowy URL do payloadu (obiektu danych wysyłanego w odpowiedzi AJAX), konkretnie do jego właściwości
payload.redirect, i wysyłają go; samo przekierowanie wykonuje następnie biblioteka po stronie klienta (Naja).
Przekazywanie parametrów
Wysyłając do komponentu parametry przez żądanie AJAX, niezależnie od tego, czy są to parametry sygnału, czy parametry
trwałe, musimy podać w żądaniu ich globalną nazwę, która zawiera również nazwę komponentu. Pełną nazwę parametru
zwraca metoda getParameterId().
let url = new URL({link //foo!});
url.searchParams.set({$control->getParameterId('bar')}, bar);
fetch(url, {
headers: {'X-Requested-With': 'XMLHttpRequest'},
})
A metoda handle z odpowiadającymi parametrami w komponencie:
public function handleFoo(int $bar): void
{
}