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 lze 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().

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.

Nette neobsahuje implementaci AJAX požadavků na straně klienta. Doporučujeme knihovnu nette.ajax.js od Vojty Dobeše nebo Nittro framework.

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

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

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

$this->sendResponse(new JsonResponse(['klic' => 'hodnota', ...]));

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

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

Nicméně 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ů.

Invalidace

Každý objekt třídy Control (což je i samotný Presenter) si umí zapamatovat, jestli při subrequestu 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($user)
{
    // 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.

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

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

$this->redrawControl(); // invaliduje celou komponentu, každý snippet
echo $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.

Makro {snippet} … {/snippet}

Nette je založeno na myšlence logických, nikoliv grafických prvků, tj. objekt třídy Control nepředstavuje pravoúhlou oblast ve stránce, ale logickou komponentu, která se může renderovat i do více podob (např. hypotetická komponenta DataGrid může mít jednu metodu pro vykreslení mřížky a druhou pro vykreslení „stránkovadla“ apod). Každý prvek může být navíc na stránce vykreslen vícekrát, nebo podmíněně, nebo pokaždé s jinou šablonou atd.

Není tedy možné jednoduše zavolat nějakou metodu render na každém invalidním objektu. K vykreslování je nutné přistupovat tak, jako když se kreslí celá stránka. Vykreslování stránky probíhá velmi podobně, jako u neAJAXového požadavku, načtou se tytéž šablony atd. Klíčovým úkolem však je vypustit ty části, které se na výstup vůbec dostat nemají, a ty, které se vykreslit mají, přidružit s identifikátorem a poslat klientovi ve formátu, kterému bude obslužný JavaScript rozumět.

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 nejsnáze použitím atributové varianty makra:

<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 HomepagePresenter extends \Nette\Application\UI\Presenter
{
    /**
     * Zde je nějaká logika pro získání celého seznamu. Správně to patří do
     * modelu, ale pro příklad to nebudu komplikovat.
     * @return array
     */
    private function getTheWholeList()
    {
        return [
            'První',
            'Druhý',
            'Třetí'
        ];
    }

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

    public function handleUpdate($id)
    {
        $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 makrem snippetArea, které pak invalidujeme spolu se samotnym snippetem.

Makro 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()
{
    $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é.