Интерактивные компоненты

Компоненты — это отдельные повторно используемые объекты, которые мы вставляем на страницы. Это могут быть формы, датагриды, опросы, в общем, все, что имеет смысл использовать повторно. Мы покажем:

  • как использовать компоненты?
  • как их писать?
  • что такое сигналы?

Nette имеет встроенную систему компонентов. Что-то подобное могут помнить старожилы из Delphi или ASP.NET Web Forms, на чем-то отдаленно похожем построены React или Vue.js. Однако в мире PHP-фреймворков это уникальное явление.

При этом компоненты кардинально влияют на подход к созданию приложений. Вы можете собирать страницы из готовых блоков. Нужен датагрид в админке? Найдите его на Componette, репозитории open-source дополнений (то есть не только компонентов) для Nette, и просто вставьте в презентер.

В презентер можно включить любое количество компонентов. А в некоторые компоненты можно вставлять другие компоненты. Таким образом, создается дерево компонентов, корнем которого является презентер.

Фабричные методы

Как компоненты вставляются в презентер и затем используются? Обычно с помощью фабричных методов.

Фабрика компонентов представляет собой элегантный способ создания компонентов только тогда, когда они действительно необходимы (lazy / on demand). Все волшебство заключается в реализации метода с именем createComponent<Name>(), где <Name> — это имя создаваемого компонента, и который создает и возвращает компонент.

class DefaultPresenter extends Nette\Application\UI\Presenter
{
	protected function createComponentPoll(): PollControl
	{
		$poll = new PollControl;
		$poll->items = $this->item;
		return $poll;
	}
}

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

Имена компонентов всегда начинаются с маленькой буквы, хотя в названии метода они пишутся с большой.

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

// обращаемся к компоненту, и если это было впервые,
// вызывается createComponentPoll(), который его создает
$poll = $this->getComponent('poll');
// альтернативный синтаксис: $poll = $this['poll'];

В шаблоне можно отрисовать компонент с помощью тега {control}. Поэтому нет необходимости вручную передавать компоненты в шаблон.

<h2>Голосуйте</h2>

{control poll}

Стиль Голливуда

Компоненты обычно используют одну свежую технику, которую мы любим называть стилем Голливуда. Вы наверняка знаете крылатую фразу, которую так часто слышат участники кинопроб: «Не звоните нам, мы вам позвоним». Именно об этом и идет речь.

В Nette вместо того, чтобы постоянно спрашивать («была ли отправлена форма?», «было ли это валидно?» или «нажал ли пользователь эту кнопку?»), вы говорите фреймворку «когда это произойдет, вызови этот метод» и оставляете дальнейшую работу ему. Если вы программируете на JavaScript, этот стиль программирования вам хорошо знаком. Вы пишете функции, которые вызываются, когда происходит определенное событие. И язык передает им соответствующие параметры.

Это полностью меняет взгляд на написание приложений. Чем больше задач вы можете оставить фреймворку, тем меньше у вас работы. И тем меньше вы можете что-то упустить.

Пишем компонент

Под понятием компонент обычно подразумевается потомок класса Nette\Application\UI\Control. (Точнее было бы использовать термин «controls», но «контролы» в русском языке имеют совершенно другое значение, и скорее прижились «компоненты».) Сам презентер Nette\Application\UI\Presenter также является потомком класса Control.

use Nette\Application\UI\Control;

class PollControl extends Control
{
}

Отрисовка

Мы уже знаем, что для отрисовки компонента используется тег {control componentName}. Он фактически вызывает метод render() компонента, в котором мы заботимся об отрисовке. В нашем распоряжении, точно так же, как и в презентере, есть шаблон Latte в переменной $this->template, в который мы передаем параметры. В отличие от презентера, мы должны указать файл с шаблоном и заставить его отрисоваться:

public function render(): void
{
	// вставляем в шаблон какие-то параметры
	$this->template->param = $value;
	// и отрисовываем его
	$this->template->render(__DIR__ . '/poll.latte');
}

Тег {control} позволяет передать параметры в метод render():

{control poll $id, $message}
public function render(int $id, string $message): void
{
	// ...
}

Иногда компонент может состоять из нескольких частей, которые мы хотим отрисовывать отдельно. Для каждой из них мы создадим собственный метод отрисовки, здесь в примере, например, renderPaginator():

public function renderPaginator(): void
{
	// ...
}

А в шаблоне мы затем вызовем его с помощью:

{control poll:paginator}

Для лучшего понимания полезно знать, как этот тег переводится в PHP.

{control poll}
{control poll:paginator 123, 'hello'}

переводится как:

$control->getComponent('poll')->render();
$control->getComponent('poll')->renderPaginator(123, 'hello');

