Dynamiczne snippety

Dość często podczas tworzenia aplikacji pojawia się potrzeba wykonywania operacji AJAX, na przykład na poszczególnych wierszach tabeli lub elementach listy. Jako przykład możemy wybrać listę artykułów, przy czym dla każdego z nich umożliwimy zalogowanemu użytkownikowi wybranie oceny “lubię/nie lubię”. Kod presentera i odpowiadającego mu szablonu bez AJAX będzie wyglądał mniej więcej tak (podaję najważniejsze fragmenty, kod zakłada istnienie usługi do oznaczania ocen i pobierania kolekcji artykułów – konkretna implementacja nie jest ważna dla celów tego poradnika):

public function handleLike(int $articleId): void
{
	$this->ratingService->saveLike($articleId, $this->user->id);
	$this->redirect('this');
}

public function handleUnlike(int $articleId): void
{
	$this->ratingService->removeLike($articleId, $this->user->id);
	$this->redirect('this');
}

Szablon:

<article n:foreach="$articles as $article">
	<h2>{$article->title}</h2>
	<div class="content">{$article->content}</div>
	{if !$article->liked}
		<a n:href="like! $article->id" class=ajax>lubię to</a>
	{else}
		<a n:href="unlike! $article->id" class=ajax>już mi się to nie podoba</a>
	{/if}
</article>

Ajaxizacja

Teraz wyposażmy tę prostą aplikację w AJAX. Zmiana oceny artykułu nie jest na tyle ważna, aby musiało dojść do przekierowania, dlatego idealnie powinna odbywać się za pomocą AJAX w tle. Wykorzystamy skrypt obsługi z dodatków ze zwyczajową konwencją, że linki AJAX mają klasę CSS ajax.

Jednak jak to zrobić konkretnie? Nette oferuje 2 ścieżki: ścieżkę tzw. dynamicznych snippetów i ścieżkę komponentów. Obie mają swoje zalety i wady, dlatego pokażemy je po kolei.

Ścieżka dynamicznych snippetów

Dynamiczny snippet w terminologii Latte oznacza specyficzny przypadek użycia znacznika {snippet}, gdzie w nazwie snippetu używana jest zmienna. Taki snippet nie może znajdować się w szablonie byle gdzie – musi być opakowany statycznym snippetem, tj. zwykłym, lub wewnątrz {snippetArea}. Nasz szablon moglibyśmy zmodyfikować w następujący sposób.

{snippet articlesContainer}
	<article n:foreach="$articles as $article">
		<h2>{$article->title}</h2>
		<div class="content">{$article->content}</div>
		{snippet article-{$article->id}}
			{if !$article->liked}
				<a n:href="like! $article->id" class=ajax>lubię to</a>
			{else}
				<a n:href="unlike! $article->id" class=ajax>już mi się to nie podoba</a>
			{/if}
		{/snippet}
	</article>
{/snippet}

Każdy artykuł definiuje teraz jeden snippet, który ma w nazwie ID artykułu. Wszystkie te snippety są następnie razem opakowane jednym snippetem o nazwie articlesContainer. Gdybyśmy pominęli ten opakowujący snippet, Latte poinformowałoby nas o tym wyjątkiem.

Pozostaje nam uzupełnić w prezenterze przerysowanie – wystarczy przerysować statyczną otoczkę.

public function handleLike(int $articleId): void
{
	$this->ratingService->saveLike($articleId, $this->user->id);
	if ($this->isAjax()) {
		$this->redrawControl('articlesContainer');
		// $this->redrawControl('article-' . $articleId); -- nie jest potrzebne
	} else {
		$this->redirect('this');
	}
}

Podobnie zmodyfikujemy również siostrzaną metodę handleUnlike(), i AJAX działa!

Rozwiązanie ma jednak jedną wadę. Gdybyśmy bardziej zbadali, jak przebiega żądanie AJAX, odkrylibyśmy, że chociaż na zewnątrz aplikacja wydaje się oszczędna (zwraca tylko jeden snippet dla danego artykułu), w rzeczywistości na serwerze wyrenderowała wszystkie snippety. Pożądany snippet umieściła w payloadzie, a pozostałe odrzuciła (całkowicie niepotrzebnie je również pobrała z bazy danych).

Aby zoptymalizować ten proces, będziemy musieli interweniować tam, gdzie przekazujemy do szablonu kolekcję $articles (powiedzmy w metodzie renderDefault()). Wykorzystamy fakt, że przetwarzanie sygnałów odbywa się przed metodami render<Something>:

public function handleLike(int $articleId): void
{
	// ...
	if ($this->isAjax()) {
		// ...
		$this->template->articles = [
			$this->db->table('articles')->get($articleId),
		];
	} else {
		// ...
}

public function renderDefault(): void
{
	if (!isset($this->template->articles)) {
		$this->template->articles = $this->db->table('articles');
	}
}

Teraz podczas przetwarzania sygnału do szablonu przekazywana jest zamiast kolekcji ze wszystkimi artykułami tylko tablica z jednym artykułem – tym, który chcemy wyrenderować i wysłać w payloadzie do przeglądarki. {foreach} przebiegnie więc tylko raz i żadne dodatkowe snippety się nie wyrenderują.

Ścieżka komponentów

Zupełnie inny sposób rozwiązania unika dynamicznych snippetów. Sztuczka polega na przeniesieniu całej logiki do osobnego komponentu – od teraz o wprowadzanie ocen nie będzie dbał presenter, ale dedykowany LikeControl. Klasa będzie wyglądać następująco (oprócz tego będzie zawierać również metody render, handleUnlike itd.):

class LikeControl extends Nette\Application\UI\Control
{
	public function __construct(
		private Article $article,
	) {
	}

	public function handleLike(): void
	{
		$this->ratingService->saveLike($this->article->id, $this->presenter->user->id);
		if ($this->presenter->isAjax()) {
			$this->redrawControl();
		} else {
			$this->presenter->redirect('this');
		}
	}
}

Szablon komponentu:

{snippet}
	{if !$article->liked}
		<a n:href="like!" class=ajax>lubię to</a>
	{else}
		<a n:href="unlike!" class=ajax>już mi się to nie podoba</a>
	{/if}
{/snippet}

Oczywiście zmieni nam się szablon widoku i do presentera będziemy musieli dodać fabrykę. Ponieważ komponent utworzymy tyle razy, ile artykułów pobierzemy z bazy danych, wykorzystamy do jego “rozmnożenia” klasę Multiplier.

protected function createComponentLikeControl()
{
	$articles = $this->db->table('articles');
	return new Nette\Application\UI\Multiplier(function (int $articleId) use ($articles) {
		return new LikeControl($articles[$articleId]);
	});
}

Szablon widoku zmniejszy się do niezbędnego minimum (i całkowicie pozbawiony snippetów!):

<article n:foreach="$articles as $article">
	<h2>{$article->title}</h2>
	<div class="content">{$article->content}</div>
	{control "likeControl-$article->id"}
</article>

Mamy prawie gotowe: aplikacja teraz będzie działać AJAXowo. Również tutaj czeka nas optymalizacja aplikacji, ponieważ ze względu na użycie Nette Database podczas przetwarzania sygnału niepotrzebnie ładowane są wszystkie artykuły z bazy danych zamiast jednego. Zaletą jest jednak to, że nie dojdzie do ich renderowania, ponieważ wyrenderuje się rzeczywiście tylko nasz komponent.