Изгледи с Razor
Проблем
HTML, сглобен от низове
В предишния урок Details върна изглед с три реда. Каталогът обаче е таблица с четири продукта, всяка страница има едно и също меню, а формата за поръчка трябва да не пуска празно име и количество −3.
Да сглобиш всичко това с "<tr><td>" + product.Name + ... е бавно и лесно се греши. И опасно: ако някой даде на продукт име <script>…</script>, низът го слага в страницата като истински код и той тръгва в браузъра на всеки купувач. Тази атака се казва XSS (cross-site scripting).
Виж го
Razor: HTML с C# вътре
Изгледът е файл .cshtml. Той е HTML, а @ превключва към C#. Контролерът подава списъка и заглавие:
public class ProductsController : Controller{ private readonly IProductService products;
public ProductsController(IProductService products) => this.products = products;
public IActionResult Index() { ViewData["Title"] = "Каталог"; return View(products.All()); }}Изгледът Views/Products/Index.cshtml:
@model List<Product><h1>@ViewData["Title"]</h1><table> <tr><th>Продукт</th><th>Цена</th><th></th></tr> @foreach (Product product in Model) { <tr> <td>@product.Name</td> <td>@product.Price €</td> @if (product.Price > 10) { <td>подбрано</td> } else { <td></td> } </tr> }</table><p>Продукти: @Model.Count</p><h1>Каталог</h1><table> <tr><th>Продукт</th><th>Цена</th><th></th></tr> <tr> <td>Етиопия Йиргачефе</td> <td>14.90 €</td> <td>подбрано</td> </tr> <tr> <td>Колумбия Супремо</td> <td>12.50 €</td> <td>подбрано</td> </tr> <tr> <td>Сенча</td> <td>8.40 €</td> <td></td> </tr> <tr> <td>Ерл Грей</td> <td>6.90 €</td> <td></td> </tr></table><p>Продукти: 4</p>@model List<Product>казва какъв обект подава контролерът. В изгледа той еModel. Така изгледът е строго типизиран: грешка като@Model.Contсе хваща още приdotnet build.@product.Nameвмъква стойност.@foreachи@ifса обикновен C#, а HTML-ът в тях се повтаря или показва според условието.ViewData["Title"]е речник, с който контролерът праща дребни неща на изгледа, освен модела.- Всяка стойност се кодира:
<става<. Име<script>се показва като текст и не се изпълнява. Това е защитата от XSS, без да правиш нищо.
Шаблонът на изгледа се казва Razor. Няма специален синтаксис за край на C#: Razor сам познава кога започва HTML таг.
Същото в по-кратък изглед, продукт по продукт:
@model List<Product><h1>Каталог</h1><ul>@foreach (Product product in Model){ <li>@product.Name: @product.Price €</li>}</ul><p>Продукти: @Model.Count</p>Стъпка по стъпка, с думи
- Обикновеният HTML отива в отговора както е.
- Контролерът подава списък с 4 продукта. В изгледа той е Model.
- @foreach взима първия продукт. @product.Name става „Етиопия Йиргачефе“, @product.Price става 14.90.
- Следващият продукт: същият ред <li> със „Колумбия Супремо“ и 12.50.
- Следващият продукт: същият ред <li> със „Сенча“ и 8.40.
- Следващият продукт: същият ред <li> със „Ерл Грей“ и 6.90.
- Цикълът свърши. @Model.Count вмъква 4. Браузърът получава готов HTML, без нито едно @.
По-точно, за любопитните
- Израз с интервали или оператори се слага в скоби:
@(product.Price * 2). Блок C# без изход е@{ ... }, например@{ ViewData["Title"] = "Каталог"; }в началото на изгледа. - Ако наистина искаш да вмъкнеш готов HTML, без кодиране, има
@Html.Raw(text). Ползва се само за текст, който си написал ти, никога за въведен от потребителя. @DateTime.Now.Yearвмъква текущата година: удобно за „© 2026“ най-долу, която не трябва да сменяш всяка година.- Защо
AddWebEncodersсUnicodeRanges.Allе вProgram.cs, е обяснено в урока за MVC: без него кирилицата излиза катоК. Браузърът я показва правилно и в двата случая.
Пробвай
Кое ще се покаже
Действието Cheap подава всички четири продукта. Какво връща изгледът?
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) от @Model.Count</p>Вътре в @foreach вече си в C#, затова if е без @. Показват се само продуктите под 10 €. В края @Model.Count(p => p.Price < 10) е LINQ, извикан направо в изгледа: 2 от 4.
Идеята
Общ layout
Менюто и долният ред са на всяка страница. Вместо да ги копираш, ги слагаш в layout: рамка, в която влиза страницата. Той е във Views/Shared/_Layout.cshtml:
<!DOCTYPE html><html lang="bg"><head> <title>@ViewData["Title"] – Кафе и чай</title></head><body> <nav> <a asp-controller="Home" asp-action="Index">Начало</a> <a asp-controller="Products" asp-action="Index">Продукти</a> </nav> <main> @RenderBody() </main> <footer>Кафе и чай, ул. „Шипка“ 12</footer></body></html>@RenderBody() е мястото на страницата. Кой layout да се ползва, казва Views/_ViewStart.cshtml, който върви преди всеки изглед:
@{ Layout = "_Layout";}<a asp-controller="Products" asp-action="Index"> е таг-помощник (tag helper): атрибутите asp-… се изпълняват на сървъра и сглобяват адреса от маршрута. В отговора става <a href="/Products">. Помощниците се включват веднъж за всички изгледи във Views/_ViewImports.cshtml:
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpersПо-точно, за любопитните
- Заглавието на страницата идва от
ViewData["Title"], което изгледът или контролерът попълва. Така layout-ът е общ, а заглавието е различно. - Изглед може да мине без layout с
@{ Layout = null; }или да избере друг, например"_AdminLayout". - Layout може да има и секции:
@RenderSection("Scripts", required: false)е място, което страницата попълва с@section Scripts { ... }, например със скрипт само за нея.
Виж го
Частичен изглед, ViewData и ViewBag
Редът на продукта ще трябва и в каталога, и в кошницата, и в поръчката. Затова е в частичен изглед (partial view) Views/Shared/_ProductRow.cshtml:
@model Product<tr><td>@Model.Name</td><td>@Model.Price €</td></tr>Каталогът го вика за всеки продукт с <partial>:
@model List<Product><h1>@ViewData["Title"]</h1><p>@ViewBag.Message</p><table> @foreach (Product product in Model) { <partial name="_ProductRow" model="product" /> }</table>Контролерите слагат заглавието в ViewData, а съобщението в ViewBag:
public class HomeController : Controller{ public IActionResult Index() { ViewData["Title"] = "Начало"; return View(); }}
public class ProductsController : Controller{ private readonly IProductService products;
public ProductsController(IProductService products) => this.products = products;
public IActionResult Index() { ViewData["Title"] = "Продукти"; ViewBag.Message = "Безплатна доставка над 50 €"; return View(products.All()); }}Отговорът на /Products: layout-ът, а в <main> страницата с четирите реда:
<!DOCTYPE html><html lang="bg"><head> <title>Продукти – Кафе и чай</title></head><body> <nav> <a href="/">Начало</a> <a href="/Products">Продукти</a> </nav> <main> <h1>Продукти</h1><p>Безплатна доставка над 50 €</p><table> <tr><td>Етиопия Йиргачефе</td><td>14.90 €</td></tr>
<tr><td>Колумбия Супремо</td><td>12.50 €</td></tr>
<tr><td>Сенча</td><td>8.40 €</td></tr>
<tr><td>Ерл Грей</td><td>6.90 €</td></tr>
</table>
</main> <footer>Кафе и чай, ул. „Шипка“ 12</footer></body></html>ViewData["Message"]иViewBag.Messageса едно и също хранилище.ViewDataе речник с ключ низ, аViewBagе същото, но с точка. И двете не се проверяват при компилиране: правописна грешка в ключа дава празно място. За главните данни ползвай модела.- Името на частичния изглед по конвенция започва с
_, за да се вижда, че не е цяла страница.
По-точно, за любопитните
- В по-старите проекти частичният изглед се вика с
@Html.Partial("_ProductRow", product). То работи и днес, но чака синхронно и компилаторът предупреждава. Новият начин е<partial>или@await Html.PartialAsync(...). - Частичен изглед със собствена логика (например кошницата в менюто, която сама брои продуктите) е View Component: клас плюс изглед, който се вика от layout-а.
- Празните редове между
<tr>са от това, че<partial>е на отделен ред в цикъла. Браузърът ги пренебрегва.
Пробвай
Мястото на страницата
Какво вика layout-ът там, където влиза страницата?
<body>
<nav>…</nav>
<main>
@()
</main>
</body>@RenderBody() вмъква изгледа на действието. Всичко около него, менюто и долният ред, е общо за страниците.
Идеята
Форма, свързване и валидация
Поръчката е клас. Атрибутите над свойствата казват какво е допустимо:
public class Order{ public int Id { get; set; }
[Required(ErrorMessage = "Въведи име.")] public string Name { get; set; }
[Required(ErrorMessage = "Въведи имейл.")] [EmailAddress(ErrorMessage = "Това не е имейл.")] public string Email { get; set; }
[Range(1, 20, ErrorMessage = "Количеството е от 1 до 20.")] public int Quantity { get; set; }}Формата е изглед с модел Order. asp-for прави поле за свойството с правилните name, id и type, а asp-validation-for е място за грешката му:
@model Order
<form asp-action="Create" method="post"> <label asp-for="Name">Име</label> <input asp-for="Name" /> <span asp-validation-for="Name"></span>
<label asp-for="Email">Имейл</label> <input asp-for="Email" /> <span asp-validation-for="Email"></span>
<label asp-for="Quantity">Брой</label> <input asp-for="Quantity" /> <span asp-validation-for="Quantity"></span>
<button>Поръчай</button></form>Контролерът има две действия Create: с GET показва празната форма, а с POST я приема:
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)получава готов обект. ASP.NET Core взима полетата от формата по имената им (Name,Email,Quantity) и ги слага в свойствата. Това се казва свързване на модела (model binding).- Докато свързва, проверява атрибутите. Резултатът е в
ModelState:IsValidеfalse, ако някое правило не е спазено. - При грешка връщаш същата форма с
View(order): въведеното остава, а до всяко поле е грешката му. - При успех записваш и пренасочваш.
new { id }е обект с едно свойствоid, от което се сглобява адресът/Orders/Thanks/1. Шаблонът се казва POST, пренасочване, GET (PRG): презареждането на страницата „Благодарим“ не праща поръчката втори път.
Виж го
Формата в действие
Клиентът отваря празната форма, праща грешна и после вярна:
var http = new HttpClient(new HttpClientHandler { AllowAutoRedirect = false });string site = "http://localhost:5244";
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("Грешна форма: " + (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"] = "Ана", ["Email"] = "ana@example.bg", ["Quantity"] = "2" });HttpResponseMessage accepted = await http.PostAsync(site + "/Orders/Create", good);Console.WriteLine("Вярна форма: " + (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="Въведи име." id="Name" name="Name" value="" />Грешна форма: 200<span class="field-validation-error" data-valmsg-for="Name" data-valmsg-replace="true">Въведи име.</span><input type="email" class="input-validation-error" data-val="true" data-val-email="Това не е имейл." data-val-required="Въведи имейл." id="Email" name="Email" value="ana@" /><span class="field-validation-error" data-valmsg-for="Email" data-valmsg-replace="true">Това не е имейл.</span><span class="field-validation-error" data-valmsg-for="Quantity" data-valmsg-replace="true">Количеството е от 1 до 20.</span>Вярна форма: 302, Location: /Orders/Thanks/1<p>Благодарим, Ана! Поръчка № 1 за 2 бр. е приета.</p><p>Потвърждение ще получиш на ana@example.bg.</p>- Първият ред е полето, което
asp-forнаправи отName.data-val-requiredноси съобщението до браузъра: с JavaScript библиотеката за валидация той показва грешката, още преди формата да тръгне. - Грешната форма връща 200: същата страница, с класа
field-validation-errorи съобщенията. Имейлът пази въведенотоana@. - Вярната форма е 302 към
/Orders/Thanks/1, а там е потвърждението от изгледаThanks.cshtml:
@model Order<p>Благодарим, @Model.Name! Поръчка № @Model.Id за @Model.Quantity бр. е приета.</p><p>Потвърждение ще получиш на @Model.Email.</p>По-точно, за любопитните
- Проверката в браузъра е за удобство. Проверката, на която разчиташ, е
ModelState.IsValidна сървъра: всеки може да прати POST и без браузър, както тази програма. - Още атрибути:
[StringLength(50)],[Phone],[Compare("Password")](две полета да съвпадат),[RegularExpression(...)].[DataType(DataType.Password)]не проверява нищо, а казва наasp-forда направиtype="password". <form asp-action="Create" method="post">слага и скрито поле__RequestVerificationToken. С[ValidateAntiForgeryToken]над POST действието сървърът приема само форми, които сам е дал. Така чужд сайт не може да прати поръчка от името на влезлия потребител (атаката се казва CSRF).Quantityеint, затова е задължително и без[Required]: празно поле не може да стане число.
Пробвай
Поръчката минава без име
Формата с празно име се записва и потребителят вижда „Благодарим, !“. А вярната форма се връща с грешки. На кой ред е проблемът?
Избери реда с грешката.
Условието е обърнато. View(order) трябва да се върне, когато моделът не е валиден: if (!ModelState.IsValid) return View(order);. Сега грешните поръчки се записват, а верните се връщат на формата.
Упражнение
Страниците на магазина
Реши задачите в проект от dotnet new mvc. Продуктите и IProductService са от урока за MVC.
1. Таблица с продукти. Index подава продуктите подредени по цена. Изгледът е таблица с колони продукт, категория и цена, а последният ред е общата сума с @Model.Sum(...):
<table> <tr><th>Продукт</th><th>Категория</th><th>Цена</th></tr> <tr><td>Ерл Грей</td><td>чай</td><td>6.90 €</td></tr> <tr><td>Сенча</td><td>чай</td><td>8.40 €</td></tr> <tr><td>Колумбия Супремо</td><td>кафе</td><td>12.50 €</td></tr> <tr><td>Етиопия Йиргачефе</td><td>кафе</td><td>14.90 €</td></tr> <tr><td colspan="2">Общо</td><td>42.70 €</td></tr></table>2. Общ layout с меню. HomeController има Index и Contacts. Layout-ът е с меню от тагове-помощници и заглавие от ViewData["Title"]. Contacts праща телефона с ViewBag.Phone:
<!DOCTYPE html><html lang="bg"><head> <title>Контакти – Кафе и чай</title></head><body> <nav> <a href="/">Начало</a> <a href="/Home/Contacts">Контакти</a> </nav> <h1>Контакти</h1><p>Телефон: 02 123 4567</p>
</body></html>3. Запис за дегустация. Формата Booking има име ([Required]), имейл ([Required], [EmailAddress]) и брой места от 1 до 8 ([Range]). При грешка се връща формата, а при успех се пренасочва към Done, което казва колко места са запазени и колко са общо досега. Клиентът праща три форми: грешна (имейл без @ и 12 места) и две верни. При грешна печата броя на съобщенията, а при вярна страницата, до която стига след пренасочването:
Грешки: 2Запазени места: 3. Общо за дегустацията: 3.Запазени места: 2. Общо за дегустацията: 5.Тест