Метод getComponent() возвращает компонент poll, и над этим компонентом вызывается метод render(), соответственно renderPaginator(), если в теге после двоеточия указан другой способ рендеринга.

Внимание, если где-либо в параметрах появится =>, все параметры будут упакованы в массив и переданы как первый аргумент:

{control poll, id: 123, message: 'hello'}

переводится как:

$control->getComponent('poll')->render(['id' => 123, 'message' => 'hello']);

Отрисовка суб-компонента:

{control cartControl-someForm}

переводится как:

$control->getComponent("cartControl-someForm")->render();

Компоненты, так же как и презентеры, автоматически передают в шаблоны несколько полезных переменных:

  • $basePath — абсолютный URL-путь к корневому каталогу (например, /eshop)
  • $baseUrl — абсолютный URL к корневому каталогу (например, http://localhost/eshop)
  • $user — объект, представляющий пользователя
  • $presenter — текущий презентер
  • $control — текущий компонент
  • $flashes — массив сообщений, отправленных функцией flashMessage()

Сигнал

Мы уже знаем, что навигация в приложении Nette заключается в ссылках или перенаправлениях на пары Presenter:action. Но что, если мы просто хотим выполнить действие на текущей странице? Например, изменить сортировку столбцов в таблице; удалить элемент; переключить светлый/темный режим; отправить форму; проголосовать в опросе; и т. д.

Этот тип запросов называется сигналами. И подобно тому, как действия вызывают методы action<Action>() или render<Action>(), сигналы вызывают методы handle<Signal>(). В то время как понятие действия (или view) связано исключительно с презентерами, сигналы относятся ко всем компонентам. И, следовательно, и к презентерам, потому что UI\Presenter является потомком UI\Control.

public function handleClick(int $x, int $y): void
{
	// ... обработка сигнала ...
}

Ссылку, которая вызывает сигнал, мы создаем обычным способом, то есть в шаблоне атрибутом n:href или тегом {link}, в коде методом link(). Подробнее в главе Создание URL-ссылок.

<a n:href="click! $x, $y">нажмите здесь</a>

Сигнал всегда вызывается на текущем презентере и action, невозможно вызвать его на другом презентере или другом action.

Таким образом, сигнал вызывает перезагрузку страницы точно так же, как при первоначальном запросе, только дополнительно вызывает метод обработки сигнала с соответствующими параметрами. Если метод не существует, выбрасывается исключение Nette\Application\UI\BadSignalException, которое отображается пользователю как страница ошибки 403 Forbidden.

Сниппеты и AJAX

Сигналы могут немного напомнить вам AJAX: обработчики, которые вызываются на текущей странице. И вы правы, сигналы действительно часто вызываются с помощью AJAX, и затем мы передаем в браузер только измененные части страницы. То есть так называемые сниппеты. Дополнительную информацию можно найти на странице, посвященной AJAX.

Flash-сообщения

Компонент имеет собственное хранилище flash-сообщений, независимое от презентера. Это сообщения, которые, например, информируют о результате операции. Важной особенностью flash-сообщений является то, что они доступны в шаблоне даже после перенаправления. Даже после отображения они остаются активными еще 30 секунд — например, на случай, если из-за ошибки передачи пользователь обновит страницу — сообщение не исчезнет сразу.

Отправку обеспечивает метод flashMessage. Первым параметром является текст сообщения или объект stdClass, представляющий сообщение. Необязательным вторым параметром является его тип (error, warning, info и т. д.). Метод flashMessage() возвращает экземпляр flash-сообщения как объект stdClass, к которому можно добавлять дополнительную информацию.

$this->flashMessage('Элемент был удален.');
$this->redirect(/* ... */); // и перенаправляем

В шаблоне эти сообщения доступны в переменной $flashes как объекты stdClass, которые содержат свойства message (текст сообщения), type (тип сообщения) и могут содержать уже упомянутую пользовательскую информацию. Отрисуем их, например, так:

{foreach $flashes as $flash}
	<div class="flash {$flash->type}">{$flash->message}</div>
{/foreach}

Перенаправление после сигнала

После обработки сигнала компонента часто следует перенаправление. Это похожая ситуация, как с формами — после их отправки мы также перенаправляем, чтобы при обновлении страницы в браузере не произошло повторной отправки данных.

$this->redirect('this') // перенаправляет на текущий презентер и action

Поскольку компонент является повторно используемым элементом и обычно не должен иметь прямой связи с конкретными презентерами, методы redirect() и link() автоматически интерпретируют параметр как сигнал компонента:

$this->redirect('click') // перенаправляет на сигнал 'click' того же компонента

Если вам нужно перенаправить на другой презентер или действие, вы можете сделать это через презентер:

$this->getPresenter()->redirect('Product:show'); // перенаправляет на другой презентер/action

Персистентные параметры

Персистентные параметры служат для поддержания состояния в компонентах между различными запросами. Их значение остается неизменным даже после нажатия на ссылку. В отличие от данных в сессии, они передаются в URL. И это происходит полностью автоматически, включая ссылки, созданные в других компонентах на той же странице.

Например, у вас есть компонент для постраничной навигации контента. Таких компонентов на странице может быть несколько. И мы хотим, чтобы после нажатия на ссылку все компоненты остались на своей текущей странице. Поэтому мы сделаем номер страницы (page) персистентным параметром.

Создание персистентного параметра в Nette невероятно просто. Достаточно создать публичное свойство и пометить его атрибутом: (ранее использовалось /** @persistent */)

use Nette\Application\Attributes\Persistent;  // эта строка важна

class PaginatingControl extends Control
{
	#[Persistent]
	public int $page = 1; // должно быть public
}

У свойства рекомендуется указывать тип данных (например, int), и вы можете указать значение по умолчанию. Значения параметров можно валидировать.

При создании ссылки можно изменить значение персистентного параметра:

<a n:href="this page: $page + 1">следующая</a>

Или его можно сбросить, то есть удалить из URL. Тогда он примет свое значение по умолчанию:

<a n:href="this page: null">сбросить</a>

Персистентные компоненты

Не только параметры, но и компоненты могут быть персистентными. У такого компонента его персистентные параметры передаются и между различными действиями презентера, или между несколькими презентерами. Персистентные компоненты помечаются аннотацией у класса презентера. Например, так мы пометим компоненты calendar и poll:

/**
 * @persistent(calendar, poll)
 */
class DefaultPresenter extends Nette\Application\UI\Presenter
{
}

Подкомпоненты внутри этих компонентов помечать не нужно, они также станут персистентными.

В PHP 8 вы можете использовать атрибуты для обозначения персистентных компонентов:

use Nette\Application\Attributes\Persistent;

#[Persistent('calendar', 'poll')]
class DefaultPresenter extends Nette\Application\UI\Presenter
{
}

Компоненты с зависимостями

Как создавать компоненты с зависимостями, не «засоряя» презентеры, которые их будут использовать? Благодаря умным свойствам DI-контейнера в Nette, так же как при использовании классических сервисов, можно оставить большую часть работы фреймворку.

Возьмем в качестве примера компонент, который имеет зависимость от сервиса PollFacade:

class PollControl extends Control
{
	public function __construct(
		private int $id, //  Id опроса, для которого мы создаем компонент
		private PollFacade $facade,
	) {
	}

	public function handleVote(int $voteId): void
	{
		$this->facade->vote($this->id, $voteId);
		// ...
	}
}

Если бы мы писали классический сервис, проблем бы не было. О передаче всех зависимостей невидимо позаботился бы DI-контейнер. Однако с компонентами мы обычно обращаемся так, что создаем их новый экземпляр прямо в презентере в фабричных методах createComponent…(). Но передавать все зависимости всех компонентов в презентер, чтобы затем передать их компонентам, громоздко. И сколько написанного кода…

Логичный вопрос: почему бы просто не зарегистрировать компонент как классический сервис, не передать его в презентер и затем в методе createComponent…() не возвращать? Такой подход, однако, неуместен, потому что мы хотим иметь возможность создавать компонент даже несколько раз.

Правильным решением является написание для компонента фабрики, то есть класса, который нам создаст компонент:

class PollControlFactory
{
	public function __construct(
		private PollFacade $facade,
	) {
	}

	public function create(int $id): PollControl
	{
		return new PollControl($id, $this->facade);
	}
}

Так мы зарегистрируем фабрику в нашем контейнере в конфигурации:

services:
	- PollControlFactory

и, наконец, используем ее в нашем презентере:

class PollPresenter extends Nette\Application\UI\Presenter
{
	public function __construct(
		private PollControlFactory $pollControlFactory,
	) {
	}

	protected function createComponentPollControl(): PollControl
	{
		$pollId = 1; // можем передать наш параметр
		return $this->pollControlFactory->create($pollId);
	}
}

Замечательно то, что Nette DI умеет генерировать такие простые фабрики, поэтому вместо всего ее кода достаточно написать только ее интерфейс:

interface PollControlFactory
{
	public function create(int $id): PollControl;
}

И это все. Nette внутренне реализует этот интерфейс и передаст его в презентер, где мы уже можем его использовать. Он волшебным образом добавит в наш компонент и параметр $id, и экземпляр класса PollFacade.

Компоненты в глубину

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

  1. он может быть отрисован в шаблоне
  2. он знает, какую свою часть нужно отрисовать при AJAX-запросе (сниппеты)
  3. он имеет возможность сохранять свое состояние в URL (персистентные параметры)
  4. он имеет возможность реагировать на действия пользователя (сигналы)
  5. он создает иерархическую структуру (где корнем является презентер)

Каждую из этих функций обеспечивает один из классов иерархии наследования. За отрисовку (1 + 2) отвечает Nette\Application\UI\Control, за включение в жизненный цикл (3, 4) — класс Nette\Application\UI\Component, а за создание иерархической структуры (5) — классы Container и Component.

Nette\ComponentModel\Component  { IComponent }
|
+- Nette\ComponentModel\Container  { IContainer }
	|
	+- Nette\Application\UI\Component  { SignalReceiver, StatePersistent }
		|
		+- Nette\Application\UI\Control  { Renderable }
			|
			+- Nette\Application\UI\Presenter  { IPresenter }

Жизненный цикл компонента

Жизненный цикл компонента

Валидация персистентных параметров

Значения персистентных параметров, полученные из URL, записываются в свойства методом loadState(). Он также проверяет, соответствует ли тип данных, указанный у свойства, иначе отвечает ошибкой 404, и страница не отображается.

Никогда слепо не доверяйте персистентным параметрам, потому что они могут быть легко изменены пользователем в URL. Так, например, мы проверим, что номер страницы $this->page больше 0. Подходящий способ — переопределить упомянутый метод loadState():

class PaginatingControl extends Control
{
	#[Persistent]
	public int $page = 1;

	public function loadState(array $params): void
	{
		parent::loadState($params); // здесь устанавливается $this->page
		// следует собственная проверка значения:
		if ($this->page < 1) {
			$this->error();
		}
	}
}

Обратный процесс, то есть сбор значений из персистентных свойств, отвечает метод saveState().

Сигналы в глубину

Сигнал вызывает перезагрузку страницы точно так же, как при первоначальном запросе (кроме случая, когда он вызывается AJAX-ом), и вызывает метод signalReceived($signal), чья реализация по умолчанию в классе Nette\Application\UI\Component пытается вызвать метод, составленный из слов handle{signal}. Дальнейшая обработка зависит от данного объекта. Объекты, наследующие от Component (т. е. Control и Presenter), реагируют так, что пытаются вызвать метод handle{signal} с соответствующими параметрами.

Другими словами: берется определение функции handle{signal} и все параметры, пришедшие с запросом, и к аргументам по имени подставляются параметры из URL, и делается попытка вызвать данный метод. Например, в качестве параметра $id передается значение из параметра id в URL, в качестве $something передается something из URL и т. д. И если метод не существует, метод signalReceived выбрасывает исключение.

Сигнал может принимать любой компонент, презентер или объект, который реализует интерфейс SignalReceiver и подключен к дереву компонентов.

Основными получателями сигналов будут Presenters и визуальные компоненты, наследующие от Control. Сигнал должен служить знаком для объекта, что он должен что-то сделать — опрос должен засчитать голос пользователя, блок с новостями должен развернуться и показать в два раза больше новостей, форма была отправлена и должна обработать данные и т. п.

URL для сигнала создаем с помощью метода Component::link(). В качестве параметра $destination передаем строку {signal}! и в качестве $args — массив аргументов, которые мы хотим передать сигналу. Сигнал всегда вызывается на текущем презентере и action с текущими параметрами, параметры сигнала просто добавляются. Кроме того, в самом начале добавляется параметр ?do, который определяет сигнал.

Его формат — либо {signal}, либо {signalReceiver}-{signal}. {signalReceiver} — это имя компонента в презентере. Поэтому в имени компонента не может быть дефиса — он используется для разделения имени компонента и сигнала, однако таким образом можно вложить несколько компонентов.

Метод isSignalReceiver() проверяет, является ли компонент (первый аргумент) получателем сигнала (второй аргумент). Второй аргумент можно опустить — тогда проверяется, является ли компонент получателем любого сигнала. В качестве второго параметра можно указать true, чтобы проверить, является ли получателем не только указанный компонент, но и любой его потомок.

На любом этапе, предшествующем handle{signal}, мы можем выполнить сигнал вручную, вызвав метод processSignal(), который берет на себя обработку сигнала — берет компонент, который определен как получатель сигнала (если получатель сигнала не указан, это сам презентер), и отправляет ему сигнал.

Пример:

if ($this->isSignalReceiver($this, 'paging') || $this->isSignalReceiver($this, 'sorting')) {
	$this->processSignal();
}

Таким образом, сигнал выполняется преждевременно и больше не будет вызываться.

версия: 4.0