Динамические сниппеты
Как с помощью 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 обработка сигнала зря загружает из базы все статьи вместо одной нужной. Преимущество, однако, в том, что лишней отрисовки не происходит: отрисовывается только конкретный экземпляр компонента.