AJAX и сниппеты

Современные веб-приложения сегодня работают наполовину на сервере, а наполовину в браузере. AJAX является жизненно важным объединяющим фактором. Какую поддержку предлагает фреймворк Nette?

  • отправка фрагментов шаблонов (так называемых сниппетов)
  • передача переменных между PHP и JavaScript
  • Отладка приложений AJAX

AJAX-запрос может быть обнаружен с помощью метода сервиса инкапсуляция HTTP-запроса $httpRequest->isAjax() (определяет на основе HTTP-заголовка X-Requested-With). Существует также сокращенный метод в презентере: $this->isAjax().

AJAX-запрос ничем не отличается от обычного — вызывается презентер с определенным представлением и параметрами. От презентера также зависит, как он отреагирует: он может использовать свои процедуры для возврата фрагмента HTML-кода (сниппета), XML-документа, объекта JSON или фрагмента кода Javascript.

Nette не реализует AJAX-запросы на стороне клиента. Мы рекомендуем библиотеку Naja или фреймворк Nittro.

Существует предварительно обработанный объект payload, предназначенный для отправки данных в браузер в формате JSON.

public function actionDelete(int $id): void
{
	if ($this->isAjax()) {
		$this->payload->message = 'Успешно';
	}
	// ...
}

Для полного контроля над выводом JSON используйте метод sendJson в презентере. Это немедленно прервет работу презентера, и вы обойдетесь без шаблона:

$this->sendJson(['key' => 'value', /* ... */]);

Если мы хотим отправить HTML, мы можем установить специальный шаблон для AJAX-запросов:

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

Naja

Библиотека Naja используется для обработки AJAX-запросов на стороне браузера. Установите его как пакет node.js (для использования с Webpack, Rollup, Vite, Parcel и другими):

npm install naja

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

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

Сниппеты

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

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

Сниппеты могут напомнить вам Hotwire для Ruby on Rails или Symfony UX Turbo, но Nette придумал их четырнадцатью годами раньше.

Инвалидация

Каждый потомок класса Control (которым является и Presenter) способен помнить, были ли какие-либо изменения во время запроса, требующие повторного отображения. Существует несколько способов справиться с этим: redrawControl() и isControlInvalid(). Пример:

public function handleLogin(string $user): void
{
	// Объект должен повторно отображаться после того, как пользователь вошел в систему
	$this->redrawControl();
	// ...
}

Однако Nette обеспечивает ещё более тонкое разрешение, чем целые компоненты. Перечисленные методы принимают имя так называемого «фрагмента» в качестве необязательного параметра. «Фрагмет» это, по сути, элемент в вашем шаблоне, помеченный для этой цели макросом Latte, подробнее об этом позже. Таким образом, можно попросить компонент перерисовать только часть своего шаблона. Если весь компонент недействителен, то все его фрагменты отображаются заново. Компонент является «недействительным», если любой из его субкомпонентов является недействительным.

$this->isControlInvalid(); // -> false

$this->redrawControl('header'); // аннулирует фрагмент с именем 'header'
$this->isControlInvalid('header'); // -> true
$this->isControlInvalid('footer'); // -> false
$this->isControlInvalid(); // -> true, по крайней мере один фрагмент недействителен

$this->redrawControl(); // делает недействительным весь компонент, каждый фрагмент
$this->isControlInvalid('footer'); // -> true

Компонент, получивший сигнал, автоматически помечается для перерисовки.

Благодаря перерисовке фрагментов мы точно знаем, какие части каких элементов должны быть перерисованы.

Тег {snippet} … {/snippet}

Рендеринг страницы происходит точно так же, как и при обычном запросе: загружаются одни и те же шаблоны и т. д. Однако самое важное — это не допустить попадания в выходной сигнал тех частей, которые не должны попасть в выходной сигнал; остальные части должны быть связаны с идентификатором и отправлены пользователю в формате, понятном для обработчика JavaScript.

Синтаксис

Если в шаблоне есть элемент управления или фрагмент, мы должны обернуть его с помощью парного тега {snippet} ... {/snippet} — отрисованный фрагмент будет «вырезан» и отправится в браузер. Он также заключит его в вспомогательный тег <div> (можно использовать другой). В следующем примере определен сниппет с именем header. Он также может представлять собой шаблон компонента:

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

