Dynamische Snippets

Wie Sie mit AJAX nur die Teile einer Seite aktualisieren, die sich wirklich ändern, etwa einzelne Einträge in einer Liste, und zwar mithilfe der dynamischen Snippets in Latte.

Bei der Anwendungsentwicklung entsteht recht häufig der Bedarf, AJAX-Operationen zum Beispiel über einzelne Tabellenzeilen oder Listeneinträge auszuführen. Als Beispiel nehmen wir eine Artikelliste, bei der wir angemeldeten Benutzern erlauben, jeden Artikel mit “gefällt mir” oder “gefällt mir nicht” zu bewerten. Der Code des Presenters und das zugehörige Template ohne AJAX sehen ungefähr so aus (ich zeige die wichtigsten Ausschnitte; der Code setzt einen Service für das Speichern der Bewertungen und das Laden der Artikel voraus – die konkrete Implementierung ist für diese Anleitung nicht wichtig):

public function handleLike(int $articleId): void
{
	$this->ratingService->saveLike($articleId, $this->user->id);
	$this->redirect('this');
}

public function handleUnlike(int $articleId): void
{
	$this->ratingService->removeLike($articleId, $this->user->id);
	$this->redirect('this');
}

Template:

<article n:foreach="$articles as $article">
	<h2>{$article->title}</h2>
	<div class="content">{$article->content}</div>
	{if !$article->liked}
		<a n:href="like! $article->id" class=ajax>gefällt mir</a>
	{else}
		<a n:href="unlike! $article->id" class=ajax>gefällt mir nicht mehr</a>
	{/if}
</article>

Ajaxifizierung

Statten wir diese einfache Anwendung nun mit AJAX aus. Die Änderung der Bewertung eines Artikels ist nicht so wichtig, dass dafür eine Weiterleitung nötig wäre, deshalb sollte sie idealerweise per AJAX im Hintergrund ablaufen. Wir verwenden das Handler-Skript aus den Addons mit der üblichen Konvention, dass AJAX-Links die CSS-Klasse ajax tragen.

Aber wie geht das konkret? Nette bietet zwei Wege: den Weg der dynamischen Snippets und den Weg der Komponenten. Beide haben ihr Für und Wider, deshalb zeigen wir sie einen nach dem anderen.

Der Weg der dynamischen Snippets

Ein dynamisches Snippet bezeichnet in der Terminologie von Latte den speziellen Fall des Tags {snippet}, bei dem im Namen des Snippets eine Variable verwendet wird. Ein solches Snippet darf im Template nicht einfach irgendwo stehen – es muss von einem statischen, also gewöhnlichen Snippet umschlossen sein oder sich innerhalb eines {snippetArea} befinden. Unser Template könnten wir folgendermaßen anpassen:

{snippet articlesContainer}
	<article n:foreach="$articles as $article">
		<h2>{$article->title}</h2>
		<div class="content">{$article->content}</div>
		{snippet article-{$article->id}}
			{if !$article->liked}
				<a n:href="like! $article->id" class=ajax>gefällt mir</a>
			{else}
				<a n:href="unlike! $article->id" class=ajax>gefällt mir nicht mehr</a>
			{/if}
		{/snippet}
	</article>
{/snippet}

Jeder Artikel definiert nun ein Snippet, das die ID des Artikels im Namen trägt. Alle diese dynamischen Snippets sind dann gemeinsam von einem statischen Snippet mit dem Namen articlesContainer umschlossen. Würden wir dieses umschließende Snippet weglassen, würde Latte eine Exception werfen.

Es bleibt nur noch, im Presenter das Neuzeichnen zu ergänzen – es genügt, die statische Hülle neu zu zeichnen.

public function handleLike(int $articleId): void
{
	$this->ratingService->saveLike($articleId, $this->user->id);
	if ($this->isAjax()) {
		$this->redrawControl('articlesContainer');
		// $this->redrawControl('article-' . $articleId); -- nicht nötig
	} else {
		$this->redirect('this');
	}
}

