Komponenty interaktywne

Komponenty to osobne obiekty wielokrotnego użytku, które osadzamy w stronach. Mogą to być formularze, datagridy, ankiety, w zasadzie wszystko, co warto wykorzystywać wielokrotnie. Pokażemy:

  • jak używać komponentów?
  • jak je pisać?
  • czym są sygnały?

Nette ma wbudowany system komponentów. Coś podobnego mogą kojarzyć weterani z Delphi albo ASP.NET Web Forms; React czy Vue.js zbudowane są na czymś odlegle podobnym. W świecie frameworków PHP jest to jednak funkcja unikalna.

Jednocześnie komponenty zasadniczo wpływają na podejście do tworzenia aplikacji. Możesz składać strony z wcześniej przygotowanych jednostek. Potrzebujesz datagrida w swojej administracji? Znajdź go na Componette, repozytorium dodatków open source (nie tylko komponentów) do Nette, i po prostu wstaw do presentera.

Do presentera możesz włączyć dowolną liczbę komponentów. A w niektórych komponentach możesz osadzić kolejne. Powstaje w ten sposób drzewo komponentów, którego korzeniem jest presenter.

Metody fabryczne

Jak komponenty trafiają do presentera i jak się ich potem używa? Zwykle za pomocą metod fabrycznych.

Fabryka komponentów to elegancki sposób tworzenia komponentów dopiero wtedy, gdy są rzeczywiście potrzebne (lazy / on demand). Cała magia polega na zaimplementowaniu metody o nazwie createComponent<Nazwa>(), gdzie <Nazwa> to nazwa tworzonego komponentu, która komponent tworzy i zwraca.

class DefaultPresenter extends Nette\Application\UI\Presenter
{
	protected function createComponentPoll(): PollControl
	{
		$poll = new PollControl;
		$poll->items = $this->items;
		return $poll;
	}
}

Ponieważ wszystkie komponenty tworzone są w osobnych metodach, kod staje się przejrzystszy.

Nazwy komponentów zawsze zaczynają się małą literą, mimo że w nazwie metody pisane są wielką.

Fabryk nigdy nie wywołujemy bezpośrednio; wywoływane są automatycznie przy pierwszym użyciu komponentu. Dzięki temu komponent tworzony jest we właściwym momencie i tylko wtedy, gdy naprawdę jest potrzebny. Jeśli komponentu nie użyjemy (np. przy żądaniu AJAX, gdzie przesyłana jest tylko część strony, albo przy cachowaniu szablonu), w ogóle nie zostanie utworzony, co oszczędza wydajność serwera.

// sięgamy po komponent i jeśli był to pierwszy raz,
// wywoła się createComponentPoll(), które go utworzy
$poll = $this->getComponent('poll');
// alternatywny zapis: $poll = $this['poll'];

W szablonie komponent można wyrenderować tagiem {control}. Nie ma więc potrzeby ręcznego przekazywania komponentów do szablonu.

<h2>Zagłosuj</h2>

{control poll}

Do dynamicznego tworzenia zmiennej liczby komponentów użyj Multipliera.

Metody fabryczne createComponent<Nazwa>() działają nie tylko w presenterach. W ten sam sposób możesz zagnieździć komponent w innym komponencie, składając je w drzewo – przydaje się to na przykład przy osobno renderowanym formularzu wewnątrz komponentu.

Styl hollywoodzki

Komponenty powszechnie korzystają ze świeżej techniki, którą lubimy nazywać stylem hollywoodzkim. Na pewno znasz frazes, który często słyszą uczestnicy castingów filmowych: “Nie dzwoń do nas, my zadzwonimy do ciebie”. I dokładnie o to chodzi.

W Nette, zamiast nieustannie zadawać pytania (“czy formularz został wysłany?”, “czy był poprawny?”, “czy użytkownik nacisnął ten przycisk?”), mówisz frameworkowi “gdy to się stanie, wywołaj tę metodę” i zostawiasz mu dalszą pracę. Jeśli programujesz w JavaScripcie, dobrze znasz ten styl programowania. Piszesz funkcje, które wywoływane są, gdy nastąpi określone zdarzenie. A język przekazuje im odpowiednie parametry.

Całkowicie zmienia to perspektywę pisania aplikacji. Im więcej zadań możesz zostawić frameworkowi, tym mniej masz pracy. I tym mniej możesz przeoczyć.

Pisanie komponentu

