Component Model 4.0: od shora dolů a bez magie

před 2 hodinami od David Grudl  

Component Model je ta nejtišší knihovna v celém Nette. Nikdo o ní nemluví, skoro nikdo ji nepoužívá přímo, a přitom stojí pod každým formulářem a každým Control v aplikaci. Verze 4.0 je z velké části úklid zastaralého API, ale schovává se v ní i jedna opravdová změna chování.

K čemu vlastně Component Model je? Pokud píšete presentery a formuláře, používáte Component Model každý den, aniž byste o tom věděli. Definuje základní stavební kameny: Component (něco, co má jméno a rodiče), Container (komponenta, která může obsahovat další komponenty). Z nich dědí Nette\Application\UI\Control, Nette\Forms\Form i Nette\Forms\Container.

getComponents() zase jen pro přímé potomky

Metoda getComponents() kdysi měla dva parametry: getComponents($deep, $filterType). Přes ně se daly získat rekurzivně všechny komponenty v podstromu a rovnou je filtrovat podle typu. Od těchto parametrů jsme ale postupně ustupovali. Nejdřív jsme je v 3.x tiše skryli ze signatury, ve 4.0 jsou pryč úplně a jejich použití vyhodí DeprecatedException. Metoda teď vždy vrací pole přímých potomků a nic víc.

Pro průchod celým podstromem slouží getComponentTree(). Není to novinka 4.0, existuje už od verze 3.1:

// dříve:
$all = $container->getComponents(true);

// nyní (ale není to totéž)
$all = $container->getComponentTree();

Není to ale jen přejmenování, liší se podoba výsledku. Staré getComponents(true) vracelo iterátor, kde klíče byly jména komponent. Metoda getComponentTree() vrací obyčejné pole s číselnými klíči.

Ty číselné klíče nejsou kosmetika. Jméno komponenty je unikátní jen v rámci jednoho kontejneru, ne v rámci celého stromu, takže napříč podstromem klidně narazíte na víc komponent se stejným jménem na různých úrovních. To byl důvod, proč getComponents(true) nemohlo vracet pole, ale zmíněný iterátor. Čímž se komplikovala implementace a signatura. Proto byla snaha parametr true dát pryč a mít raději dvě prosté metody.

Filtrování podle typu (náhrada $filterType) se v dnešním PHP udělá snadno:

$forms = array_filter(
	$container->getComponentTree(),
	fn($c) => $c instanceof Form,
);

Bez magie: konec SmartObjectu

Třída Component přestala používat trait Nette\SmartObject, který kdysi hlídal přístup k neexistujícím vlastnostem a přidával magické gettery. V praxi si toho ale nevšimnete. Verze nette/application 3.3 a nette/forms 3.3, které s Component Model 4.0 počítají, si SmartObject přidávají přímo do Control, respektive BaseControl. Pokud tedy píšete presentery, formuláře nebo vlastní controly, nemění se pro vás nic. Změny si všimnete jen v případě, že rozšiřujete přímo základní třídy z Component Modelu.

Pořadí oznamování: od shora dolů

Poslední změna je jediná, která opravdu mění chování, a týká se monitoringu. To je klíčová vlastnost Component Modelu. Komponenta si může říct: „dej mi vědět, až mě někdo připojí pod presenter“, a teprve v tu chvíli, kdy zná svého předka, doplní třeba signály nebo persistentní parametry. Slouží k tomu metoda monitor():

$this->monitor(Presenter::class, function (Presenter $presenter): void {
	// teď už vím, pod jakým presenterem visím
});

A právě tady nastala změna.

Když komponentu připojíte ke stromu, který už je zavěšený pod nějakým předkem, spustí se všechny relevantní attached callbacky v podstromu. Otázka zní: v jakém pořadí?

Do verze 3.x se callbacky volaly zdola nahoru (od nejhlubšího potomka k předkovi). Ve 4.0 se volají shora dolů (od předka k potomkům). Vypadá to jako kosmetika, ale jde o opravu nepraktického chování.

Starý algoritmus pracoval ve dvou fázích. Nejdřív prošel celý podstrom a do seznamu si posbíral všechny dvojice (callback, předek). Teprve potom seznam najednou odpálil. Problém je v tom, že seznam byl snímek stromu pořízený předtím, než cokoliv proběhlo.

