Інтерактивні компоненти
Компоненти — це окремі об'єкти, що використовуються повторно, які ми вставляємо на сторінки. Це можуть бути форми, таблиці даних, опитування, власне все, що має сенс використовувати повторно. Ми покажемо:
- як використовувати компоненти?
- як їх писати?
- що таке сигнали?
Nette має вбудовану систему компонентів. Щось подібне можуть пам'ятати ті, хто працював з Delphi або ASP.NET Web Forms, на чомусь віддалено схожому побудовані React або Vue.js. Однак у світі PHP-фреймворків це унікальна річ.
При цьому компоненти суттєво впливають на підхід до створення застосунків. Ви можете складати сторінки з готових блоків. Потрібна таблиця даних в адміністративній панелі? Знайдіть її на Componette, репозиторії доповнень з відкритим кодом (тобто не тільки компонентів) для Nette, і просто вставте в presenter.
До presenter'а можна включити будь-яку кількість компонентів. А в деякі компоненти можна вставляти інші компоненти. Таким чином створюється дерево компонентів, коренем якого є presenter.
Фабричні методи
Як компоненти вставляються в presenter і потім використовуються? Зазвичай за допомогою фабричних методів.
Фабрика компонентів — це елегантний спосіб створювати компоненти
лише тоді, коли вони дійсно потрібні (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”, але “контроли” мають
в українській мові зовсім інше значення, і скоріше прижилися
“компоненти”.) Сам presenter Nette\Application\UI\Presenter є, до
речі, також нащадком класу Control
.
use Nette\Application\UI\Control;
class PollControl extends Control
{
}
Відображення
Ми вже знаємо, що для відображення компонента використовується тег
{control componentName}
. Він фактично викликає метод render()
компонента, в якому ми дбаємо про відображення. У нас є, так само як і в
presenter'і, Latte шаблон у змінній
$this->template
, куди ми передаємо параметри. На відміну від presenter'а,
ми повинні вказати файл із шаблоном і змусити його відобразитися:
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();
Компоненти, так само як і presenter'и, автоматично передають у шаблони кілька корисних змінних:
$basePath
— абсолютний URL-шлях до кореневого каталогу (наприклад,/eshop
)$baseUrl
— абсолютний URL до кореневого каталогу (наприклад,http://localhost/eshop
)$user
— об'єкт що представляє користувача$presenter
— поточний presenter$control
— поточний компонент$flashes
— масив повідомлень, надісланих функцієюflashMessage()
Сигнал
Ми вже знаємо, що навігація в застосунку Nette полягає у посиланні або
перенаправленні на пари Presenter:action
. Але що, якщо ми просто хочемо
виконати дію на поточній сторінці? Наприклад, змінити сортування
стовпців у таблиці; видалити елемент; перемкнути світлий/темний режим;
надіслати форму; проголосувати в опитуванні тощо.
Цей тип запитів називається сигналами. І подібно до того, як дії
викликають методи action<Action>()
або render<Action>()
, сигнали
викликають методи handle<Signal>()
. У той час як поняття дії (або view)
пов'язане виключно з presenter'ами, сигнали стосуються всіх компонентів. А
отже, й presenter'ів, оскільки UI\Presenter
є нащадком UI\Control
.
public function handleClick(int $x, int $y): void
{
// ... обробка сигналу ...
}
Посилання, що викликає сигнал, створюється звичайним способом, тобто
в шаблоні атрибутом n:href
або тегом {link}
, у коді методом
link()
. Більше в розділі Створення
URL-посилань.
<a n:href="click! $x, $y">натисніть тут</a>
Сигнал завжди викликається на поточному presenter'і та action, його неможливо викликати на іншому presenter'і або іншому action.
Сигнал, отже, спричиняє перезавантаження сторінки так само, як і при початковому запиті, лише додатково викликає метод обробки сигналу з відповідними параметрами. Якщо метод не існує, викидається виняток Nette\Application\UI\BadSignalException, який користувачеві відображається як сторінка помилки 403 Forbidden.
Сніпети та AJAX
Сигнали вам, можливо, трохи нагадують AJAX: обробники, які викликаються на поточній сторінці. І ви маєте рацію, сигнали дійсно часто викликаються за допомогою AJAX, і потім ми передаємо в браузер лише змінені частини сторінки. Тобто так звані сніпети. Більше інформації ви знайдете на сторінці, присвяченій AJAX.
Flash-повідомлення
Компонент має власне сховище flash-повідомлень, незалежне від presenter'а. Це повідомлення, які, наприклад, інформують про результат операції. Важливою особливістю 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') // перенаправляє на поточний presenter та action
Оскільки компонент є елементом, що використовується повторно, і
зазвичай не повинен мати прямого зв'язку з конкретними presenter'ами,
методи redirect()
та link()
автоматично інтерпретують параметр
як сигнал компонента:
$this->redirect('click') // перенаправляє на сигнал 'click' того ж компонента
Якщо вам потрібно перенаправити на інший presenter чи дію, ви можете зробити це через presenter:
$this->getPresenter()->redirect('Product:show'); // перенаправляє на інший presenter/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>
Персистентні компоненти
Не тільки параметри, але й компоненти можуть бути персистентними. У
такого компонента його персистентні параметри передаються і між
різними діями presenter'а, або між кількома presenter'ами. Персистентні
компоненти позначаємо анотацією біля класу presenter'а. Наприклад, так
позначимо компоненти 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
{
}
Компоненти із залежностями
Як створювати компоненти із залежностями, не “забруднюючи” 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-контейнер. Але з
компонентами ми зазвичай поводимося так, що їхній новий екземпляр
створюємо безпосередньо в presenter'і в фабричних
методах createComponent…()
. Але передавати всі залежності всіх
компонентів у presenter, щоб потім передати їх компонентам, незручно. І
стільки написаного коду…
Логічним питанням є, чому б просто не зареєструвати компонент як
класичний сервіс, не передати його в presenter і потім у методі
createComponent…()
не повертати? Такий підхід, однак, недоречний,
оскільки ми хочемо мати можливість створювати компонент навіть
кілька разів.
Правильним рішенням є написати для компонента фабрику, тобто клас, який нам створить компонент:
class PollControlFactory
{
public function __construct(
private PollFacade $facade,
) {
}
public function create(int $id): PollControl
{
return new PollControl($id, $this->facade);
}
}
Таким чином, фабрику зареєструємо в нашому контейнері в конфігурації:
services:
- PollControlFactory
і нарешті використаємо її в нашому presenter'і:
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 внутрішньо реалізує цей інтерфейс і передасть його в
presenter, де ми вже можемо його використовувати. Магічно він додасть до
нашого компонента і параметр $id
, і екземпляр класу
PollFacade
.
Компоненти до глибини
Компоненти в Nette Application представляють собою повторно використовувані частини веб-застосунку, які ми вставляємо на сторінки і яким, власне, присвячена вся ця глава. Які саме можливості має такий компонент?
- його можна відобразити в шаблоні
- він знає, яку свою частину має відобразити при AJAX-запиті (сніпети)
- він має можливість зберігати свій стан в URL (персистентні параметри)
- він має можливість реагувати на дії користувача (сигнали)
- він створює ієрархічну структуру (де коренем є presenter)
Кожну з цих функцій забезпечує певний клас спадкової лінії. За відображення (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
викидає виняток.
Сигнал може приймати будь-який компонент, presenter або об'єкт, який
реалізує інтерфейс SignalReceiver
і підключений до дерева
компонентів.
Основними одержувачами сигналів будуть Presenter
'и та візуальні
компоненти, що успадковують від Control
. Сигнал має служити знаком
для об'єкта, що він має щось зробити – опитування має зарахувати голос
від користувача, блок з новинами має розгорнутися і показати вдвічі
більше новин, форма була надіслана і має обробити дані тощо.
URL для сигналу створюємо за допомогою методу Component::link(). Як
параметр $destination
передаємо рядок {signal}!
і як $args
масив аргументів, які ми хочемо передати сигналу. Сигнал завжди
викликається на поточному presenter'і та action з поточними параметрами,
параметри сигналу лише додаються. Крім того, на самому початку
додається параметр ?do
, який визначає сигнал.
Його формат — або {signal}
, або {signalReceiver}-{signal}
.
{signalReceiver}
— це назва компонента в presenter'і. Тому в назві
компонента не може бути дефіса — він використовується для розділення
назви компонента і сигналу, однак таким чином можна вкладати кілька
компонентів.
Метод isSignalReceiver()
перевіряє, чи є компонент (перший аргумент) одержувачем сигналу (другий
аргумент). Другий аргумент можна опустити — тоді він з'ясовує, чи є
компонент одержувачем будь-якого сигналу. Як другий параметр можна
вказати true
, і цим перевірити, чи є одержувачем не тільки
вказаний компонент, але й будь-який його нащадок.
На будь-якому етапі, що передує handle{signal}
, ми можемо виконати
сигнал вручну, викликавши метод processSignal(),
який бере на себе обробку сигналу — бере компонент, який визначено як
одержувача сигналу (якщо одержувач сигналу не вказаний, це сам presenter) і
надсилає йому сигнал.
Приклад:
if ($this->isSignalReceiver($this, 'paging') || $this->isSignalReceiver($this, 'sorting')) {
$this->processSignal();
}
Таким чином, сигнал виконано передчасно і більше не буде викликатися.