Przez pojęcie komponent rozumiemy zwykle potomka klasy Nette\Application\UI\Control. (Trafniej byłoby używać terminu “controls”, ale w niektórych językach ma on inne znaczenie, a “komponenty” bardziej się przyjęły.) Sam presenter Nette\Application\UI\Presenter również jest potomkiem klasy Control.

use Nette\Application\UI\Control;

class PollControl extends Control
{
}

Renderowanie

Wiemy już, że do wyrenderowania komponentu służy tag {control nazwaKomponentu}. Wywołuje on w rzeczywistości metodę render() komponentu, w której zajmujemy się renderowaniem. Mamy do dyspozycji, tak jak w presenterze, szablon Latte w zmiennej $this->template, do której przekazujemy parametry. W odróżnieniu od presentera musimy podać plik szablonu i kazać go wyrenderować:

public function render(): void
{
	// wstawiamy do szablonu jakieś parametry
	$this->template->param = $value;
	// i renderujemy go
	$this->template->render(__DIR__ . '/poll.latte');
}

Tag {control} pozwala przekazać metodzie render() parametry:

{control poll $id, $message}
public function render(int $id, string $message): void
{
	// ...
}

Czasem komponent może składać się z kilku części, które chcemy renderować osobno. Dla każdej z nich tworzymy własną metodę renderującą, tutaj w przykładzie renderPaginator():

public function renderPaginator(): void
{
	// ...
}

A w szablonie wywołujemy ją potem tak:

{control poll:paginator}

Dla lepszego zrozumienia warto wiedzieć, jak ten tag przekłada się na kod PHP.

{control poll}
{control poll:paginator 123, 'hello'}

przekłada się na:

$control->getComponent('poll')->render();
$control->getComponent('poll')->renderPaginator(123, 'hello');

Metoda getComponent() zwraca komponent poll, a na tym komponencie wywoływana jest metoda render() albo renderPaginator(), jeśli w tagu po dwukropku podano inną metodę renderującą.

Uwaga, jeśli w parametrach poza nawiasami kwadratowymi pojawi się =>, wszystkie parametry zostaną opakowane w tablicę i przekazane jako pierwszy argument:

{control poll, id: 123, message: 'hello'}

przekłada się na:

$control->getComponent('poll')->render(['id' => 123, 'message' => 'hello']);

Renderowanie subkomponentu:

{control cartControl-someForm}

przekłada się na:

$control->getComponent("cartControl-someForm")->render();

