Dynamické snippety

Poměrně často při vývoji aplikací vyvstává potřeba provádět AJAXové operace například nad jednotlivými řádky tabulky či položkami seznamu. Pro příklad si můžeme zvolit výpis článků, přičemž u každého z nich umožníme přihlášenému uživateli zvolit hodnocení „líbí/nelíbí“. Kód presenteru a odpovídající šablony bez AJAXu bude vypadat přibližně následovně (uvádím nejdůležitější výseky, kód počítá s existencí služby pro značení si hodnocení a získáním kolekce článků – konkrétní implementace není pro účely tohoto návodu důležitá):

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');
}

Šablona:

<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>to se mi líbí</a>
	{else}
		<a n:href="unlike! $article->id" class=ajax>už se mi to nelíbí</a>
	{/if}
</article>

Ajaxizace

Pojďme nyní tuto jednoduchou aplikaci vybavit AJAXem. Změna hodnocení článku není natolik důležitá, aby muselo dojít k přesměrování, a proto by ideálně měla probíhat AJAXem na pozadí. Využijeme obslužného skriptu z doplňků s obvyklou konvencí, že AJAXové odkazy mají CSS třídu ajax.

Nicméně jak na to konkrétně? Nette nabízí 2 cesty: cestu tzv. dynamických snippetů a cestu komponent. Obě dvě mají svá pro a proti, a proto si je ukážeme jednu po druhé.

Cesta dynamických snippetů

Dynamický snippet znamená v terminologii Latte specifický případ užití makra {snippet}, kdy je v názvu snippetu použita proměnná. Takový snippet se nemůže v šabloně nalézat jen tak kdekoliv – musí být obalen statickým snippetem, tj. obyčejným, nebo uvnitř {snippetArea}. Naši šablonu bychom mohli upravit následovně.

{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>to se mi líbí</a>
			{else}
				<a n:href="unlike! $article->id" class=ajax>už se mi to nelíbí</a>
			{/if}
		{/snippet}
	</article>
{/snippet}

Každý článek nyní definuje jeden snippet, který má v názvu ID článku. Všechny tyto snippety jsou pak dohromady zabalené jedním snippetem s názvem articlesContainer. Pokud bychom tento obalující snippet opomněli, Latte nás na to upozorní výjimkou.

Zbývá nám doplnit do presenteru překreslení – stačí překreslit statickou obálku.

public function handleLike(int $articleId): void
{
	$this->ratingService->saveLike($articleId, $this->user->id);
	if ($this->isAjax()) {
		$this->redrawControl('articlesContainer');
		// $this->redrawControl('article-' . $articleId); -- není potřeba
	} else {
		$this->redirect('this');
	}
}

Nápodobně upravíme i sesterskou metodu handleUnlike(), a AJAX je funkční!

Řešení má však jednu stinnou stránku. Pokud bychom více zkoumali, jak AJAXový požadavek probíhá, zjistíme, že ačkoliv navenek se aplikace tváří úsporně (vrátí pouze jeden jediný snippet pro daný článek), ve skutečnosti na serveru vykreslila snippety všechny. Kýžený snippet nám umístila do payloadu, a ostatní zahodila (zcela zbytečně je tedy také získala z databáze).

Abychom tento proces zoptimalizovali, budeme muset zasáhnout tam, kde si do šablony předáváme kolekci $articles (dejme tomu v metodě renderDefault()). Využijeme faktu, že zpracování signálů probíhá před 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');
	}
}

Nyní se při zpracování signálu do šablony předá místo kolekce se všemi články jen pole s jediným článkem – tím, který chceme vykreslit a odeslat v payloadu do prohlížeče. {foreach} tedy proběhne jen jednou a žádné snippety navíc se nevykreslí.

Cesta komponent

Úplně jiný způsob řešení se dynamickým snippetům vyhne. Trik spočívá v přenesení celé logiky do zvláštní komponenty – o zadávání hodnocení se nám od teď nebude starat presenter, ale vyhrazená LikeControl. Třída bude vypadat následovně (kromě toho bude obsahovat i metody render, handleUnlike atd.):

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');
		}
	}
}

Šablona komponenty:

{snippet}
	{if !$article->liked}
		<a n:href="like!" class=ajax>to se mi líbí</a>
	{else}
		<a n:href="unlike!" class=ajax>už se mi to nelíbí</a>
	{/if}
{/snippet}

Samozřejmě se nám změní šablona view a do presenteru budeme muset doplnit továrničku. Protože komponentu vytvoříme tolikrát, kolik z databáze získáme článků, využijeme k jejímu „rozmnožení“ třídu 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]);
	});
}

Šablona view se zmenší na nezbytné minimum (a zcela prosté snippetů!):

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

Máme téměř hotovo: aplikace nyní bude fungovat AJAXově. I zde nás čeká aplikaci optimalizovat, protože vzhledem k použití Nette Database se při zpracování signálu zbytečně načtou všechny články z databáze namísto jednoho. Výhodou však je, že nedojde k jejich vykreslování, protože se vyrenderuje skutečně jen naše komponenta.