Dynamische Snippets

Bei der Entwicklung von Anwendungen entsteht relativ häufig die Notwendigkeit, AJAX-Operationen beispielsweise auf einzelnen Zeilen einer Tabelle oder Elementen einer Liste durchzuführen. Als Beispiel können wir die Auflistung von Artikeln wählen, wobei wir jedem angemeldeten Benutzer ermöglichen, eine Bewertung “gefällt mir/gefällt mir nicht” abzugeben. Der Code des Presenters und des entsprechenden Templates ohne AJAX wird ungefähr wie folgt aussehen (ich gebe die wichtigsten Ausschnitte an, der Code rechnet mit der Existenz eines Dienstes zum Markieren von Bewertungen und dem Abrufen einer Artikelsammlung – die konkrete Implementierung ist für die Zwecke dieser 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

Lassen Sie uns nun diese einfache Anwendung mit AJAX ausstatten. Die Änderung der Artikelbewertung ist nicht so wichtig, dass eine Weiterleitung erfolgen muss, daher sollte sie idealerweise im Hintergrund per AJAX erfolgen. Wir verwenden das Hilfsskript aus den Add-ons mit der üblichen Konvention, dass AJAX-Links die CSS-Klasse ajax haben.

Aber wie genau geht das? Nette bietet 2 Wege: den Weg der sogenannten dynamischen Snippets und den Weg der Komponenten. Beide haben ihre Vor- und Nachteile, daher werden wir sie nacheinander vorstellen.

Der Weg der dynamischen Snippets

Ein dynamisches Snippet bedeutet in der Latte-Terminologie einen spezifischen Anwendungsfall des {snippet}-Tags, bei dem im Snippetnamen eine Variable verwendet wird. Ein solches Snippet kann nicht einfach irgendwo im Template stehen – es muss von einem statischen Snippet, d.h. einem gewöhnlichen, oder innerhalb von {snippetArea} umschlossen sein. Unser Template könnten wir wie folgt 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 Snippets sind dann zusammen in einem Snippet mit dem Namen articlesContainer verpackt. Wenn wir dieses umschließende Snippet weglassen würden, würde uns Latte mit einer Ausnahme darauf hinweisen.

Es bleibt uns übrig, das Neuzeichnen im Presenter 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); -- ist nicht notwendig
	} else {
		$this->redirect('this');
	}
}

Analog passen wir auch die Schwestermethode handleUnlike() an, und AJAX ist funktionsfähig!

Die Lösung hat jedoch einen Nachteil. Wenn wir genauer untersuchen, wie die AJAX-Anfrage abläuft, stellen wir fest, dass, obwohl sich die Anwendung nach außen hin sparsam verhält (sie gibt nur ein einziges Snippet für den gegebenen Artikel zurück), sie tatsächlich auf dem Server alle Snippets gerendert hat. Das gewünschte Snippet wurde uns in den Payload platziert, und die anderen wurden verworfen (sie wurden also auch völlig unnötig aus der Datenbank abgerufen).

Um diesen Prozess zu optimieren, müssen wir dort eingreifen, wo wir die Sammlung $articles an das Template übergeben (angenommen in der Methode renderDefault()). Wir nutzen die Tatsache, dass die Signalverarbeitung vor den render<Something>-Methoden erfolgt:

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');
	}
}

Nun wird bei der Signalverarbeitung anstelle der Sammlung mit allen Artikeln nur ein Array mit einem einzigen Artikel an das Template übergeben – demjenigen, den wir rendern und im Payload an den Browser senden möchten. {foreach} wird also nur einmal durchlaufen und keine zusätzlichen Snippets werden gerendert.

Der Weg der Komponenten

Eine völlig andere Lösung vermeidet dynamische Snippets. Der Trick besteht darin, die gesamte Logik in eine separate Komponente zu übertragen – um die Eingabe von Bewertungen kümmert sich von nun an nicht mehr der Presenter, sondern eine dedizierte LikeControl. Die Klasse wird wie folgt aussehen (außerdem wird sie auch Methoden render, handleUnlike usw. enthalten):

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 unser View-Template und wir müssen eine Factory-Methode zum Presenter hinzufügen. Da wir die Komponente so oft erstellen, wie wir Artikel aus der Datenbank abrufen, verwenden 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 View-Template wird auf das notwendige Minimum reduziert (und 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 wird nun AJAX-fähig funktionieren. Auch hier müssen wir die Anwendung optimieren, da aufgrund der Verwendung von Nette Database bei der Signalverarbeitung unnötigerweise alle Artikel aus der Datenbank anstelle von nur einem geladen werden. Der Vorteil ist jedoch, dass es nicht zu deren Rendern kommt, da tatsächlich nur unsere Komponente gerendert wird.