Komponenty, podobnie jak presentery, automatycznie przekazują do szablonów kilka przydatnych zmiennych:

  • $basePath to bezwzględna ścieżka URL do katalogu głównego (np. /eshop)
  • $baseUrl to bezwzględny URL katalogu głównego (np. http://localhost/eshop)
  • $user to obiekt reprezentujący użytkownika
  • $presenter to bieżący presenter
  • $control to bieżący komponent
  • $flashes to tablica wiadomości wysłanych funkcją flashMessage()

Sygnał

Wiemy już, że nawigacja w aplikacji Nette polega na linkowaniu albo przekierowywaniu do par Presenter:akcja. A co, jeśli chcemy tylko wykonać akcję na bieżącej stronie? Na przykład zmienić sortowanie kolumn w tabeli; usunąć element; przełączyć tryb jasny/ciemny; wysłać formularz; zagłosować w ankiecie itd.

Ten typ żądania nazywa się sygnałem. I tak jak akcje wywołują metody action<Akcja>() albo render<Akcja>(), sygnały wywołują metody handle<Sygnał>(). Podczas gdy pojęcie akcji (albo widoku) dotyczy wyłącznie presenterów, sygnały dotyczą wszystkich komponentów. A więc i presenterów, bo UI\Presenter jest potomkiem UI\Control.

public function handleClick(int $x, int $y): void
{
	// ... przetwarzanie sygnału ...
}

Odnośnik wywołujący sygnał tworzy się w zwykły sposób, czyli w szablonie atrybutem n:href albo tagiem {link}, a w kodzie metodą link(). Więcej w rozdziale Tworzenie odnośników URL.

<a n:href="click! $x, $y">kliknij tutaj</a>

Sygnał wywoływany jest zawsze na bieżącym presenterze i akcji; nie da się wywołać go na innym presenterze albo innej akcji.

Sygnał powoduje więc przeładowanie strony dokładnie jak pierwotne żądanie, ale dodatkowo wywołuje metodę obsługującą sygnał z odpowiednimi parametrami. Jeśli metoda nie istnieje, zgłaszany jest wyjątek Nette\Application\UI\BadSignalException, który wyświetlany jest użytkownikowi jako strona błędu 403 Forbidden.

Snippety i AJAX

Sygnały mogą trochę przypominać Ci AJAX: handlery wywoływane na bieżącej stronie. I masz rację, sygnały rzeczywiście często wywoływane są przez AJAX, a następnie do przeglądarki przesyłane są tylko zmienione części strony. Nazywamy je snippetami. Więcej informacji znajdziesz na stronie poświęconej AJAX-owi.

Wiadomości flash

Komponent ma własny magazyn wiadomości flash, niezależny od presentera. To wiadomości informujące na przykład o wyniku operacji. Ważną cechą wiadomości flash jest to, że są dostępne w szablonie również po przekierowaniu. Nawet po wyświetleniu pozostają aktywne przez kolejne 30 sekund, na przykład na wypadek, gdyby użytkownik odświeżył stronę z powodu błędu transmisji – wiadomość nie zniknie od razu.

Wysyłaniem zajmuje się metoda flashMessage. Pierwszym parametrem jest tekst wiadomości (string, Stringable) albo obiekt stdClass reprezentujący wiadomość. Opcjonalnym drugim parametrem jest jej typ (error, warning, info itd.). Metoda flashMessage() zwraca instancję wiadomości flash jako obiekt stdClass, do którego można dodać dalsze informacje.

$this->flashMessage('Element został usunięty.');
$this->redirect(/* ... */); // i przekierowujemy

Wiadomości te dostępne są w szablonie w zmiennej $flashes jako obiekty stdClass, które zawierają właściwości message (tekst wiadomości), type (typ wiadomości) i mogą zawierać wspomniane informacje użytkownika. Renderujemy je na przykład tak:

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

Przekierowanie po przetworzeniu sygnału

Po przetworzeniu sygnału komponentu często następuje przekierowanie. Przypomina to formularze – po ich wysłaniu również przekierowujemy, aby zapobiec ponownemu wysłaniu danych przy odświeżeniu strony w przeglądarce.

$this->redirect('this'); // przekierowuje na bieżący presenter i akcję

Ponieważ komponent jest elementem wielokrotnego użytku i zwykle nie powinien mieć bezpośredniego powiązania z konkretnymi presenterami, metody redirect() i link() automatycznie interpretują parametr jako sygnał komponentu:

$this->redirect('click'); // przekierowuje na sygnał 'click' tego samego komponentu

Jeśli potrzebujesz przekierować na inny presenter albo inną akcję, możesz zrobić to przez presenter:

$this->getPresenter()->redirect('Product:show'); // przekierowuje na inny presenter/akcję

Parametry trwałe

Parametry trwałe służą do utrzymywania stanu w komponentach między różnymi żądaniami. Ich wartość pozostaje taka sama również po kliknięciu w odnośnik. W odróżnieniu od danych w sesji przesyłane są w URL. I dzieje się to całkowicie automatycznie, łącznie z odnośnikami tworzonymi w innych komponentach na tej samej stronie.

Masz na przykład komponent do stronicowania treści. Takich komponentów może być na stronie kilka. I chcemy, aby po kliknięciu w odnośnik wszystkie komponenty pozostały na swojej bieżącej stronie. Dlatego numer strony (page) czynimy parametrem trwałym.

Utworzenie parametru trwałego w Nette jest wyjątkowo proste. Wystarczy utworzyć właściwość publiczną i oznaczyć ją atrybutem: (wcześniej używano /** @persistent */)

use Nette\Application\Attributes\Persistent;  // ten wiersz jest ważny

class PaginatingControl extends Control
{
	#[Persistent]
	public int $page = 1; // musi być public
}

Zalecamy podanie typu danych właściwości (np. int), możesz też podać wartość domyślną. Wartości parametrów można walidować.

Przy tworzeniu odnośnika wartość parametru trwałego można zmienić:

<a n:href="this page: $page + 1">dalej</a>

Albo zresetować, czyli usunąć z URL. Przyjmie wtedy swoją wartość domyślną:

<a n:href="this page: null">resetuj</a>

Komponenty trwałe

Trwałe mogą być nie tylko parametry, ale też komponenty. Ich parametry trwałe przenoszone są wtedy również między różnymi akcjami presentera albo między wieloma presenterami. Komponenty trwałe oznaczamy atrybutem na klasie presentera. Na przykład komponenty calendar i poll oznaczymy tak:

use Nette\Application\Attributes\Persistent;

#[Persistent('calendar', 'poll')]
class DefaultPresenter extends Nette\Application\UI\Presenter
{
}

Subkomponentów wewnątrz tych komponentów nie trzeba oznaczać; również stają się trwałe.

Starsza adnotacja @persistent nadal działa, ale jest przestarzała i wywołuje ostrzeżenie:

/**
 * @persistent(calendar, poll)
 */
class DefaultPresenter extends Nette\Application\UI\Presenter
{
}

Komponenty z zależnościami

Jak tworzyć komponenty z zależnościami, nie “zaśmiecając” presenterów, które będą ich używać? Dzięki sprytnym możliwościom kontenera DI w Nette, podobnie jak przy klasycznych usługach, większość pracy można zostawić frameworkowi.

Weźmy przykład komponentu, który ma zależność od usługi PollFacade:

class PollControl extends Control
{
	public function __construct(
		private int $id, // ID ankiety, dla której tworzymy komponent
		private PollFacade $facade,
	) {
	}

	public function handleVote(int $voteId): void
	{
		$this->facade->vote($this->id, $voteId);
		// ...
	}
}

Gdybyśmy pisali klasyczną usługę, nie byłoby o czym mówić. Kontener DI niewidocznie zająłby się przekazaniem wszystkich zależności. Przy komponentach zwykle jednak radzimy sobie tak, że tworzymy nową instancję bezpośrednio w presenterze, w metodach fabrycznych createComponent…(). Ale przekazywanie wszystkich zależności wszystkich komponentów do presentera tylko po to, aby przekazać je dalej do komponentów, jest uciążliwe. A ile kodu do napisania…

Logiczne pytanie brzmi: dlaczego po prostu nie zarejestrujemy komponentu jako klasycznej usługi, nie przekażemy go do presentera i nie zwrócimy w metodzie createComponent…()? To podejście jest jednak niewłaściwe, bo chcemy mieć możliwość tworzenia komponentu w razie potrzeby wielokrotnie.

Poprawnym rozwiązaniem jest napisanie fabryki komponentu, czyli klasy, która tworzy komponent za nas:

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

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

Fabrykę tę rejestrujemy w kontenerze w konfiguracji:

services:
	- PollControlFactory

i wreszcie używamy jej w naszym presenterze:

class PollPresenter extends Nette\Application\UI\Presenter
{
	public function __construct(
		private PollControlFactory $pollControlFactory,
	) {
	}

	protected function createComponentPollControl(): PollControl
	{
		$pollId = 1; // możemy przekazać własny parametr
		return $this->pollControlFactory->create($pollId);
	}
}

Świetne jest to, że Nette DI potrafi takie proste fabryki wygenerować, więc zamiast pisać cały jej kod, wystarczy napisać jej interfejs:

interface PollControlFactory
{
	public function create(int $id): PollControl;
}

I to wszystko. Nette wewnętrznie implementuje ten interfejs i wstrzykuje go do presentera, gdzie możemy go użyć. Magicznie dodaje do naszego komponentu parametr $id i instancję klasy PollFacade.

Komponenty w głąb

Komponenty w Nette Application reprezentują części aplikacji webowej wielokrotnego użytku, które osadzamy w stronach i którym poświęcony jest cały ten rozdział. Jakie dokładnie są możliwości takiego komponentu?

  1. da się go wyrenderować w szablonie
  2. wie, którą swoją część wyrenderować przy żądaniu AJAX (snippety)
  3. ma możliwość przechowywania swojego stanu w URL (parametry trwałe)
  4. ma możliwość reagowania na działania użytkownika (sygnały)
  5. tworzy strukturę hierarchiczną (której korzeniem jest presenter)

Za każdą z tych funkcji odpowiada jedna z klas w linii dziedziczenia. Za renderowanie (1 + 2) odpowiada Nette\Application\UI\Control, za włączenie w cykl życia (3, 4) klasa Nette\Application\UI\Component, a za utworzenie struktury hierarchicznej (5) klasy Container i 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 }

