Tvorba komponent – best practice

Upozornění: Tohle vlákno je hodně staré a informace nemusí být platné pro současné Nette.
SuperMartas
Člen | 13
+
0
-

Ahoj,
již několik dní si lámu hlavu nad tvorbou komponent v Nette. Jde mi především o best practice, tedy abych neporušoval principy MVC, OOP, DRY apod. Je velice pravděpodobné, že ne všechny principy úplně dobře chápu a znám, nejsem v tom příliš zdatný.

Začnu s konkrétním příkladem, na kterém jsem se zasekl. Jedná se o Navbar a SideNav z Material Designu (resp. Materialize). Můj základní kód, ze kterého vycházím, vypadá nějak takto:

<nav>
	<div class="nav-wrapper container">
		<a n:href="Homepage:" class="brand-logo">
			<img src="{$basePath}/images/logo.svg" alt="Logo">
			<span class="hide-on-small-only">Název aplikace</span>
		</a>
		<ul class="right">
			<li>
				<a n:href="Sign:in" class="waves-effect">
					<i class="mdi mdi-login"></i>
					<span class="hide-on-med-and-down">Přihlásit</span>
				</a>
			</li>
			<li>
				<a n:href="Sign:up" class="waves-effect">
					<i class="mdi mdi-account-plus"></i>
					<span class="hide-on-med-and-down">Registrovat</span>
				</a>
			</li>
		</ul>
		<ul class="side-nav" id="nav-mobile">
			<li>
				<a n:href="Sign:in" class="waves-effect">
					<i class="mdi mdi-login"></i>
					Přihlásit se
				</a>
			</li>
			<li>
				<a n:href="Sign:up" class="waves-effect">
					<i class="mdi mdi-account-plus"></i>
					Zaregistrovat se
				</a>
			</li>
		</ul>
	</div>
</nav>

