Динамични снипети

Доста често при разработването на приложения възниква необходимостта от извършване на AJAX операции, например върху отделни редове на таблица или елементи от списък. Като пример можем да вземем списък със статии, като за всяка от тях ще позволим на влезлия потребител да избере оценка “харесвам/не харесвам”. Кодът на презентера и съответният шаблон без AJAX ще изглеждат приблизително по следния начин (представям най-важните части, кодът предполага съществуването на сървис за маркиране на оценките и получаване на колекция от статии – конкретната реализация не е важна за целите на това ръководство):

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

Шаблон:

<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>харесвам</a>
	{else}
		<a n:href="unlike! $article->id" class=ajax>вече не ми харесва</a>
	{/if}
</article>

Ajaxизация

Нека сега оборудваме това просто приложение с AJAX. Промяната на оценката на статията не е толкова важна, че да изисква пренасочване, затова в идеалния случай тя трябва да се извършва чрез AJAX във фонов режим. Ще използваме обслужващия скрипт от добавките с обичайната конвенция, че AJAX връзките имат CSS клас ajax.

Но как да го направим конкретно? Nette предлага 2 начина: пътя на т.нар. динамични снипети и пътя на компонентите. И двата имат своите плюсове и минуси, затова ще ги покажем един по един.

Пътят на динамичните снипети

Динамичен снипет в терминологията на Latte означава специфичен случай на използване на макроса {snippet}, при който в името на снипета се използва променлива. Такъв снипет не може да се намира навсякъде в шаблона – той трябва да бъде обвит в статичен снипет, т.е. обикновен, или вътре в {snippetArea}. Можем да модифицираме нашия шаблон по следния начин.

{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>харесвам</a>
			{else}
				<a n:href="unlike! $article->id" class=ajax>вече не ми харесва</a>
			{/if}
		{/snippet}
	</article>
{/snippet}

Всяка статия сега дефинира един снипет, който има ID на статията в името си. Всички тези снипети след това са обвити заедно в един снипет с име articlesContainer. Ако пропуснем този обвиващ снипет, Latte ще ни предупреди с изключение.

Остава ни да добавим прерисуването в презентера – достатъчно е да прерисуваме статичната обвивка.

public function handleLike(int $articleId): void
{
	$this->ratingService->saveLike($articleId, $this->user->id);
	if ($this->isAjax()) {
		$this->redrawControl('articlesContainer');
		// $this->redrawControl('article-' . $articleId); -- не е необходимо
	} else {
		$this->redirect('this');
	}
}

По същия начин модифицираме и сестринския метод handleUnlike(), и AJAX работи!

Решението обаче има и една тъмна страна. Ако разгледаме по-подробно как протича AJAX заявката, ще открием, че въпреки че приложението изглежда икономично отвън (връща само един снипет за дадената статия), всъщност на сървъра то е рендирало всички снипети. Желаният снипет е поставен в payload-а, а останалите са изхвърлени (следователно са били извлечени от базата данни напълно ненужно).

За да оптимизираме този процес, ще трябва да се намесим там, където предаваме колекцията $articles към шаблона (да речем в метода renderDefault()). Ще използваме факта, че обработката на сигналите се извършва преди методите 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');
	}
}

Сега, при обработката на сигнала, вместо колекция с всички статии, към шаблона се предава само масив с една статия – тази, която искаме да рендираме и изпратим в payload-а към браузъра. Следователно {foreach} ще се изпълни само веднъж и няма да се рендират допълнителни снипети.

Пътят на компонентите

Напълно различен начин на решаване избягва динамичните снипети. Трикът се състои в прехвърлянето на цялата логика в отделен компонент – отсега нататък въвеждането на оценки няма да се обработва от презентера, а от специализирания LikeControl. Класът ще изглежда по следния начин (освен това ще съдържа и методите render, handleUnlike и т.н.):

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

Шаблон на компонента:

{snippet}
	{if !$article->liked}
		<a n:href="like!" class=ajax>харесвам</a>
	{else}
		<a n:href="unlike!" class=ajax>вече не ми харесва</a>
	{/if}
{/snippet}

Разбира се, шаблонът на изгледа ще се промени и ще трябва да добавим фабрика към презентера. Тъй като ще създадем компонента толкова пъти, колкото статии получим от базата данни, ще използваме класа 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]);
	});
}

Шаблонът на изгледа се свежда до необходимия минимум (и е напълно лишен от снипети!):

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

Почти сме готови: приложението вече ще работи с AJAX. И тук трябва да оптимизираме приложението, защото поради използването на Nette Database, при обработката на сигнала ненужно се зареждат всички статии от базата данни вместо само една. Предимството обаче е, че те няма да бъдат рендирани, тъй като ще се рендира само нашият компонент.