Интерактивные компоненты
Компоненты — это отдельные объекты многократного использования, которые мы помещаем на страницы. Это могут быть формы, сетки данных, опросы, в общем, всё, что имеет смысл использовать многократно. Далее мы узнаем:
- как использовать компоненты?
- как их писать?
- что такое сигналы?
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}
Перенаправление после сигнала
После обработки сигнала компонента часто следует перенаправление. Эта ситуация похожа на ситуацию с формами – после отправки формы мы также делаем перенаправление, чтобы предотвратить повторную отправку данных при обновлении страницы в браузере.
$this->redirect('this') // redirects to the current presenter and action
Поскольку компонент – это многократно используемый элемент и
обычно не должен иметь прямой зависимости от конкретных
презентаторов, методы redirect()
и link()
автоматически
интерпретируют параметр как сигнал компонента:
$this->redirect('click') // redirects to the 'click' signal of the same component
Если вам нужно перенаправить на другого ведущего или действие, вы можете сделать это через ведущего:
$this->getPresenter()->redirect('Product:show'); // redirects to a different presenter/action
Постоянные параметры
Постоянные параметры используются для сохранения состояния компонентов между различными запросами. Их значение остается неизменным даже после нажатия на ссылку. В отличие от данных сессии, они передаются в URL. И они передаются автоматически, включая ссылки, созданные в других компонентах на той же странице.
Например, у вас есть компонент пейджинга контента. На странице может
быть несколько таких компонентов. И вы хотите, чтобы при нажатии на
ссылку все компоненты оставались на текущей странице. Поэтому мы
делаем номер страницы (page
) постоянным параметром.
Создать постоянный параметр в Nette очень просто. Просто создайте
публичное свойство и пометьте его атрибутом: (ранее использовалось
/** @persistent */
).
use Nette\Application\Attributes\Persistent; // эта строка важна
class PaginatingControl extends Control
{
#[Persistent]
public int $page = 1; // должны быть публичными
}
Мы рекомендуем указывать тип данных (например, int
) вместе со
свойством, а также можно указать значение по умолчанию. Значения
параметров могут быть проверены.
Значение постоянного параметра можно изменить при создании ссылки:
<a n:href="this page: $page + 1">next</a>
Или его можно сбросить, т.е. удалить из URL. Тогда он примет значение по умолчанию:
<a n:href="this page: null">reset</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($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\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 – это многократно используемые части веб-приложения, которые мы встраиваем в страницы, о чем и пойдет речь в этой главе. Каковы возможности такого компонента?
- он может быть отображен в шаблоне
- он знает , какую часть себя от рисовывать при AJAX-запросе (сниппеты)
- он имеет возможность хранить свое состояние в URL (постоянные параметры)
- имеет возможность реагировать на действия пользователя (сигналы)
- создает иерархическую структуру (где корнем является ведущий)
Каждая из этих функций обрабатывается одним из классов линии наследования. Рендеринг (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}
и все
параметры, которые были получены в запросе, сопоставляются с
параметрами метода. Это означает, что параметр 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();
}
Сигнал выполняется преждевременно и больше не будет вызван.