AJAX & snippety

Moderní webové aplikace dnes běží napůl na serveru, napůl v prohlížeči. AJAX je tím klíčovým spojovacím prvkem. Jakou podporu nabízí Nette Framework?

  • posílání výřezů šablony (tzv. snippety)
  • předávání proměnných mezi PHP a JavaScriptem
  • debugování AJAXových aplikací

AJAXový požadavek

AJAXový požadavek se nijak neliší od klasického požadavku – je zavolán presenter s určitým view a parametry. Je také věcí presenteru, jak bude na něj reagovat: může použít vlastní rutinu, která vrátí nějaký fragment HTML kódu (HTML snippet), XML dokument, JSON objekt nebo kód v JavaScriptu.

Na straně serveru lze AJAXový požadavek detekovat metodou služby zapouzdřující HTTP požadavek $httpRequest->isAjax() (detekuje podle HTTP hlavičky X-Requested-With). Uvnitř presenteru je k dispozici „zkratka“ v podobě metody $this->isAjax().

Pro odesílání dat prohlížeči ve formátu JSON lze využít předpřipravený objekt payload:

public function actionDelete(int $id): void
{
	if ($this->isAjax()) {
		$this->payload->message = 'Success';
	}
	// ...
}

Pokud potřebujete plnou kontrolu nad odeslaným JSONem, použijte metodu sendJson v presenteru. Tím ihned ukončíte činnost presenteru a obejdete se i bez šablony:

$this->sendJson(['key' => 'value', /* ... */]);

Když chceme odeslat HTML, můžeme jednak zvolit speciální šablonu pro AJAX:

public function handleClick($param): void
{
	if ($this->isAjax()) {
		$this->template->setFile('path/to/ajax.latte');
	}
	// ...
}

Naja

K obsluze AJAXových požadavků na straně prohlížeče slouží knihovna Naja. Tu nainstalujte jako node.js balíček (pro použití s aplikacemi Webpack, Rollup, Vite, Parcel a dalšími):

npm install naja

…nebo přímo vložte do šablony stránky:

<script src="https://unpkg.com/naja@2/dist/Naja.min.js"></script>

Aby se z obyčejného odkazu (signálu) nebo odeslání formuláře vytvořil AJAXový požadavek, stačí označit příslušný odkaz, formulář nebo tlačítko třídou ajax:

<a n:href="go!" class="ajax">Go</a>

<form n:name="form" class="ajax">
    <input n:name="submit">
</form>

nebo
<form n:name="form">
    <input n:name="submit" class="ajax">
</form>

Snippety

Daleko silnější nástroj představuje vestavěná podpora AJAXových snippetů. Díky ní lze udělat z obyčejné aplikace AJAXovou prakticky několika řádky kódu. Jak to celé funguje, demonstruje příklad Fifteen, jehož kód najdete na GitHubu.

Snippety fungují tak, že při prvotním (tedy neAJAXovém) požadavku se přenese celá stránka a poté se při každém již AJAXovém subrequestu (= požadavku na stejný presenter a view) přenáší pouze kód změněných částí ve zmíněném úložišti payload. K tomu slouží dva mechanismy: invalidace a renderování snippetů.

Snippety vám mohou připomínat Hotwire pro Ruby on Rails nebo Symfony UX Turbo, nicméně Nette s nimi přišlo už o čtrnáct let dříve.

Invalidace snippetů

Každý objekt třídy Control (což je i samotný Presenter) si umí zapamatovat, jestli při signálu došlo ke změnám, které si vyžadují jej překreslit. K tomu slouží dvojice metod redrawControl() a isControlInvalid(). Příklad:

public function handleLogin(string $user): void
{
	// po přihlášení uživatele se musí objekt překreslit
	$this->redrawControl();
	// ...
}

Nette však nabízí ještě jemnější rozlišení, než na úrovni komponent. Uvedené metody mohou totiž jako argument přijímat název tzv. „snippetu“, nebo-li výstřižku. Lze tedy invalidovat (rozuměj: vynutit překreslení) na úrovni těchto snippetů (každý objekt může mít libovolné množství snippetů). Pokud se invaliduje celá komponenta, tak se i každý snippet překreslí. Komponenta je „invalidní“ i tehdy, pokud je invalidní některá její subkomponenta.

$this->isControlInvalid(); // -> false

$this->redrawControl('header'); // invaliduje snippet 'header'
$this->isControlInvalid('header'); // -> true
$this->isControlInvalid('footer'); // -> false
$this->isControlInvalid(); // -> true, alespoň jeden snippet je invalid

$this->redrawControl(); // invaliduje celou komponentu, každý snippet
$this->isControlInvalid('footer'); // -> true

Komponenta, která přijímá signál, je automaticky označena za invalidní.

Díky invalidaci snippetů přesně víme, které části kterých prvků bude potřeba překreslit.

Tagy {snippet} … {/snippet}

Vykreslování stránky probíhá velmi podobně jako při běžném požadavku: načtou se stejné šablony atd. Podstatné však je vynechání částí, které se nemají dostat na výstup; ostatní části se přiřadí k identifikátoru a pošlou se uživateli ve formátu srozumitelném pro obslužný program JavaScriptu.

Syntaxe

