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

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

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

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

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

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

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

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

Фабрика компонентов — это элегантный способ создавать компоненты только тогда, когда они действительно нужны (лениво / по требованию). Вся магия заключается в реализации метода 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. Сам презентер 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 путь к корневому каталогу (например, /CD-collection).
  • $baseUrl — абсолютный URL к корневому каталогу (например, http://localhost/CD-collection)
  • $user — это объект, представляющий пользователя.
  • $presenter — текущий презентер
  • $control — текущий компонент
  • $flashes список сообщений, отправленных методом flashMessage().

Сигнал

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

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

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

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

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

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

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

Сниппеты и AJAX

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

Флэш-сообщения

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

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

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

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

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

Постоянные параметры

Часто бывает необходимо сохранить какой-либо параметр в компоненте на всё время работы с ним. Это может быть, например, номер страницы в пагинации. Этот параметр должен быть помечен как постоянный с помощью аннотации @persistent.

class PollControl extends Control
{
	/** @persistent */
	public $page = 1;
}

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

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

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

use Nette\Application\Attributes\Persistent;

class PollControl extends Control
{
	#[Persistent]
	public $page = 1;
}

Постоянные компоненты

Постоянными могут быть не только параметры, но и компоненты. Их постоянные параметры также передаются между различными действиями или между различными презентерами. Мы помечаем постоянные компоненты этой аннотацией для класса презентера. Например, здесь мы помечаем компоненты 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($id, $voteId);
		//...
	}
}

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

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

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

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

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

Теперь мы регистрируем наш сервис в DI-контейнере для конфигурации:

services:
	- PollControlFactory

Наконец, мы будем использовать эту фабрику в нашем презентере:

class PollPresenter extends Nette\UI\Application\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 }

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

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

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

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

Другими словами: берется определение метода handle{Signal} и все параметры, которые были получены в запросе, сопоставляются с параметрами метода. Это означает, что параметр id из URL сопоставляется с параметром метода $id, something — с $something и так далее. А если метод не существует, то метод signalReceived выбрасывает исключение.

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

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

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

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

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

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

Пример:

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

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