Vistas con Razor
En resumen: Razor es la plantilla de la vista en ASP.NET Core: HTML con C# dentro, con un modelo, bucles, un layout común, vistas parciales y formularios con validación.
Problema
HTML pegado a partir de cadenas
En la lección anterior, Details devolvía una vista de tres líneas. Pero el catálogo es una tabla con cuatro productos, cada página tiene el mismo menú, y el formulario de pedido no debe aceptar un nombre vacío ni una cantidad de −3.
Pegar todo esto con "<tr><td>" + product.Name + ... es lento y es fácil equivocarse. Además es peligroso: si alguien le pone a un producto el nombre <script>…</script>, la cadena lo mete en la página como código de verdad, y se ejecuta en el navegador de cada comprador. Este ataque se llama XSS (cross-site scripting).
Míralo
Razor: HTML con C# dentro
Una vista es un archivo .cshtml. Es HTML, y @ cambia a C#. El controlador pasa la lista y un título:
public class ProductsController : Controller{ private readonly IProductService products;
public ProductsController(IProductService products) => this.products = products;
public IActionResult Index() { ViewData["Title"] = "Catálogo"; return View(products.All()); }}La vista Views/Products/Index.cshtml:
@model List<Product><h1>@ViewData["Title"]</h1><table> <tr><th>Producto</th><th>Precio</th><th></th></tr> @foreach (Product product in Model) { <tr> <td>@product.Name</td> <td>@product.Price €</td> @if (product.Price > 10) { <td>destacado</td> } else { <td></td> } </tr> }</table><p>Productos: @Model.Count</p><h1>Catálogo</h1><table> <tr><th>Producto</th><th>Precio</th><th></th></tr> <tr> <td>Etiopía Yirgacheffe</td> <td>14.90 €</td> <td>destacado</td> </tr> <tr> <td>Colombia Supremo</td> <td>12.50 €</td> <td>destacado</td> </tr> <tr> <td>Sencha</td> <td>8.40 €</td> <td></td> </tr> <tr> <td>Earl Grey</td> <td>6.90 €</td> <td></td> </tr></table><p>Productos: 4</p>@model List<Product>dice qué tipo de objeto pasa el controlador. En la vista, esModel. Así la vista es fuertemente tipada: un error como@Model.Contse detecta ya endotnet build.@product.Nameinserta un valor.@foreachy@ifson C# normal, y el HTML que tienen dentro se repite o se muestra según la condición.ViewData["Title"]es un diccionario con el que el controlador envía cosas pequeñas a la vista, además del modelo.- Cada valor se codifica:
<se convierte en<. Un nombre como<script>se muestra como texto y no se ejecuta. Esta es la protección contra XSS, sin que tengas que hacer nada.
El lenguaje de plantillas de la vista se llama Razor. No hay una sintaxis especial para el final del C#: Razor reconoce solo dónde empieza una etiqueta HTML.
Lo mismo en una vista más corta, producto por producto:
@model List<Product><h1>Catálogo</h1><ul>@foreach (Product product in Model){ <li>@product.Name: @product.Price €</li>}</ul><p>Productos: @Model.Count</p>Paso a paso, en palabras
- El HTML normal va a la respuesta tal cual.
- El controlador pasa una lista de 4 productos. En la vista, es Model.
- @foreach toma el primer producto. @product.Name se convierte en «Etiopía Yirgacheffe», y @product.Price en 14.90.
- El siguiente producto: la misma línea <li> con «Colombia Supremo» y 12.50.
- El siguiente producto: la misma línea <li> con «Sencha» y 8.40.
- El siguiente producto: la misma línea <li> con «Earl Grey» y 6.90.
- El bucle terminó. @Model.Count inserta 4. El navegador recibe HTML terminado, sin ni una sola @.
Más precisamente, para curiosos
- Una expresión con espacios u operadores va entre paréntesis:
@(product.Price * 2). Un bloque de C# sin salida es@{ ... }, por ejemplo@{ ViewData["Title"] = "Catálogo"; }al principio de la vista. - Si de verdad quieres insertar HTML ya hecho, sin codificar, existe
@Html.Raw(text). Úsalo solo para texto que escribiste tú, nunca para texto que escribió un usuario. @DateTime.Now.Yearinserta el año actual: útil para el «© 2026» del final, que así no tienes que cambiar cada año.- Por qué
AddWebEncodersconUnicodeRanges.Allestá enProgram.csse explica en la lección de MVC: sin él, las letras fuera del latín básico, como laé, salen como códigos del tipoé. El navegador las muestra bien en los dos casos.
Pruébalo
Qué se mostrará
La acción Cheap pasa los cuatro productos. ¿Qué devuelve la vista?
public class ProductsController : Controller{ private readonly IProductService products;
public ProductsController(IProductService products) => this.products = products;
public IActionResult Cheap() => View(products.All());}@model List<Product><ul>@foreach (Product product in Model){ if (product.Price < 10) { <li>@product.Name: @product.Price €</li> }}</ul><p>@Model.Count(p => p.Price < 10) de @Model.Count</p>Dentro de @foreach ya estás en C#, así que el if va sin @. Solo se muestran los productos de menos de 10 €. Al final, @Model.Count(p => p.Price < 10) es LINQ llamado directamente en la vista: 2 de 4.
La idea
Un layout común
El menú y el pie de página están en todas las páginas. En lugar de copiarlos, los pones en un layout: un marco en el que entra la página. Vive en Views/Shared/_Layout.cshtml:
<!DOCTYPE html><html lang="es"><head> <title>@ViewData["Title"] – Café y té</title></head><body> <nav> <a asp-controller="Home" asp-action="Index">Inicio</a> <a asp-controller="Products" asp-action="Index">Productos</a> </nav> <main> @RenderBody() </main> <footer>Café y té, calle Mayor 12</footer></body></html>@RenderBody() es el lugar de la página. Qué layout se usa lo dice Views/_ViewStart.cshtml, que se ejecuta antes de cada vista:
@{ Layout = "_Layout";}<a asp-controller="Products" asp-action="Index"> es un asistente de etiquetas (tag helper): los atributos asp-… se ejecutan en el servidor y construyen la dirección a partir de la ruta. En la respuesta se convierte en <a href="/Products">. Los asistentes se activan una sola vez para todas las vistas en Views/_ViewImports.cshtml:
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpersMás precisamente, para curiosos
- El título de la página viene de
ViewData["Title"], que rellena la vista o el controlador. Así el layout es común, pero el título es distinto. - Una vista puede prescindir del layout con
@{ Layout = null; }, o elegir otro, como"_AdminLayout". - Un layout también puede tener secciones:
@RenderSection("Scripts", required: false)es un lugar que la página rellena con@section Scripts { ... }, por ejemplo con un script solo para esa página.
Míralo
Vistas parciales, ViewData y ViewBag
La fila del producto hará falta en el catálogo, en el carrito y en el pedido. Por eso va en una vista parcial (partial view), Views/Shared/_ProductRow.cshtml:
@model Product<tr><td>@Model.Name</td><td>@Model.Price €</td></tr>El catálogo la llama para cada producto con <partial>:
@model List<Product><h1>@ViewData["Title"]</h1><p>@ViewBag.Message</p><table> @foreach (Product product in Model) { <partial name="_ProductRow" model="product" /> }</table>Los controladores ponen el título en ViewData y el mensaje en ViewBag:
public class HomeController : Controller{ public IActionResult Index() { ViewData["Title"] = "Inicio"; return View(); }}
public class ProductsController : Controller{ private readonly IProductService products;
public ProductsController(IProductService products) => this.products = products;
public IActionResult Index() { ViewData["Title"] = "Productos"; ViewBag.Message = "Envío gratis a partir de 50 €"; return View(products.All()); }}La respuesta para /Products: el layout, y en <main>, la página con las cuatro filas:
<!DOCTYPE html><html lang="es"><head> <title>Productos – Café y té</title></head><body> <nav> <a href="/">Inicio</a> <a href="/Products">Productos</a> </nav> <main> <h1>Productos</h1><p>Envío gratis a partir de 50 €</p><table> <tr><td>Etiopía Yirgacheffe</td><td>14.90 €</td></tr>
<tr><td>Colombia Supremo</td><td>12.50 €</td></tr>
<tr><td>Sencha</td><td>8.40 €</td></tr>
<tr><td>Earl Grey</td><td>6.90 €</td></tr>
</table>
</main> <footer>Café y té, calle Mayor 12</footer></body></html>ViewData["Message"]yViewBag.Messageson el mismo almacenamiento.ViewDataes un diccionario con claves de tipo cadena, yViewBages lo mismo, pero con punto. Ninguno de los dos se comprueba al compilar: una errata en la clave te da un hueco vacío. Para los datos principales, usa el modelo.- Por convención, el nombre de una vista parcial empieza con
_, para que se vea que no es una página entera.
Más precisamente, para curiosos
- En proyectos más antiguos, una vista parcial se llama con
@Html.Partial("_ProductRow", product). Todavía funciona, pero espera de forma síncrona, y el compilador avisa. La forma nueva es<partial>o@await Html.PartialAsync(...). - Una vista parcial con su propia lógica (por ejemplo, el carrito del menú que cuenta sus productos por sí mismo) es un View Component: una clase más una vista, que se llama desde el layout.
- Las líneas vacías entre los
<tr>vienen de que<partial>está en su propia línea dentro del bucle. El navegador las ignora.
Pruébalo
El lugar de la página
¿Qué llama el layout en el lugar donde entra la página?
<body>
<nav>…</nav>
<main>
@()
</main>
</body>@RenderBody() inserta la vista de la acción. Todo lo que la rodea, el menú y el pie de página, es común a las páginas.
La idea
Formularios, enlace y validación
Un pedido es una clase. Los atributos sobre las propiedades dicen qué está permitido:
public class Order{ public int Id { get; set; }
[Required(ErrorMessage = "Escribe un nombre.")] public string Name { get; set; }
[Required(ErrorMessage = "Escribe un correo electrónico.")] [EmailAddress(ErrorMessage = "Eso no es un correo electrónico.")] public string Email { get; set; }
[Range(1, 20, ErrorMessage = "La cantidad va de 1 a 20.")] public int Quantity { get; set; }}El formulario es una vista con un modelo Order. asp-for crea un campo para la propiedad con el name, el id y el type correctos, y asp-validation-for es un lugar para su error:
@model Order
<form asp-action="Create" method="post"> <label asp-for="Name">Nombre</label> <input asp-for="Name" /> <span asp-validation-for="Name"></span>
<label asp-for="Email">Correo electrónico</label> <input asp-for="Email" /> <span asp-validation-for="Email"></span>
<label asp-for="Quantity">Cantidad</label> <input asp-for="Quantity" /> <span asp-validation-for="Quantity"></span>
<button>Pedir</button></form>El controlador tiene dos acciones Create: con GET muestra el formulario vacío, y con POST lo recibe:
public class OrdersController : Controller{ private readonly OrderBook book;
public OrdersController(OrderBook book) => this.book = book;
[HttpGet] public IActionResult Create() => View(new Order { Quantity = 1 });
[HttpPost] public IActionResult Create(Order order) { if (!ModelState.IsValid) return View(order); int id = book.Add(order); return RedirectToAction("Thanks", new { id }); }
public IActionResult Thanks(int id) { Order order = book.Find(id); if (order == null) return NotFound(); return View(order); }}Create(Order order)recibe un objeto ya hecho. ASP.NET Core toma los campos del formulario por sus nombres (Name,Email,Quantity) y los pone en las propiedades. Esto se llama enlace de modelos (model binding).- Mientras enlaza, comprueba los atributos. El resultado está en
ModelState:IsValidesfalsesi alguna regla no se cumple. - Si hay un error, devuelves el mismo formulario con
View(order): lo escrito se queda, y cada campo tiene su error al lado. - Si todo va bien, guardas y rediriges.
new { id }es un objeto con una propiedad,id, a partir del cual se construye la dirección/Orders/Thanks/1. El patrón se llama POST, redirección, GET (PRG): al recargar la página «Gracias», el pedido no se envía por segunda vez.
Míralo
El formulario en acción
El cliente abre el formulario vacío, envía uno incorrecto y después uno correcto:
var http = new HttpClient(new HttpClientHandler { AllowAutoRedirect = false });string site = "http://localhost:8244";
string empty = await http.GetStringAsync(site + "/Orders/Create");Console.WriteLine(empty.Split('\n').First(line => line.Contains("id=\"Name\"")).Trim());
var bad = new FormUrlEncodedContent(new Dictionary<string, string> { ["Name"] = "", ["Email"] = "ana@", ["Quantity"] = "0" });HttpResponseMessage rejected = await http.PostAsync(site + "/Orders/Create", bad);Console.WriteLine("Formulario incorrecto: " + (int)rejected.StatusCode);string page = await rejected.Content.ReadAsStringAsync();foreach (string line in page.Split('\n').Where(line => line.Contains("field-validation-error") || line.Contains("value=\"ana@\""))){ Console.WriteLine(line.Trim());}
var good = new FormUrlEncodedContent(new Dictionary<string, string> { ["Name"] = "Ana", ["Email"] = "ana@example.es", ["Quantity"] = "2" });HttpResponseMessage accepted = await http.PostAsync(site + "/Orders/Create", good);Console.WriteLine("Formulario correcto: " + (int)accepted.StatusCode + ", Location: " + accepted.Headers.Location);Console.Write(await http.GetStringAsync(site + accepted.Headers.Location));<input type="text" data-val="true" data-val-required="Escribe un nombre." id="Name" name="Name" value="" />Formulario incorrecto: 200<span class="field-validation-error" data-valmsg-for="Name" data-valmsg-replace="true">Escribe un nombre.</span><input type="email" class="input-validation-error" data-val="true" data-val-email="Eso no es un correo electrónico." data-val-required="Escribe un correo electrónico." id="Email" name="Email" value="ana@" /><span class="field-validation-error" data-valmsg-for="Email" data-valmsg-replace="true">Eso no es un correo electrónico.</span><span class="field-validation-error" data-valmsg-for="Quantity" data-valmsg-replace="true">La cantidad va de 1 a 20.</span>Formulario correcto: 302, Location: /Orders/Thanks/1<p>¡Gracias, Ana! El pedido n.º 1 de 2 uds. ha sido recibido.</p><p>Recibirás una confirmación en ana@example.es.</p>- La primera línea es el campo que
asp-forcreó a partir deName.data-val-requiredlleva el mensaje al navegador: con la biblioteca de validación de JavaScript, muestra el error incluso antes de enviar el formulario. - El formulario incorrecto devuelve 200: la misma página, con la clase
field-validation-errory los mensajes. El correo conserva lo que se escribió,ana@. - El formulario correcto es un 302 a
/Orders/Thanks/1, y allí está la confirmación de la vistaThanks.cshtml:
@model Order<p>¡Gracias, @Model.Name! El pedido n.º @Model.Id de @Model.Quantity uds. ha sido recibido.</p><p>Recibirás una confirmación en @Model.Email.</p>Más precisamente, para curiosos
- La comprobación en el navegador es por comodidad. La comprobación en la que confías es
ModelState.IsValiden el servidor: cualquiera puede enviar un POST sin navegador, igual que este programa. - Más atributos:
[StringLength(50)],[Phone],[Compare("Password")](dos campos deben coincidir),[RegularExpression(...)].[DataType(DataType.Password)]no comprueba nada; le dice aasp-forque creetype="password". <form asp-action="Create" method="post">también añade un campo oculto__RequestVerificationToken. Con[ValidateAntiForgeryToken]sobre la acción POST, el servidor acepta solo los formularios que él mismo entregó. Así otro sitio no puede enviar un pedido en nombre del usuario que ha iniciado sesión (el ataque se llama CSRF).Quantityes unint, así que es obligatorio incluso sin[Required]: un campo vacío no puede convertirse en un número.
Pruébalo
El pedido pasa sin nombre
Un formulario con el nombre vacío se guarda, y el usuario ve «¡Gracias, !». Y un formulario correcto vuelve con errores. ¿En qué línea está el problema?
Elige la línea con el error.
La condición está al revés. View(order) debe devolverse cuando el modelo no es válido: if (!ModelState.IsValid) return View(order);. Ahora mismo los pedidos incorrectos se guardan, y los correctos vuelven al formulario.
Ejercicio
Las páginas de la tienda
Resuélvelos en un proyecto de dotnet new mvc. Los productos y IProductService son de la lección de MVC.
1. Una tabla de productos. Index pasa los productos ordenados por precio. La vista es una tabla con columnas de producto, categoría y precio, y la última fila es el total con @Model.Sum(...):
<table> <tr><th>Producto</th><th>Categoría</th><th>Precio</th></tr> <tr><td>Earl Grey</td><td>té</td><td>6.90 €</td></tr> <tr><td>Sencha</td><td>té</td><td>8.40 €</td></tr> <tr><td>Colombia Supremo</td><td>café</td><td>12.50 €</td></tr> <tr><td>Etiopía Yirgacheffe</td><td>café</td><td>14.90 €</td></tr> <tr><td colspan="2">Total</td><td>42.70 €</td></tr></table>2. Un layout común con menú. HomeController tiene Index y Contacts. El layout tiene un menú hecho con asistentes de etiquetas y un título de ViewData["Title"]. Contacts envía el teléfono con ViewBag.Phone:
<!DOCTYPE html><html lang="es"><head> <title>Contacto – Café y té</title></head><body> <nav> <a href="/">Inicio</a> <a href="/Home/Contacts">Contacto</a> </nav> <h1>Contacto</h1><p>Teléfono: 912 345 678</p>
</body></html>3. Reservar una cata. El formulario Booking tiene un nombre ([Required]), un correo electrónico ([Required], [EmailAddress]) y un número de plazas de 1 a 8 ([Range]). Si hay un error, el formulario vuelve, y si todo va bien, redirige a Done, que dice cuántas plazas se reservaron y cuántas hay en total hasta ahora. El cliente envía tres formularios: uno incorrecto (un correo sin @ y 12 plazas) y dos correctos. Para el incorrecto imprime el número de mensajes, y para uno correcto, la página a la que llega después de la redirección:
Errores: 2Plazas reservadas: 3. Total para la cata: 3.Plazas reservadas: 2. Total para la cata: 5.Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: async y await · Cómo funciona internet · Un servidor web con Minimal API · MVC: modelo, vista, controlador
- Continúa en: WebAPI: datos para otros programas · Entrada no confiable
- Ejercicios sobre el tema: Web y escritorio