Cykl życia komponentu

Cykl życia komponentu

Walidacja parametrów trwałych

Wartości parametrów trwałych otrzymane z URL zapisywane są do właściwości metodą loadState(). Sprawdza ona również, czy typ danych podany dla właściwości się zgadza; w przeciwnym razie odpowiada błędem 404 i strona nie zostaje wyświetlona.

Nigdy nie ufaj ślepo parametrom trwałym, bo użytkownik może je łatwo nadpisać w URL. Tak sprawdzimy na przykład, czy numer strony $this->page jest większy od 0. Odpowiednim sposobem jest nadpisanie wspomnianej metody loadState():

class PaginatingControl extends Control
{
	#[Persistent]
	public int $page = 1;

	public function loadState(array $params): void
	{
		parent::loadState($params); // tutaj ustawiane jest $this->page
		// następuje własne sprawdzenie wartości:
		if ($this->page < 1) {
			$this->error();
		}
	}
}

Odwrotnym procesem, czyli zebraniem wartości z właściwości trwałych, zajmuje się metoda saveState().

Podłączenie do presentera

W chwili, gdy komponent staje się częścią hierarchii presentera, wywoływane są jego callbacki zapisane w tablicy $onAnchor. Od tego momentu komponent ma dostępny presenter, może bezpiecznie tworzyć odnośniki, odczytywać parametry trwałe itd.