Если вы хотите создать сниппет с другим содержащим элементом, отличным от <div>, или добавить пользовательские атрибуты к элементу, вы можете использовать следующее определение:

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

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

В Nette вы также можете определить сниппеты с динамическим именем, основанным на параметре времени выполнения. Это наиболее подходит для различных списков, где нам нужно изменить только одну строку, но мы не хотим переносить весь список вместе с ней. Примером этого может быть:

<ul n:snippet="itemsContainer">
	{foreach $list as $id => $item}
		<li n:snippet="item-$id">{$item} <a class="ajax" n:href="update! $id">обновить</a></li>
	{/foreach}
</ul>

Существует один статический сниппет itemsContainer, содержащий несколько динамических сниппетов: пункт-0, пункт-1 и так далее.

Вы не можете перерисовать динамический фрагмент напрямую (перерисовка item-1 не имеет эффекта), вы должны перерисовать его родительский фрагмент (в данном примере itemsContainer). При этом выполняется код родительского сниппета, но браузеру передаются только его вложенные сниппеты. Если вы хотите передать только один из вложенных сниппетов, вам нужно изменить ввод для родительского сниппета, чтобы не генерировать другие вложенные сниппеты.

В приведенном примере необходимо убедиться, что при AJAX-запросе в массив $list будет добавлен только один элемент, поэтому цикл foreach будет выводить только один динамический фрагмент.

class HomepagePresenter extends Nette\Application\UI\Presenter
{
	/**
	 * Этот метод возвращает данные для списка.
	 * Обычно это просто запрос данных из модели.
	 * Для целей этого примера данные жёстко закодированы.
	 */
	private function getTheWholeList(): array
	{
		return [
			'First',
			'Second',
			'Third',
		];
	}

	public function renderDefault(): void
	{
		if (!isset($this->template->list)) {
			$this->template->list = $this->getTheWholeList();
		}
	}

	public function handleUpdate(int $id): void
	{
		$this->template->list = $this->isAjax()
				? []
				: $this->getTheWholeList();
		$this->template->list[$id] = 'Updated item';
		$this->redrawControl('itemsContainer');
	}
}

Сниппеты во включенном шаблоне

Может случиться так, что сниппет находится в шаблоне, который включается из другого шаблона. В этом случае необходимо обернуть код включения во втором шаблоне макросом snippetArea, затем перерисовать как snippetArea, так и сам сниппет.

Макрос snippetArea гарантирует, что код внутри него будет выполнен, но браузеру будет отправлен только фактический фрагмент включенного шаблона.

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

Вы также можете сочетать его с динамическими сниппетами.

Добавление и удаление

Если добавить новый элемент в список и аннулировать itemsContainer, AJAX-запрос вернет фрагменты, включая новый, но javascript-обработчик не сможет его отобразить. Это происходит потому, что нет HTML-элемента с вновь созданным ID.

В этом случае самый простой способ — обернуть весь список в ещё один сниппет и признать его недействительным:

{snippet wholeList}
<ul n:snippet="itemsContainer">
	{foreach $list as $id => $item}
	<li n:snippet="item-$id">{$item} <a class="ajax" n:href="update! $id">обновить</a></li>
	{/foreach}
</ul>
{/snippet}
<a class="ajax" n:href="add!">Добавить</a>
public function handleAdd(): void
{
	$this->template->list = $this->getTheWholeList();
	$this->template->list[] = 'New one';
	$this->redrawControl('wholeList');
}

То же самое относится и к удалению элемента. Можно было бы отправить пустой сниппет, но обычно списки могут быть постраничными, и было бы сложно реализовать удаление одного элемента и загрузку другого (который раньше находился на другой странице постраничного списка).

Отправка параметров компоненту

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

$.getJSON(
	{link changeCountBasket!},
	{
		{$control->getParameterId('id')}: id,
		{$control->getParameterId('count')}: count
	}
});

И обработать метод с соответствующими параметрами в компоненте.

public function handleChangeCountBasket(int $id, int $count): void
{

}