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

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

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

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}

Постійні параметри

Постійні параметри використовуються для збереження стану компонентів між різними запитами. Їх значення залишається незмінним навіть після переходу за посиланням. На відміну від сесійних даних, вони передаються в 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\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 }

Життєвий цикл компонента

Життєвий цикл компонента

Перевірка постійних параметрів

Значення постійних параметрів, отримані з 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();
}

Сигнал виконується передчасно і більше не буде викликаний.

версію: 4.0