Dinamikus Snippetek

Az alkalmazásfejlesztés során meglehetősen gyakran felmerül az igény AJAX műveletek végrehajtására, például táblázatok egyes sorain vagy listaelemeken. Példaként választhatjuk a cikkek listázását, ahol minden cikknél lehetővé tesszük a bejelentkezett felhasználó számára, hogy “tetszik/nem tetszik” értékelést adjon. A presenter és a hozzá tartozó sablon kódja AJAX nélkül körülbelül így fog kinézni (a legfontosabb részeket mutatom be, a kód számol az értékelések jelölésére szolgáló szolgáltatás létezésével és a cikkek gyűjteményének megszerzésével – a konkrét implementáció nem fontos ennek az útmutatónak a céljaihoz):

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

Sablon:

<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>tetszik</a>
	{else}
		<a n:href="unlike! $article->id" class=ajax>már nem tetszik</a>
	{/if}
</article>

Ajaxizálás

Most lássuk el ezt az egyszerű alkalmazást AJAX-szal. A cikk értékelésének megváltoztatása nem annyira fontos, hogy átirányításra legyen szükség, ezért ideális esetben AJAX-szal kellene történnie a háttérben. Használjuk a kiegészítők kiszolgáló szkriptjét a szokásos konvencióval, miszerint az AJAX linkeknek ajax CSS osztályuk van.

De hogyan is csináljuk ezt konkrétan? A Nette 2 utat kínál: az ún. dinamikus snippetek útját és a komponensek útját. Mindkettőnek megvannak az előnyei és hátrányai, ezért egyenként bemutatjuk őket.

A dinamikus snippetek útja

A dinamikus snippet a Latte terminológiájában a {snippet} tag egy speciális használati esetét jelenti, amikor a snippet nevében egy változó szerepel. Egy ilyen snippet nem lehet bárhol a sablonban – egy statikus snippetbe, azaz egy közönséges snippetbe vagy egy {snippetArea}-ba kell csomagolni. A sablonunkat a következőképpen módosíthatnánk.

{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>tetszik</a>
			{else}
				<a n:href="unlike! $article->id" class=ajax>már nem tetszik</a>
			{/if}
		{/snippet}
	</article>
{/snippet}

Most minden cikk definiál egy snippetet, amelynek nevében a cikk ID-ja szerepel. Mindezeket a snippeket aztán egyetlen, articlesContainer nevű snippetbe csomagoljuk. Ha ezt a csomagoló snippetet kihagynánk, a Latte kivétellel figyelmeztetne minket.

Már csak a presenterben kell kiegészítenünk az újrarajzolást – elég a statikus burkolót újrarajzolni.

public function handleLike(int $articleId): void
{
	$this->ratingService->saveLike($articleId, $this->user->id);
	if ($this->isAjax()) {
		$this->redrawControl('articlesContainer');
		// $this->redrawControl('article-' . $articleId); -- nem szükséges
	} else {
		$this->redirect('this');
	}
}

Hasonlóképpen módosítjuk a testvér handleUnlike() metódust is, és az AJAX működik!

A megoldásnak azonban van egy árnyoldala. Ha jobban megvizsgálnánk, hogyan zajlik az AJAX kérés, rájönnénk, hogy bár kifelé az alkalmazás takarékosnak tűnik (csak egyetlen snippetet ad vissza az adott cikkhez), valójában a szerveren az összes snippetet kirajzolta. A kívánt snippetet a payloadba helyezte, a többit pedig eldobta (tehát teljesen feleslegesen szerezte be őket az adatbázisból is).

Ahhoz, hogy ezt a folyamatot optimalizáljuk, ott kell beavatkoznunk, ahol a $articles gyűjteményt átadjuk a sablonnak (mondjuk a renderDefault() metódusban). Kihasználjuk azt a tényt, hogy a signálok feldolgozása a render<Something> metódusok előtt történik:

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

Most a signál feldolgozásakor a sablonba az összes cikket tartalmazó gyűjtemény helyett csak egy tömb kerül átadásra egyetlen cikkel – azzal, amelyet ki akarunk rajzolni és a payloadban elküldeni a böngészőnek. A {foreach} tehát csak egyszer fut le, és nem rajzolódnak ki felesleges snippettek.

A komponensek útja

Egy teljesen más megoldási mód elkerüli a dinamikus snippetteket. A trükk abban rejlik, hogy az egész logikát egy külön komponensbe helyezzük át – az értékelések megadásától kezdve nem a presenter fog gondoskodni, hanem egy dedikált LikeControl. Az osztály a következőképpen fog kinézni (ezen kívül tartalmazni fogja a render, handleUnlike stb. metódusokat is):

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

A komponens sablonja:

{snippet}
	{if !$article->liked}
		<a n:href="like!" class=ajax>tetszik</a>
	{else}
		<a n:href="unlike!" class=ajax>már nem tetszik</a>
	{/if}
{/snippet}

Természetesen megváltozik a view sablonja, és a presenterbe be kell illesztenünk egy factory-t. Mivel a komponenst annyiszor hozzuk létre, ahány cikket lekérünk az adatbázisból, a “sokszorosításához” a Multiplier osztályt használjuk.

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

A view sablonja a szükséges minimumra csökken (és teljesen mentes a snippettektől!):

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

Majdnem készen vagyunk: az alkalmazás mostantól AJAX-osan fog működni. Itt is optimalizálnunk kell az alkalmazást, mert a Nette Database használata miatt a signál feldolgozásakor feleslegesen betöltődik az összes cikk az adatbázisból egy helyett. Előnye azonban, hogy nem kerülnek kirajzolásra, mert valóban csak a mi komponensünk renderelődik.