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

Довольно часто при разработке приложений возникает необходимость выполнения операций 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');
}

Template:

<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 в это простое приложение. Изменение рейтинга статьи не настолько важно, чтобы требовать HTTP-запрос с перенаправлением, поэтому в идеале это должно быть сделано с помощью 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-запрос, то обнаружим, что хотя приложение выглядит эффективным внешне (оно возвращает только один сниппет для данной статьи), на самом деле оно отображает все сниппеты на сервере. Он поместил нужный фрагмент в нашу полезную нагрузку, а остальные отбросил (таким образом, совершенно без необходимости, он также извлек их из базы данных).

Чтобы оптимизировать этот процесс, нам понадобится действие, при котором мы передаем коллекцию $articles шаблону (скажем, в методе renderDefault()). Мы воспользуемся тем, что обработка сигнала происходит до методов render<Something>:

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

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

Теперь, когда сигнал обрабатывается, вместо коллекции со всеми статьями в шаблон передается только массив с одной статьей — той, которую мы хотим отобразить и отправить в полезной нагрузке браузеру. Таким образом, {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->connection->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, обработка сигнала будет излишне загружать все статьи из базы данных вместо одной. Однако преимущество в том, что рендеринга не будет, потому что на самом деле рендерится только наш компонент.