Pokud se uvnitř šablony nachází control nebo snippet, musíme jej obalit párovou značkou {snippet} ... {/snippet} – ty totiž zajistí, že se vykreslený snippet vystřihne a pošle do prohlížeče. Také jej obalí pomocnou značkou <div> s vygenerovaným id. V uvedeném příkladě je snippet pojmenován jako header a může představovat i například šablonu controlu:

{snippet header}
	<h1>Hello ... </h1>
{/snippet}

Snippetu jiného typu než <div> nebo snippetu s dalšími HTML atributy docílíme použitím atributové varianty:

<article n:snippet="header" class="foo bar">
	<h1>Hello ... </h1>
</article>

Dynamické snippety

Nette také umožňuje používání snippetů, jejichž název se vytvoří až za běhu – tj. dynamicky. Hodí se to pro různé seznamy, kde při změně jednoho řádku nechceme přenášet AJAXem celý seznam, ale stačí onen samotný řádek. Příklad:

<ul n:snippet="itemsContainer">
	{foreach $list as $id => $item}
	<li n:snippet="item-$id">{$item} <a class="ajax" n:href="update! $id">update</a></li>
	{/foreach}
</ul>

Zde máme statický snippet itemsContainer, obsahující několik dynamických snippetů item-0, item-1 atd.

Dynamické snippety nelze invalidovat přímo (invalidace item-1 neudělá vůbec nic), musíte invalidovat jim nadřazený statický snippet (zde snippet itemsContainer). Potom dojde k tomu, že se provede celý kód toho kontejneru, ale prohlížeči se pošlou jenom jeho sub-snippety. Pokud chcete, aby prohlížeč dostal pouze jediný z nich, musíte upravit vstup toho kontejneru tak, aby ostatní negeneroval.

V příkladu výše zkrátka musíte zajistit, aby při ajaxovém požadavku byla v proměnné $list pouze jedna položka a tedy aby ten cyklus foreach naplnil pouze jeden dynamický snippet:

class HomePresenter extends Nette\Application\UI\Presenter
{
	/**
	 * Tato metoda vrací data pro seznam.
	 * Obvykle se jedná pouze o vyžádání dat z modelu.
	 * Pro účely tohoto příkladu jsou data zadána natvrdo.
	 */
	private function getTheWholeList(): array
	{
		return [
			'První',
			'Druhý',
			'Třetí',
		];
	}

	public function renderDefault(): void
	{
		if (!isset($this->template->list)) {
			$this->template->list = $this->getTheWholeList();
		}
	}

	public function handleUpdate(int $id): void
	{
		$this->template->list = $this->isAjax()
				? []
				: $this->getTheWholeList();
		$this->template->list[$id] = 'Updated item';
		$this->redrawControl('itemsContainer');
	}
}

Snippety v includované šabloně

Může se stát, že máme snippet v šabloně, kterou teprve includujeme do jiné šablony. V takovém případě je nutné vkládání této šablony obalit značkami snippetArea, které pak invalidujeme spolu se samotnym snippetem.

Tagy snippetArea zaručí, že se daný kód, který vkládá šablonu, provede, do prohlížeče se však odešle pouze snippet v includované šabloně.

{* parent.latte *}
{snippetArea wrapper}
{include 'child.latte'}
{/snippetArea}
{* child.latte *}
{snippet item}
...
{/snippet}
$this->redrawControl('wrapper');
$this->redrawControl('item');

Tento přístup se nechá použít i v kombinaci s dynamickými snippety.

Přidávání a mazání

Pokud přidáte novou položku a invalidujete itemsContainer, pak vám AJAXový požadavek sice vrátí i nový snippet, ale obslužný javascript ho neumí nikam přiřadit. Na stránce totiž zatím není žádný HTML prvek s takovým ID.

V takovém případě je nejjednodušší celý ten seznam obalit ještě jedním snippetem a invalidovat to celé:

{snippet wholeList}
<ul n:snippet="itemsContainer">
	{foreach $list as $id => $item}
	<li n:snippet="item-$id">{$item} <a class="ajax" n:href="update! $id">update</a></li>
	{/foreach}
</ul>
{/snippet}
<a class="ajax" n:href="add!">Add</a>
public function handleAdd(): void
{
	$this->template->list = $this->getTheWholeList();
	$this->template->list[] = 'New one';
	$this->redrawControl('wholeList');
}

Totéž platí i pro mazání. Sice by se dal nějak poslat prázdný snippet, jenže v praxi jsou většinou seznamy stránkované a řešit úsporněji smazání jednoho plus případné načtení jiného (který se předtím nevešel) by bylo příliš složité.

Posílání parametrů do komponenty

Pokud komponentě pomocí AJAXového požadavku odesíláme parametry, ať už parametry signálu nebo persistentní parametry, musíme u požadavku uvést jejich globální název, který obsahuje i jméno komponenty. Celý název parametru vrací metoda getParameterId().

$.getJSON(
	{link changeCountBasket!},
	{
		{$control->getParameterId('id')}: id,
		{$control->getParameterId('count')}: count
	}
});

A handle metoda s odpovídajícími parametry v komponentě.

public function handleChangeCountBasket(int $id, int $count): void
{

}
verze: 4.0 3.x 2.x