Інтерактивні компоненти

Компоненти — це окремі об'єкти, що використовуються повторно, які ми вставляємо на сторінки. Це можуть бути форми, таблиці даних, опитування, власне все, що має сенс використовувати повторно. Ми покажемо:

  • як використовувати компоненти?
  • як їх писати?
  • що таке сигнали?

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 представляють собою повторно використовувані частини веб-застосунку, які ми вставляємо на сторінки і яким, власне, присвячена вся ця глава. Які саме можливості має такий компонент?

  1. його можна відобразити в шаблоні
  2. він знає, яку свою частину має відобразити при AJAX-запиті (сніпети)
  3. він має можливість зберігати свій стан в URL (персистентні параметри)
  4. він має можливість реагувати на дії користувача (сигнали)
  5. він створює ієрархічну структуру (де коренем є 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();
}

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

версія: 4.0