Nápadů, jak to provést, jsem vyzkoušel už několik:

  1. Nepoužívat komponenty a psát v různých částech aplikace, kde bude Navbar odlišný, tohle celé znovu, případně s menšími úpravami.

    Tohle mi nepřišlo jako dobré řešení, protože bych musel spoustu částí kódu duplikovat a kdyby se stala nějaká změna v Materialize nebo bych chtěl třeba jen odebrat „waves-effect“, musel bych to na všech místech změnit ručně.

    Jednu výhodu to ale asi jen přeci má – je to nejspíš výkonově nejrychlejší řešení.

  2. Přemístit data do polí a v cyklu vykreslovat některé prvky, např. odkazy.
    <li n:foreach="[['Sign:in', 'login', 'Přihlásit'], ['Sign:up', 'account-plus', 'Registrovat']] as $link">
    	<a n:href="$link[0]" class="waves-effect">
    		<i class="mdi mdi-{$link[1]}"></i>
    		<span class="hide-on-med-and-down">{$link[2]}</span>
    	</a>
    </li>

    Stále zůstává problém, že pokud budu chtít odebrat „waves-effect“ u všech Navbarů, u každého to budu muset udělat minimálně jednou. Navíc jsou data v nepěkném poli. Pro přehlednost by možná pomohlo odřádkování a asociativní pole a asi by to chtělo data někam (kam?) vyčlenit. A co když budu chtít přidat nějakou výjimku, že se třeba na tabletech zobrazí jen ikonka pro registraci? Další prvek pole? Blbost.

  3. Místo pole použít třídy jako holé obálky na data.

    Pro začátek například třída Link:

    <li n:foreach="$links as $link"> <!-- kde pole s odkazy ($links) vytvářet? v presenteru? -->
    	<a n:href="$link->link" class="waves-effect">
    		<i class="mdi mdi-{$link->icon}" n:ifset="{$link->icon}"></i> <!-- n:ifset - ikonku odkaz třeba vůbec nemusí mít... -->
    		<span class="hide-on-med-and-down" n:ifset="{$link->text}">{$link->text}</span>
    	</a>
    </li>

    To už by šlo, ale stále přetrvává problém s odstraněním „waves-effect“. Co s tím?

  4. Přesunout celý Navbar do jediné komponenty (NavbarControl).

    To by vyřešilo „waves-effect“ problém. Ale tady nastupuje zásadní otázka: Kudy do NavbarControl cpát data?

    1. Vycházím z předpokladu, že presenter je taky vlastně komponenta a existuje jediná instance v celé aplikaci. Předpokládal jsem proto, že i vlastní komponenty by měly být v celé aplikaci unikátkní a měla by existovat jediná instance a komponenta by se tak právě chovala jako „controller“, tedy to, co řídí tok dat mezi modelem a šablonou na základě vstupních dat.

      Jenže kde mám pro tohle sebrat model? A co má vlastně tady ten model být?

      Taky mě napadlo cpát data přes metodu render. Že bych do ní předal všechny ty Link(y) a měl nějaké další třídy jako Logo a Image (obrázek v logu) a ty bych potom taky předal pomocí render metody. Nebo bych všechny tyto parametry obalil do nějaké třídy, např. Navbar, a tu bych poté předal metodě render místo všech těch parametrů. Možná by to i dávalo smysl, protože by to znamenalo něco jako: „Hej komponento, vykresli mi parametry, které ti předám.“

      Po bližším zkoumání jsem pochopil, že takhle to asi úplně nefunguje.

    2. Další možností, jak do NavbarControl nahrnout data, by bylo přes konstruktor a/nebo settery. Tzn. v metodě presenteru createComponentNavbar (nebo ještě lépe v továrničce) bych naházel do NavbarControl logo, linky apod. a pak už bych jen vykresloval přes {control navbar}. Pokud bych chtěl na jedné stránce další Navbar s jinými parametry, musel bych udělat další metodu, např. createComponentNavbar2. Ale v případě, že bych tam data hrnul přes render metodu, stačilo by mi na oba Navbary mít jednu metodu createComponentNavbar, ale data bych do šablony musel dostat jinudy.
    Tady se ještě nabízí otázka, jestli používat pro nacpání dat do komponent
    jen konstruktor,
    jen settery,
    nebo oboje?

    Pokud použiji jen konstruktor, tak buď ztratím možnost předávat parametry rodičovské třídy nebo je budu muset předat na konec jako nepovinné parametry a bude se to nepěkně mixovat. A co když navíc bude mít komponenta nějaké závislosti? Továrnička by bylo řešení, ale furt by se to nepěkně mixovalo… Navíc při absenci setterů by nešla měnit data u již vytvořeného objektu, ale je otázka, jestli je to vůbec žádoucí…

    Pokud použiji jen settery, konstruktor zůstane krásně čistý pro případně závislosti, ale objekt by se zase mohl dostat do neplatného stavu, kdy by nešel vykreslit (např. některý povinný parametr mu nikdo nepředal) a metoda render by tedy skončila výjimkou a musela by v ní být nějaká kontrola (takže by se při každém vykreslení muselo znovu a znovu kontrolovat, jestli je objekt v pořádku).

    A nakonec, pokud použiji obojí, zkombinují se nevýhody jak konstruktoru, tak setterů.

  5. Proč by měly být třídy jako Link, Logo a Image jen pouhé obálky na data, když mohou být vykreslitelné? Pojďme úplně všechno rozházet do komponent!

    Takže máme komponenty LinkControl:

    <a href="{$link}" class="waves-effect">
    	<i class="mdi mdi-{$icon}" n:ifset="$icon"></i>
    	<span class="hide-on-med-and-down" n:ifset="$text">{$text}</span>
    </a>

    LinkListControl, do které jsou přidávány Link(y) asi nějak takto: $linkList[spl_object_hash($link)] = $link; a $links se poté do šablony předá nějak takto: $template->links = $this->getComponents(false, LinkControl::class);:

    <ul class="right" n:if="$links">
    	<li n:foreach="$links as $link">
    		 {control $link}
    	</li>
    </ul>

    ImageControl:

    <img src="{$src}" alt="{$alt}">

    LogoControl (může obsahovat ImageControl):

    <a href="{$link}" class="brand-logo">
    	{if $image}{control image}{/if}
    	<span class="hide-on-small-only" n:ifset="$text">{$text}</span>
    </a>

    A na NavbarControl tak de facto zbyde jen:

    <nav>
    	<div class="nav-wrapper container">
    		{if $logo}{control logo}{/if}
    		{control links}
    	</div>
    </nav>

    Pořád mi to ale nepřišlo dost dobré a tak jsem začal přemýšlet o ještě menším rozkouskování na ještě menší části, aby to bylo ještě lépe rozšiřitelné.

  6. Ještě víc komponent! Nechť je komponenta absolutně všechno, bez výjimky!

    V této fázi by byly navíc komponenty jako MdiControl:

    {mdi $icon} <!-- zavedl bych i nové makro na ikonky, komponenta je tu jen kvůli tomu, aby šla připojovat do jiných komponent -->

    TextControl – prostě vykreslí jen obyčejný text {$text}, nic víc (ještě nějak udělat, aby šlo vykreslovat i HTML).

    Výhody jsou zřejmé – např. do Navbaru, ale i do ostatních komponent by šlo připojovat takřka cokoliv:

    $navbar["logo"] = new LogoControl(...);
    $navbar["info"] = new TextControl("text");
    $navbar["icon"] = new MdiControl("login");
    // ...

    Vykreslování by pak ve všech komponentách probíhalo nějak takto:

    <nav>
    	<div class="nav-wrapper container">
    		{foreach $components as $component}
    			{control $component}
    		{/foreach}
    	</div>
    </nav>

    Tohle mi přijde ale neskutečně komplikované, aby úplně všechno byla komponenta, navíc díky tomu určitě klesne výkon a nemyslím si, že by to byla zanedbatelná hodnota. Navíc zůstávají nějaké části, které nejsou konfigurovatelné. Např. jak přidat k odkazu (LinkControl) nějakou CSS třídu? IDčko? Cokoliv?

  7. Pro ještě vyšší konfigurovatelnost by se vykreslovalo pomocí Nette\Utils\Html.

    Všechny komponenty by tedy měly v sobě místo klasické latte šablony instanci třídy Nette\Utils\Html a v metodě render by ji vykreslovali.

    Tohle by krásně vyřešilo problém se sebemenší konfigurací a bylo by to tedy finální řešení. Ale tohle podle mě porušuje princip MVC. Co když budu chtít, aby view byla Reactí komponenta? Tím, že jsem použil Nette\Utils\Html jsem si předurčil, že se bude renderovat vždy do HTML a ne jinak.