$control->onAnchor[] = function ($control): void {
	// komponent ma teraz dostępny presenter
};

Sygnały w głąb

Sygnał powoduje przeładowanie strony dokładnie jak pierwotne żądanie (poza wywołaniem przez AJAX) i wywołuje metodę signalReceived($signal), której domyślna implementacja w klasie Nette\Application\UI\Component próbuje wywołać metodę złożoną ze słów handle<Sygnał>. Dalsze przetwarzanie zależy od danego obiektu. Obiekty dziedziczące po Component (czyli Control i Presenter) reagują próbą wywołania metody handle<Sygnał> z odpowiednimi parametrami.

Innymi słowy: brana jest definicja funkcji handle<Sygnał> wraz ze wszystkimi parametrami, które przyszły z żądaniem, a parametry z URL przypisywane są do argumentów po nazwie, po czym następuje próba wywołania metody. Na przykład wartość parametru id z URL przekazywana jest jako argument $id, something z URL jako $something itd. A jeśli metoda nie istnieje, metoda signalReceived zgłasza wyjątek.

Poza parametrami z URL sygnał odczytuje również parametry wysłane w ciele POST żądania. Przydaje się to, bo sygnały często wywoływane są przez JavaScript, w którym naturalne jest wysyłanie danych metodą POST. Jeśli jednak parametr o tej samej nazwie przyjdzie zarówno z URL, jak i z ciała POST, pierwszeństwo ma wartość z URL. Unikaj więc nadawania polu POST tej samej nazwy co parametrowi URL albo trasy, bo wartość z URL po cichu by je nadpisała. Parametry sygnału dzielą wspólną przestrzeń z parametrami akcji i parametrami trwałymi, zobacz Wspólna przestrzeń parametrów.

Sygnał może odebrać dowolny komponent, presenter albo obiekt implementujący interfejs SignalReceiver i podłączony do drzewa komponentów.

Głównymi odbiorcami sygnałów będą Presentery i komponenty wizualne dziedziczące po Control. Sygnał ma służyć jako znak dla obiektu, że powinien coś zrobić: ankieta ma policzyć głos użytkownika, blok z newsami ma się rozwinąć i wyświetlić dwa razy więcej newsów, formularz został wysłany i ma przetworzyć dane itd.

URL sygnału tworzy się metodą Component::link(). Jako parametr $destination przekazujemy string {sygnał}!, a jako $args tablicę argumentów, które chcemy przekazać sygnałowi. Sygnał wywoływany jest zawsze na bieżącym presenterze i akcji z bieżącymi parametrami; parametry sygnału są tylko dodawane. Ponadto dodawany jest parametr ?do, który określa sygnał.

Jego format to albo {sygnał}, albo {odbiorcaSygnału}-{sygnał}. {odbiorcaSygnału} to nazwa komponentu w presenterze. Dlatego w nazwie komponentu nie można użyć myślnika – służy on do oddzielenia nazwy komponentu i sygnału, choć w ten sposób można zagnieżdżać wiele komponentów.

Metoda isSignalReceiver() sprawdza, czy komponent (pierwszy argument) jest odbiorcą sygnału (drugi argument). Drugi argument można pominąć – sprawdzane jest wtedy, czy komponent jest odbiorcą jakiegokolwiek sygnału. Jeśli drugi parametr ustawimy na true, sprawdzane jest, czy odbiorcą jest podany komponent albo któryś z jego potomków.

Na dowolnym etapie poprzedzającym handle<Sygnał> możemy wykonać sygnał ręcznie, wywołując metodę processSignal(), która zajmuje się obsługą sygnału – bierze komponent zidentyfikowany jako odbiorca sygnału (jeśli odbiorcy nie podano, jest nim sam presenter) i wysyła mu sygnał.

Przykład:

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

Sygnał zostanie w ten sposób wykonany przedwcześnie i nie zostanie wywołany ponownie.

wersja: 4.x