Jenže callbacky se stromem běžně manipulují. Rodičovská komponenta může ve svém attached odebrat nebo přesunout potomky, provést přesměrování, zrušit sama sebe. A protože callbacky potomků už byly nasbírané, zavolaly se i pro komponenty, které mezitím ze stromu vypadly. Komponenta dostala oznámení „jsi připojena pod presenterem“ v okamžiku, kdy už pod ním dávno nebyla.

Nový algoritmus už nepracuje ve dvou fázích. Místo aby si callbacky posbíral dopředu a pak je naráz spustil, prochází strom a u každého uzlu rovnou zavolá jeho callbacky, teprve pak sestoupí k potomkům. Před každým sestupem ověří, že potomek je pořád skutečně potomkem a že už nebyl zpracován. Díky tomu:

  • Rodič je oznámen dřív než jeho potomci. To odpovídá intuici i tomu, jak funguje třeba capture fáze událostí v DOM. Než dostane slovo potomek, jeho rodič už měl příležitost připravit půdu.
  • Rodič může potomka zastavit. Když rodičovský callback potomka odebere, jeho callback se prostě nezavolá. Strom se prochází živý, ne ze snímku.
  • Přesuny a reentrance jsou ošetřené. Pokud callback přesune komponentu do kontejneru, který přijde na řadu později, množina již zpracovaných uzlů zajistí, že se nezpracuje podruhé. Hrubý booleovský zámek z verze 3.x nahradila přesná evidence per-objekt.
  • Deduplikace zůstává. Stejná dvojice callback plus konkrétní předek se zavolá právě jednou, i když na téhož předka míří víc monitorů.

Jde tedy o změnu, která dělá chování předvídatelnějším a opravuje případy, kdy se dřív callback volal nad komponentou, která už ve stromu nebyla.

Způsobí to BC break?

Tohle je jediná změna chování, ne jen úklid API, takže si zaslouží poctivou odpověď. Prošel jsem řadu knihoven postavených nad Component Modelem a nenašel jsem jediný případ, kde by změna pořadí něco rozbila. Důvod je prostý: typický attached callback se dívá jen „nahoru“ na předka, který v tu chvíli existuje, a řeší sám sebe. Nepředpokládá nic o tom, jestli už proběhl callback sourozence nebo potomka.

Rozbít se to ale teoreticky může, a to ve dvou situacích:

  • Spoléháte na opačné pořadí. Pokud máte dvě komponenty, které se přes attached callbacky vzájemně koordinují, a potomek dřív zapisoval stav, který předek četl (nebo naopak), nové pořadí to prohodí. Předek teď běží první a uvidí jiný stav než dřív. Tohle je vzácné, protože vyžaduje záměrnou komunikaci mezi komponentami napříč stromem skrz callbacky.
  • Spoléháte na staré chybné chování. Pokud váš kód nějak využíval to, že se callback potomka zavolal i poté, co ho rodič během připojování odebral, tak se teď tento callback prostě nespustí.

Pokud nic takového neděláte, a to je drtivá většina případů, upgrade na vás z tohoto pohledu nebude mít žádný dopad.

Jak migrovat

Pro většinu projektů je upgrade triviální. Projděte si, jestli se vás netýká něco z následujícího:

  • přetěžované attached() / detached() přepište na monitor($type, $attached, $detached),
  • getComponents(true) nahraďte getComponentTree(),
  • filtrování getComponents(false, Foo::class) nahraďte array_filter() nad getComponents() (nebo nad getComponentTree(), pokud jste filtrovali rekurzivně),
  • magické $component->name a spol. nahraďte gettery,
  • konstantu NAME_SEPARATOR přejmenujte na NameSeparator,
  • pokud přetěžujete addComponent(), doplňte do signatury návratový typ : static (dřív byl jen v phpDocu jako @return static).
David Grudl Programátor, blogger a AI evangelista. Vytvořil Nette Framework používaný statisíci webů. Píše na Uměligence o umělé inteligenci a phpFashion o webovém vývoji. Každý týden moderuje Tech Guys a učí lidi pracovat s ChatGPT a dalšími AI nástroji. Fascinují ho technologie, které mění náš svět, a rád je přibližuje široké veřejnosti.