Až sem jsem se dostal ve svých úvahách a pokusech a pomalu mi začínají docházet síly to nějak rozumně dál řešit. Proto bych ocenil i jakoukoliv sebemenší radu.

enumag
Člen | 2118
+
0
-

Až do konce bodu 4 s tebou souhlasím – imho ideální je mít required parametry v konstruktoru a optional parametry v setterech.

5. Proč by měly být třídy jako Link, Logo a Image jen pouhé obálky na data, když mohou být vykreslitelné? vykreslitelné? Pojďme úplně všechno rozházet do komponent!

Odtud už mi to připadá jako overkill. Spousta mrňavých komponent znamená spoustu šablon. Pak zjistíš že potřebuješ jednomu odkazu na jednom konkrétním místě přidat classu a buď budeš psát novou šablonu nebo přidávat parametr. Samozřejmě že link, logo i image atd. mohou být vykreslitelné – ale neuvedl jsi jakou to má výhodu ani co se ti nelíbí na konci bodu 4. Respektive já při svých úvahách v podstatě na konci bodu 4 skončil a nevidím důvod v nich pokračovat. (Leda něco ve smyslu komponent zcela nezávislých na presenterech.)

Editoval enumag (18. 1. 2016 20:30)

Pavel Janda
Člen | 977
+
0
-

Podle mě nad tím přemýšlím moc složitě.

1, Tak jako tak musíš mít někde vyblito, který presenter má jaké menu. Klíč nemusí být presenter, ale nějaký tebou vymyšlený identifikátor (třeba presenter-<poradi_navbaru>). Můžeš to mít klidně v té komponentě jako hash strukturu, nebo si to tam načteš z databáze. A nebo úplně jinak. V šabloně to proiteruješ, jak jsi psal.

2, Příklad třeba pomocí Multiplieru: Šablona: {control menu-1} a v BasePresenteru něco takového:

createComponentMenu
{
	$name = $this->getName();
	$menuFactory = $this->menuFactory;

	return new Multiplier(function ($order) use ($name, $menuFactory) {
		return $menuFactory->create($name, $order);
	};
}

3, V tom více či méně persistentním úložišti pak budeš mapovat položky menu na ‚Homepage-1‘ a podobně (Další položka menu pod klíčem ‚Homepage-2‘: {control menu-2}). Jasně, že –1 a –2 vypadá blbě, tak tam místo toho dáš třeba -top a -bottom.


Další prvek pole je blbost? Proč? :D
Pokud se ti to hnusí, napiš si na to admin a ukládej to do db. Menší práce je při vyblití do šablony pole rekurzivně převést na \stdClass, ať se na to lépe šahá. Ale mít menu v poli – na tom nevidím nic špatného. Jasně – nebude to krutopřísně hezké, ale bude to krutopřísně rychlé udělat. Není tam nic, co by se mohlo podělat. Když si bude potřebovat frontenďák upravit položky menu, tak to zvládne sám. Když přijde k projektu další programátor, tak mu to bude hned jasné (stejně jako tobě po 2 letech, co na projekt nešáhneš..).

Editoval Pavel Janda (19. 1. 2016 8:53)

SuperMartas
Člen | 13
+
0
-

Děkuji za rady, ještě bych měl ale pár připomínek.


@enumag

ideální je mít required parametry v konstruktoru a optional parametry v setterech

Tady je ještě menší zádrhel, že kdybych to chtěl udělat třeba „lazy“ getterem (nebo jak to nazvat), tak nemůžou být ani povinné parametry v konstruktoru:

// class NavbarControl
public function getLogo(): Logo
{
	if (!$this->logo)
		$this->logo = new Logo(); // zde bych neměl odkud předat povinné parametry

	return $this->logo;
}

Pak by to šlo krásně používat přes $navbar->logo->text = "xyz"; bez vlastnoručního vytváření a nastavování instance třídy Logo. Navíc pokud bych na ten getter ani nešáhl, logo se nevykreslí (bude null). Co si myslíš o tomhle přístupu? To už je asi na zvážení, aby se to dobře používalo a vyhovovalo to požadavkům…

potřebuješ jednomu odkazu na jednom konkrétním místě přidat classu a buď budeš psát novou šablonu nebo přidávat parametr

A jak jinak přidat třeba odkazu „Registrovat“ (a nikomu jinému) CSS třídu než parametrem v Link a podmínkou v šabloně (v případě, že mám jednu velkou komponentu NavbarControl)? Šablonu celé komponenty NavbarControl jen tak nevyměním, to bych rovnou nemusel používat komponentu. O výměně šablony by se dalo uvažovat právě, kdyby byl Link vykreslitelný. To už by se měnila podstatně menší část, i když stále by tam byly nějaké duplicitní části.


@PavelJanda To je velmi zajímavé řešení i s tím polem, rozhodně ho vyzkouším, díky.

Šaman
Člen | 2665
+
+1
-

Ještě bych rád připomněl jednu možnost, jak předávat komponentě parametry. A to do render metody.
Pokud ta komponenta není stavová a má jen vykreslit nějakou část šablony podle složitější logiky, tak by možná šlo předávat nějaký objekt s konfigurací menu přímo do render metody a v šabloně pak volat {control navbar, $navbarSetting}. Veškerá příprava dat pak může být zcela lazy, presenter nepotřebuje nic nastavovat před začátkem vykreslování, továrnička bude triviální.
Vpodstatě je to pak jen komponentou obalený kus šablony, ale samotná komponenta si může například nechat injectovat model a pak to podle dodaného nastavení jen vykreslit. Jen nesmíš potřebovbat žádnou logiku řešit dřív, než v render metodě.

A pokud jde o to rozkouskování na malé samostatné kousky šablon, tak to se dá řešit i includováním šablony. Výkon tím neztratíš (po nacacheování sestavené šablony) a i těm includovaným šablonám lze předávat parametry. Takže třeba na položku nabídky bych klidně použil v hlavní šabloně komponenty foreach, ve kterém by se pro každou položku načetla šablona třeba takto: {include 'item.latte', label => $label, isActive => $isActive}

P.S. Pokud by chtěl někdo ty ukázky kopírovat, tak to nedělejte, obsahují nedělitelné mezery místo obyčejných :)

Editoval Šaman (19. 1. 2016 20:49)