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

Как с помощью AJAX обновлять только те части страницы, которые действительно меняются, например отдельные элементы списка, используя динамические сниппеты Latte.

Довольно часто при разработке приложения возникает необходимость выполнять 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>I like it</a>
	{else}
		<a n:href="unlike! $article->id" class=ajax>I don't like it anymore</a>
	{/if}
</article>

Добавление AJAX

Теперь добавим в это простое приложение функциональность AJAX. Изменение оценки статьи не настолько важно, чтобы ради него перезагружать страницу целиком, поэтому в идеале оно должно происходить в фоне через AJAX. Мы воспользуемся скриптом-обработчиком из дополнений с общепринятым соглашением, что у AJAX-ссылок есть CSS-класс ajax.

Но как именно это реализовать? Nette предлагает два подхода: динамические сниппеты и компоненты. У каждого есть свои плюсы и минусы, поэтому покажем оба.

Способ с динамическими сниппетами

В терминологии 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>I like it</a>
			{else}
				<a n:href="unlike! $article->id" class=ajax>I don't like it anymore</a>
			{/if}
		{/snippet}
	</article>
{/snippet}

Каждая статья теперь задаёт сниппет, в имя которого входит идентификатор статьи. Все эти динамические сниппеты вместе обёрнуты статическим сниппетом с именем 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>I like it</a>
	{else}
		<a n:href="unlike!" class=ajax>I don't like it anymore</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 обработка сигнала зря загружает из базы все статьи вместо одной нужной. Преимущество, однако, в том, что лишней отрисовки не происходит: отрисовывается только конкретный экземпляр компонента.