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>:
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:
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¶
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):
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:
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¶
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:
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:
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
- Crear la clase
Productocon los atributosnombreyprecio(con sus getters y setters). - Crear un
@Controllercon un@GetMapping("/productos"). - Armar una lista en memoria y agregarla al
Modelcon la claveproductos. - Crear
templates/productos.htmlque recorra la lista conth:eachy muestre cada campo conth: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.