Saltar a contenido

Expresiones y atributos de Thymeleaf

Una plantilla Thymeleaf es HTML normal al que se le agregan atributos con el prefijo th:. El navegador ignora esos atributos —por eso la plantilla se puede abrir directamente como archivo y sigue viéndose bien—, pero el servidor los procesa antes de enviar la respuesta y reemplaza el contenido con los datos reales.

Para que el editor reconozca el prefijo, la plantilla declara el espacio de nombres en la etiqueta <html>:

<!DOCTYPE html>
<html lang="es" xmlns:th="http://www.thymeleaf.org">

El xmlns:th no es obligatorio

Thymeleaf procesa los atributos aunque no se declare. Se escribe porque evita que el IDE marque los th:* como error y porque documenta que el archivo es una plantilla.


Los cuatro tipos de expresión

Antes de ver los atributos conviene distinguir los cuatro símbolos que aparecen dentro de las comillas. Cada uno resuelve algo distinto:

Sintaxis Nombre Qué resuelve Ejemplo
${...} Expresión de variable Un atributo del modelo enviado por el controlador th:text="${mensaje}"
*{...} Expresión de selección Una propiedad del objeto seleccionado con th:object th:field="*{nombre}"
@{...} Expresión de URL Una ruta de la aplicación, agregando el contexto th:href="@{/productos}"
#{...} Expresión de mensaje Un texto de los archivos de internacionalización th:text="#{app.titulo}"

La más usada es ${...}. El nombre que va dentro debe coincidir exactamente con la clave que el controlador publicó en el Model:

model.addAttribute("mensaje", "Hola, Thymeleaf");   // clave: mensaje
<h1 th:text="${mensaje}">Mensaje de respaldo</h1>   <!-- consume: mensaje -->

El texto Mensaje de respaldo que está escrito dentro del <h1> es lo que se ve al abrir el archivo sin servidor; en tiempo de ejecución Thymeleaf lo reemplaza por el valor real. Esa es la idea de natural templating que se mencionó en Generalidades.


Mostrar valores: th:text y th:utext

<span th:text="${nombre}">Nombre</span>

th:text sustituye todo el contenido del elemento por el valor recibido y escapa el HTML: si el valor es <b>Pablo</b>, en pantalla se lee literalmente <b>Pablo</b>, no Pablo en negrita.

Cuando se necesita que el valor se interprete como HTML existe th:utext (unescaped text):

<div th:utext="${descripcionHtml}">Descripción</div>

Cuidado con th:utext

Si el valor proviene de datos ingresados por un usuario, th:utext abre la puerta a ataques de XSS (inyección de scripts). Úselo solo con contenido que la aplicación controle.

También se pueden concatenar valores y texto fijo con el operador + o con literales sustituidos (|...|):

<p th:text="'Total: ' + ${total}">Total: 0</p>
<p th:text="|Bienvenido, ${usuario.nombre}|">Bienvenido</p>

Escribir valores fuera de un atributo

Si no se quiere crear una etiqueta solo para mostrar un dato, se puede usar la sintaxis en línea con dobles corchetes:

<p>Bienvenido, [[${usuario.nombre}]]. Tienes [[${pendientes}]] tareas.</p>

Recorrer colecciones: th:each

th:each repite el elemento que lo contiene una vez por cada elemento de la colección:

<ul>
  <li th:each="producto : ${productos}">
    <span th:text="${producto.nombre}">Nombre</span>
    <span th:text="${producto.precio}">0.00</span>
  </li>
</ul>

Se lee igual que un for de Java: para cada producto dentro de ${productos}. La variable producto solo existe dentro del elemento y sus hijos, y se accede a sus propiedades con punto (producto.nombre invoca internamente getNombre()).

La variable de estado

th:each admite una segunda variable con información sobre la iteración:

<table>
  <tr th:each="producto, estado : ${productos}">
    <td th:text="${estado.count}">1</td>
    <td th:text="${producto.nombre}">Nombre</td>
    <td th:text="${producto.precio}">0</td>
  </tr>
</table>
Propiedad Significado
estado.index Posición empezando en 0
estado.count Posición empezando en 1 (útil para numerar filas)
estado.size Cantidad total de elementos
estado.first / estado.last true si es el primero / el último
estado.even / estado.odd true si la posición es par / impar (útil para alternar estilos)

La variable de estado es opcional: si no se declara, simplemente no se usa.


Condicionales: th:if, th:unless y th:switch

<p th:if="${activo}">Activo</p>
<p th:unless="${activo}">Inactivo</p>

th:if renderiza el elemento solo si la condición es verdadera; th:unless hace lo contrario. Cuando la condición es falsa, el elemento no aparece en el HTML final (no es que esté oculto con CSS: no se genera).

Thymeleaf considera verdadero: true, un número distinto de cero, un texto que no esté vacío ni sea "false" / "off" / "no", y cualquier objeto no nulo. Por eso th:if="${lista}" también sirve para preguntar "¿llegó algo?".