Passen Sie die zugehörige Methode handleUnlike() auf dieselbe Weise an, und AJAX funktioniert!

Diese Lösung hat allerdings einen Schönheitsfehler. Wenn wir uns den AJAX-Request genauer ansehen, stellen wir fest, dass die Anwendung nach außen zwar sparsam wirkt (sie gibt nur ein einziges Snippet für den betreffenden Artikel zurück), auf dem Server aber in Wirklichkeit alle Snippets gerendert hat. Das gewünschte Snippet legt sie in den Payload, die übrigen wirft sie weg (sie hat sie also völlig unnötig auch aus der Datenbank geholt und gerendert).

Um diesen Vorgang zu optimieren, müssen wir dort eingreifen, wo wir die Sammlung $articles an das Template übergeben (sagen wir in der Methode renderDefault()). Wir nutzen dabei die Tatsache, dass die Verarbeitung der Signale vor den render<Something>-Methoden abläuft:

public function handleLike(int $articleId): void
{
	// ...
	if ($this->isAjax()) {
		// ...
		$this->template->articles = [
			$this->db->table('articles')->get($articleId),
		];
	} else {
		// ...
}

public function renderDefault(): void
{
	if (!isset($this->template->articles)) {
		$this->template->articles = $this->db->table('articles');
	}
}

Bei der Verarbeitung des Signals wird nun statt der Sammlung aller Artikel nur noch ein Array mit dem einen relevanten Artikel an das Template übergeben – dem, den wir rendern und im Payload an den Browser senden wollen. Die {foreach}-Schleife läuft also nur einmal, und es werden keine überflüssigen Snippets gerendert.

Der Weg der Komponenten

Ein völlig anderer Lösungsweg vermeidet dynamische Snippets ganz. Der Trick besteht darin, die gesamte Logik in eine eigene Komponente zu verlagern – um das Bewerten kümmert sich von nun an nicht mehr der Presenter, sondern ein dafür vorgesehenes LikeControl. Die Klasse sieht folgendermaßen aus (außerdem enthält sie noch Methoden wie render, handleUnlike usw.):

class LikeControl extends Nette\Application\UI\Control
{
	public function __construct(
		private Article $article,
	) {
	}

	public function handleLike(): void
	{
		$this->ratingService->saveLike($this->article->id, $this->presenter->user->id);
		if ($this->presenter->isAjax()) {
			$this->redrawControl();
		} else {
			$this->presenter->redirect('this');
		}
	}
}

Template der Komponente:

{snippet}
	{if !$article->liked}
		<a n:href="like!" class=ajax>gefällt mir</a>
	{else}
		<a n:href="unlike!" class=ajax>gefällt mir nicht mehr</a>
	{/if}
{/snippet}

Natürlich ändert sich das Template des Views, und im Presenter müssen wir eine Factory ergänzen. Da wir von dieser Komponente für jeden aus der Datenbank geladenen Artikel eine Instanz erzeugen, nutzen wir zu ihrer “Vervielfältigung” die Klasse Multiplier.

protected function createComponentLikeControl()
{
	$articles = $this->db->table('articles');
	return new Nette\Application\UI\Multiplier(function (int $articleId) use ($articles) {
		return new LikeControl($articles[$articleId]);
	});
}

Das Template des Views schrumpft auf das nötige Minimum (und ist völlig frei von Snippets!):

<article n:foreach="$articles as $article">
	<h2>{$article->title}</h2>
	<div class="content">{$article->content}</div>
	{control "likeControl-$article->id"}
</article>

Wir sind fast fertig: Die Anwendung funktioniert nun mit AJAX. Auch hier ist eine Optimierung nötig, denn wegen der Verwendung von Nette Database werden bei der Verarbeitung des Signals unnötig alle Artikel aus der Datenbank geladen statt nur des einen relevanten. Der Vorteil ist jedoch, dass nichts unnötig gerendert wird, weil wirklich nur unsere Komponenteninstanz gerendert wird.