AJAX и сниппеты

В эпоху современных веб-приложений, где функциональность часто распределена между сервером и браузером, AJAX служит незаменимым связующим звеном. Какие возможности предлагает в этой области Nette Framework?

  • отправку частей шаблона, называемых сниппетами
  • передачу переменных между PHP и JavaScript
  • инструменты для отладки AJAX-запросов

AJAX-запрос

AJAX-запрос принципиально не отличается от обычного HTTP-запроса. Вызывается презентер с определёнными параметрами. Как ответить на запрос, решает сам презентер: он может вернуть данные в формате JSON, отправить часть HTML-кода, XML-документ и так далее.

На стороне браузера мы инициируем AJAX-запрос функцией fetch():

fetch(url, {
	headers: {'X-Requested-With': 'XMLHttpRequest'},
})
.then(response => response.json())
.then(payload => {
	// обрабатываем ответ
});

На стороне сервера AJAX-запрос распознаётся методом $httpRequest->isAjax() сервиса, упаковывающего HTTP-запрос. Для определения он использует HTTP-заголовок X-Requested-With, поэтому принципиально важно его отправлять. Внутри презентера можно использовать метод $this->isAjax().

Если вы хотите отправить данные в формате JSON, используйте метод sendJson(). Этот метод также завершает работу презентера.

public function actionExport(): void
{
	$this->sendJson($this->model->getData());
}

Если вы собираетесь ответить особым шаблоном, предназначенным для AJAX, это делается так:

public function handleClick($param): void
{
	if ($this->isAjax()) {
		$this->template->setFile('path/to/ajax.latte');
	}
	// ...
}

Сниппеты

Самый мощный инструмент, который Nette предлагает для связи сервера с клиентом, – это сниппеты. С их помощью вы можете превратить обычное приложение в AJAX-приложение минимальными усилиями и несколькими строками кода. Пример Fifteen показывает, как всё это работает, а его код можно найти на GitHub.

Сниппеты позволяют обновлять только части страницы вместо перезагрузки всей страницы. Это не только быстрее и эффективнее, но и удобнее для пользователя. Сниппеты могут напомнить вам Hotwire для Ruby on Rails или Symfony UX Turbo. Любопытно, что Nette представил сниппеты на 14 лет раньше.

Как работают сниппеты? При первой загрузке страницы (не-AJAX-запрос) загружается вся страница, включая все сниппеты. Когда пользователь взаимодействует со страницей (например, нажимает кнопку, отправляет форму и так далее), вместо перезагрузки всей страницы инициируется AJAX-запрос. Код в презентере выполняет действие и решает, какие сниппеты нужно обновить. Nette отрисовывает эти сниппеты и отправляет их как payload в формате JSON, содержащий массив со сниппетами. Обрабатывающий код в браузере затем вставляет полученные сниппеты обратно в страницу. Таким образом передаётся только код изменившихся сниппетов, что экономит трафик и ускоряет загрузку по сравнению с передачей всего содержимого страницы. Если ни один сниппет не был инвалидирован через redrawControl(), Nette возвращает всю страницу даже для AJAX-запроса: сниппеты отправляются, только когда что-то инвалидировано.

Naja

Для работы со сниппетами на стороне браузера используется библиотека Naja. Установите её как пакет Node.js (для использования со сборщиками вроде Webpack, Rollup, Vite, Parcel и других):

npm install naja

…или вставьте прямо в шаблон страницы:

<script src="https://unpkg.com/naja@3/dist/Naja.min.js"></script>

Сначала библиотеку нужно инициализировать:

naja.initialize();

Чтобы превратить обычную ссылку (сигнал) или отправку формы в AJAX-запрос, достаточно пометить соответствующую ссылку, форму или кнопку классом ajax:

<a n:href="go!" class="ajax">Go</a>

<form n:name="form" class="ajax">
    <input n:name="submit">
</form>

или

<form n:name="form">
    <input n:name="submit" class="ajax">
</form>

Перерисовка сниппетов

Каждый объект класса Control (включая сам презентер) следит за тем, произошли ли изменения, требующие перерисовки. Для этого служит метод redrawControl():

public function handleLogin(string $user): void
{
	// после входа нужно перерисовать соответствующую часть
	$this->redrawControl();
	// ...
}

Nette позволяет ещё точнее управлять тем, что нужно перерисовать. Метод может принять аргументом имя сниппета. Благодаря этому можно инвалидировать (то есть заставить перерисоваться) на уровне частей шаблона. Если инвалидирован весь компонент, будет перерисован и каждый сниппет внутри него:

// инвалидирует сниппет 'header'
$this->redrawControl('header');

Можно и отменить ожидающую инвалидацию вторым параметром $redraw: вызов $this->redrawControl('header', redraw: false) помечает сниппет как не нуждающийся в перерисовке. Полная сигнатура – redrawControl(?string $snippet = null, bool $redraw = true).

Сниппеты в Latte

Использовать сниппеты в Latte исключительно просто. Чтобы объявить часть шаблона сниппетом, достаточно обернуть её тегами {snippet} и {/snippet}:

{snippet header}
	<h1>Hello ... </h1>
{/snippet}

Сниппет создаёт в HTML-странице элемент <div> со специальным порождённым id. При перерисовке сниппета содержимое этого элемента обновляется. Поэтому необходимо, чтобы при первоначальной отрисовке страницы отрисовывались и все сниппеты, даже если поначалу они пусты.

Сниппет можно создать и на другом элементе, не <div>, с помощью n:атрибута:

<article n:snippet="header" class="foo bar">
	<h1>Hello ... </h1>
</article>

Области сниппетов

Имена сниппетов могут быть и выражениями:

{foreach $items as $id => $item}
	<li n:snippet="item-{$id}">{$item}</li>
{/foreach}

Сам по себе это неработающий промежуточный шаг: отрисованный вне статического {snippet} или {snippetArea}, динамический сниппет выдаёт E_USER_WARNING с сообщением Dynamic snippets are allowed only inside static snippet/snippetArea. Ниже мы это исправим.

Так создаётся несколько сниппетов вроде item-0, item-1 и так далее. Если бы мы напрямую инвалидировали динамический сниппет (например, item-1), не перерисовалось бы ничего. Причина в том, что сниппеты действительно работают как выдержки и напрямую отрисовываются только они сами. Однако в шаблоне технически нет сниппета с именем item-1. Он возникает только тогда, когда выполняется код вокруг сниппета, то есть цикл foreach. Поэтому мы помечаем ту часть шаблона, которая должна выполниться, тегом {snippetArea}:

<ul n:snippetArea="itemsContainer">
	{foreach $items as $id => $item}
		<li n:snippet="item-{$id}">{$item}</li>
	{/foreach}
</ul>

И запрашиваем перерисовку как отдельного сниппета, так и всей родительской области:

$this->redrawControl('itemsContainer');
$this->redrawControl('item-1');

Одновременно стоит позаботиться о том, чтобы в массиве $items были только те элементы, которые нужно перерисовать.

Если мы подключаем в главный шаблон другой шаблон со сниппетами через тег {include}, подключение шаблона нужно снова обернуть в snippetArea и инвалидировать её вместе со сниппетом:

{snippetArea include}
	{include 'included.latte'}
{/snippetArea}
{* included.latte *}
{snippet item}
	...
{/snippet}
$this->redrawControl('include');
$this->redrawControl('item');

Сниппеты в компонентах

Сниппеты можно создавать и внутри компонентов, и Nette будет автоматически их перерисовывать. Однако есть ограничение: для перерисовки сниппетов Nette вызывает метод render() без параметров. Поэтому передача параметров в шаблоне работать не будет:

OK
{control productGrid}

работать не будет:
{control productGrid $arg, $arg}
{control productGrid:paginator}

Отправка собственных данных

Вместе со сниппетами вы можете отправить клиенту любые дополнительные данные. Достаточно записать их в объект payload:

public function actionDelete(int $id): void
{
	// ...
	if ($this->isAjax()) {
		$this->payload->message = 'Success';
	}
}

Перенаправление

Во время AJAX-запроса методы redirect() и redirectUrl() не отправляют HTTP-перенаправление. Вместо этого они записывают целевой URL в payload (объект данных, отправляемый в AJAX-ответе), а именно в его свойство payload.redirect, и отправляют его; само перенаправление затем выполняет клиентская библиотека (Naja).

Передача параметров

Отправляя параметры компоненту через AJAX-запрос, будь то параметры сигнала или постоянные параметры, мы должны указать в запросе их глобальное имя, включающее имя компонента. Полное имя параметра возвращает метод getParameterId().

let url = new URL({link //foo!});
url.searchParams.set({$control->getParameterId('bar')}, bar);

fetch(url, {
	headers: {'X-Requested-With': 'XMLHttpRequest'},
})

И метод handle с соответствующими параметрами в компоненте:

public function handleFoo(int $bar): void
{
}

Дополнительные материалы

версия: 4.x