Ejemplo típico de listado vacío:

<p th:if="${#lists.isEmpty(productos)}">Todavía no hay productos registrados.</p>

Para varias alternativas excluyentes existe th:switch:

<div th:switch="${usuario.rol}">
  <span th:case="'ADMIN'">Administrador</span>
  <span th:case="'DOCENTE'">Docente</span>
  <span th:case="*">Invitado</span>
</div>

th:case="*" es el caso por defecto y debe ir de último.


Enlaces y recursos: @{...}

<a th:href="@{/productos}">Ver productos</a>
<link rel="stylesheet" th:href="@{/css/styles.css}">
<script th:src="@{/js/script.js}"></script>

La ventaja frente a escribir href="/productos" es que @{...} antepone automáticamente el context path de la aplicación. Si mañana la app se despliega en https://servidor/miapp, los enlaces siguen funcionando sin tocar las plantillas.

Para construir URLs con datos se usan parámetros y variables de ruta:

<!-- /productos?id=5  → parámetro de consulta -->
<a th:href="@{/productos(id=${producto.id})}">Ver</a>

<!-- /productos/5/editar  → variable de ruta -->
<a th:href="@{/productos/{id}/editar(id=${producto.id})}">Editar</a>

Otros atributos frecuentes

Atributo Qué hace Ejemplo
th:value Asigna el valor de un input <input th:value="${producto.nombre}">
th:attr Asigna cualquier atributo th:attr="data-id=${producto.id}"
th:class Reemplaza el atributo class th:class="${activo} ? 'ok' : 'error'"
th:classappend Agrega clases sin borrar las existentes th:classappend="${estado.odd} ? 'fila-impar'"
th:src Ruta de una imagen o script <img th:src="@{/img/logo.png}">
th:object Selecciona el objeto de un formulario th:object="${producto}"
th:field Enlaza un input a una propiedad th:field="*{nombre}"
th:fragment Define un bloque reutilizable th:fragment="header"
th:replace Sustituye la etiqueta por un fragmento th:replace="~{fragments/header :: header}"

Los tres últimos se explican en detalle en Templates y Formularios.

El operador ternario y los valores nulos

<span th:text="${producto.precio != null} ? ${producto.precio} : 'Sin precio'">0</span>
<span th:text="${producto.precio} ?: 'Sin precio'">0</span>

El operador ?: (Elvis) devuelve el valor de la izquierda si no es nulo, y el de la derecha si lo es. También existe el acceso seguro ${producto?.precio}, que devuelve null en lugar de fallar cuando producto es nulo.


th:block y comentarios

A veces se necesita aplicar un th:each o un th:if sobre varios elementos sin envolverlos en un <div> que ensucie el HTML. Para eso existe th:block, una etiqueta que desaparece al renderizar:

<th:block th:each="producto : ${productos}">
  <dt th:text="${producto.nombre}">Nombre</dt>
  <dd th:text="${producto.precio}">0.00</dd>
</th:block>

Los comentarios <!-- ... --> se envían al navegador. Si se quiere un comentario que no llegue al HTML final se usa la sintaxis de comentario de Thymeleaf:

<!--/* Este texto solo existe en la plantilla */-->

Objetos de utilidad

Thymeleaf expone objetos auxiliares —todos empiezan con #— para formatear y consultar datos desde la vista:

Objeto Uso típico Ejemplo
#numbers Formatear cifras ${#numbers.formatDecimal(producto.precio, 1, 'POINT', 2, 'COMMA')}
#dates / #temporals Formatear fechas ${#temporals.format(fecha, 'dd/MM/yyyy')}
#strings Operar sobre texto ${#strings.isEmpty(nombre)}, ${#strings.abbreviate(texto, 40)}
#lists Consultar colecciones ${#lists.size(productos)}, ${#lists.isEmpty(productos)}
#fields Consultar errores de validación ${#fields.hasErrors('nombre')}

Regla práctica

Los objetos de utilidad sirven para presentar datos, no para calcularlos. Si en la plantilla aparece lógica de negocio (sumas, descuentos, reglas), esa lógica debería estar en el servicio o en el controlador: la vista solo muestra lo que ya viene resuelto.


Reto: una vista dinámica sin base de datos

Objetivo. Construir una página /productos que reciba una lista en el Model y la muestre con th:each.

Entregables

  1. Crear la clase Producto con los atributos nombre y precio (con sus getters y setters).
  2. Crear un @Controller con un @GetMapping("/productos").
  3. Armar una lista en memoria y agregarla al Model con la clave productos.
  4. Crear templates/productos.html que recorra la lista con th:each y muestre cada campo con th:text, numerando las filas con la variable de estado.

Criterio de éxito. La página muestra varios productos generados en memoria, con su número de fila.

Pregunta para la siguiente sesión. ¿Qué le falta a esta aplicación para que los productos sigan ahí después de reiniciar el servidor? La respuesta está en CRUD con JPA.