Snippets dinámicos

Cómo usar AJAX para refrescar solo las partes de la página que realmente cambian, como los elementos individuales de una lista, mediante los snippets dinámicos de Latte.

Con bastante frecuencia surge durante el desarrollo la necesidad de realizar operaciones AJAX, por ejemplo sobre las filas de una tabla o los elementos de una lista. Como ejemplo, imaginemos un listado de artículos en el que los usuarios conectados pueden valorar cada artículo con “me gusta” o “no me gusta”. El código del presenter y la plantilla correspondiente sin AJAX tendrían más o menos este aspecto (mostramos las partes más relevantes; el código da por hecho que existe un servicio que gestiona las valoraciones y obtiene los artículos, cuya implementación concreta no es esencial para esta guía):

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

Plantilla:

<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>I like it</a>
	{else}
		<a n:href="unlike! $article->id" class=ajax>I don't like it anymore</a>
	{/if}
</article>

Ajaxización

Añadamos ahora funcionalidad AJAX a esta sencilla aplicación. Cambiar la valoración de un artículo no es tan importante como para justificar una recarga completa de la página, así que lo ideal es que ocurra por AJAX en segundo plano. Usaremos el script de gestión de los complementos con la convención habitual de que los enlaces AJAX llevan la clase CSS ajax.

¿Pero cómo lo implementamos exactamente? Nette ofrece dos enfoques: los snippets dinámicos y los componentes. Ambos tienen sus ventajas e inconvenientes, así que mostraremos cada uno de ellos.

La vía de los snippets dinámicos

En la terminología de Latte, un snippet dinámico es un uso concreto de la etiqueta {snippet} en el que se usa una variable en el nombre del snippet. Un snippet así no se puede colocar en cualquier sitio de la plantilla: tiene que estar envuelto por un snippet estático (normal) o estar dentro de un {snippetArea}. Podríamos modificar nuestra plantilla así:

{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>I like it</a>
			{else}
				<a n:href="unlike! $article->id" class=ajax>I don't like it anymore</a>
			{/if}
		{/snippet}
	</article>
{/snippet}

Cada artículo define ahora un snippet cuyo nombre incluye el ID del artículo. Todos estos snippets dinámicos están envueltos a su vez por un snippet estático llamado articlesContainer. Si omitiéramos este snippet exterior, Latte lanzaría una excepción.

Solo queda añadir al presenter la lógica del redibujado: basta con redibujar el envoltorio estático.

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

Modifique de forma parecida el método handleUnlike() correspondiente y ¡AJAX ya funciona!

Esta solución tiene, sin embargo, un inconveniente. Si examinamos la petición AJAX más de cerca, veremos que, aunque por fuera la aplicación parece eficiente (devuelve un único snippet del artículo concreto), en realidad renderiza en el servidor todos los snippets. Coloca en la carga útil el snippet requerido y descarta los demás (lo que significa que también los obtuvo y renderizó innecesariamente).

Para optimizarlo tenemos que intervenir donde se pasa la colección $articles a la plantilla (digamos que en el método renderDefault()). Aprovecharemos que el procesamiento de la señal ocurre antes de los métodos render<Something>:

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

Ahora, durante el procesamiento de la señal, en lugar de toda la colección de artículos se le pasa a la plantilla solo un array con el único artículo relevante, el que queremos renderizar y enviar en la carga útil al navegador. Como consecuencia, el bucle {foreach} se ejecuta una sola vez y no se renderiza ningún snippet innecesario.

La vía de los componentes

Un enfoque completamente distinto evita del todo los snippets dinámicos. El truco consiste en encapsular toda la lógica en un componente separado. En lugar de que el presenter gestione la valoración, se ocupará de ella un LikeControl dedicado. La clase tendrá este aspecto (contendría también los métodos render, handleUnlike, etc.):

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

Plantilla del componente:

{snippet}
	{if !$article->liked}
		<a n:href="like!" class=ajax>I like it</a>
	{else}
		<a n:href="unlike!" class=ajax>I don't like it anymore</a>
	{/if}
{/snippet}

Naturalmente, la plantilla de la vista cambiará y tendremos que añadir una factory al presenter. Como crearemos una instancia de este componente para cada artículo obtenido de la base de datos, usaremos la clase Multiplier para gestionar su creación.

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

La plantilla de la vista se reduce al mínimo (¡y queda completamente libre de snippets!):

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

Ya casi hemos terminado: la aplicación funcionará ahora con AJAX. También aquí hace falta optimizar, porque, debido al uso de Nette Database, el procesamiento de la señal carga innecesariamente de la base de datos todos los artículos en lugar de solo el relevante. La ventaja, sin embargo, es que no se produce ningún renderizado innecesario, porque solo se renderiza la instancia concreta del componente.