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