Уявіть, що на кожній сторінці вашого інтернет-магазину міститься іконка кошика з кількістю товарів. Ця кількість бере дані з бази або сесії. У правому куті — дзвіночок сповіщень: скільки непрочитаних повідомлень. Вгорі — breadcrumbs (навігаційна стрічка), що формується динамічно залежно від поточної сторінки.
Як реалізувати ці компоненти «правильно»? Перший інстинкт — Partial View. Але Partial View не може виконувати власну логіку отримання даних: він просто рендерить модель, яку йому хтось передав. Передавайте дані через ViewModel кожного Action — і ваш MovieController.Index почне знати про кошик і сповіщення. Це порушення принципу єдиної відповідальності.
Саме для цього і створені View Components (Компоненти відображення) — незалежні, самодостатні блоки UI, що мають власну логіку, власні залежності та незалежні від Controller-а, на сторінці якого вони відображаються.
Перш ніж розбиратися як це будувати, зрозуміємо коли що обирати.
| Критерій | Partial View | View Component | Tag Helper |
|---|---|---|---|
| Власна логіка отримання даних | ❌ Ні | ✅ Так | ❌ Ні |
| DI-залежності | ❌ Ні | ✅ Так | ✅ Так |
| Власна View-шаблон (.cshtml) | ✅ Так | ✅ Так | ❌ Ні (рядки/атрибути) |
| Кешування результату | ❌ Ручне | ✅ Вбудована підтримка | — |
| Складність реалізації | Мінімальна | Середня | Висока |
| Типовий сценарій | Картка статті, рядок таблиці | Кошик, меню, breadcrumbs | <email>, форматування |
Правило вибору просте:
View Component складається з двох частин:
1. C# клас — успадковує від ViewComponent, містить метод InvokeAsync (або Invoke для синхронного варіанту). Тут живе вся логіка.
2. View-шаблон — звичайний .cshtml файл за конвенційним шляхом Views/Shared/Components/{ComponentName}/Default.cshtml.
Views/
└── Shared/
└── Components/ ← папка для всіх View Components
├── ShoppingCart/
│ └── Default.cshtml ← шаблон для ShoppingCartViewComponent
├── NotificationBell/
│ └── Default.cshtml
└── Breadcrumb/
└── Default.cshtml
Назва папки — ім'я класу без суфікса ViewComponent. Ім'я файлу за замовчуванням — Default.cshtml. Ім'я можна змінити, повернувши View("CustomName", model) з InvokeAsync.
ViewComponentusing Microsoft.AspNetCore.Mvc;
namespace ShopApp.ViewComponents;
// Конвенція: суфікс ViewComponent в імені класу
public class ShoppingCartViewComponent : ViewComponent
{
// InvokeAsync — точка входу, може приймати довільні параметри
public async Task<IViewComponentResult> InvokeAsync()
{
// Логіка тут — повністю незалежна від Controller
// ...
return View(someModel); // рендерить Default.cshtml з переданою моделлю
}
}
Метод InvokeAsync повертає IViewComponentResult. На практиці майже завжди View(model) — що рендерить Default.cshtml. Але є ще:
View("ViewName", model) — рендеринг іменованого шаблонуContent("рядок") — повернення простого рядка без шаблонуПараметри InvokeAsync є довільними — ви самі визначаєте їх сигнатуру. При виклику з View вони передаються як іменовані аргументи.
Є два еквівалентних синтаксиси виклику:
Синтаксис 1: Component.InvokeAsync (традиційний)
@* Виклик без параметрів *@
@await Component.InvokeAsync("ShoppingCart")
@* Виклик з параметрами (анонімний об'єкт) *@
@await Component.InvokeAsync("NotificationBell", new { userId = currentUserId })
@* Виклик з параметром *@
@await Component.InvokeAsync("Breadcrumb", new { items = breadcrumbItems })
Синтаксис 2: Tag Helper (рекомендований, сучасніший)
@* Потрібно у _ViewImports.cshtml: @addTagHelper *, YourProjectName *@
@* Без параметрів *@
<vc:shopping-cart></vc:shopping-cart>
@* З параметрами — kebab-case ім'я компоненту та параметрів *@
<vc:notification-bell user-id="@currentUserId"></vc:notification-bell>
@* З кількома параметрами *@
<vc:breadcrumb items="@breadcrumbItems"></vc:breadcrumb>
Tag Helper-синтаксис є кращим: він виглядає як HTML і підтримує IntelliSense у Visual Studio / Rider.
ViewComponent) у kebab-case: ShoppingCartViewComponent → <vc:shopping-cart>. Параметри методу InvokeAsync також переводяться у kebab-case: userId → user-id.Розгортаємо проєкт з нуля — усі файли наведені повністю, щоб можна було скопіювати та запустити.
dotnet new mvc -n ShopApp
cd ShopApp
_ViewImports.cshtmlДля роботи Tag Helper синтаксису (<vc:...>) додайте директиву @addTagHelper у файл Views/_ViewImports.cshtml. Замініть весь вміст:
@using ShopApp
@using ShopApp.Models
@using ShopApp.ViewComponents
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@addTagHelper *, ShopApp
@addTagHelper *, ShopApp — саме він вмикає <vc:...> синтаксис. Ім'я після *, — це назва вашої збірки (збігається з <AssemblyName> у .csproj, або ж назва проєкту).Відображає іконку кошика з кількістю товарів у навбарі. Кількість зчитується з сесії.
namespace ShopApp.Services;
public interface ICartService
{
Task<int> GetItemCountAsync(string sessionId);
Task<decimal> GetTotalPriceAsync(string sessionId);
Task AddItemAsync(string sessionId, string productId, int quantity);
}
namespace ShopApp.Services;
// Проста in-memory реалізація для демо.
// У реальному проєкті тут буде робота з БД або зовнішнім кошиком.
public class SessionCartService : ICartService
{
// Імітуємо сховище — стан заради демо зберігаємо статично
private static readonly Dictionary<string, List<(string ProductId, int Qty, decimal Price)>> _store = [];
public Task<int> GetItemCountAsync(string sessionId)
{
if (!_store.TryGetValue(sessionId, out var items))
return Task.FromResult(0);
return Task.FromResult(items.Sum(i => i.Qty));
}
public Task<decimal> GetTotalPriceAsync(string sessionId)
{
if (!_store.TryGetValue(sessionId, out var items))
return Task.FromResult(0m);
return Task.FromResult(items.Sum(i => i.Price * i.Qty));
}
public Task AddItemAsync(string sessionId, string productId, int quantity)
{
if (!_store.TryGetValue(sessionId, out var items))
{
items = [];
_store[sessionId] = items;
}
// Для демо — ціна фіксована 100 грн за одиницю
items.Add((productId, quantity, 100m));
return Task.CompletedTask;
}
}
using Microsoft.AspNetCore.Mvc;
using ShopApp.Services;
namespace ShopApp.ViewComponents;
public class ShoppingCartViewComponent : ViewComponent
{
private readonly ICartService _cart;
// DI — ін'єктуємо залежності через конструктор, як у звичайному сервісі
public ShoppingCartViewComponent(ICartService cart)
{
_cart = cart;
}
// Параметрів немає — все що потрібно, зчитуємо самостійно
public async Task<IViewComponentResult> InvokeAsync()
{
// HttpContext доступний через властивість HttpContext (від ViewComponent)
var sessionId = HttpContext.Session.GetString("CartId") ?? "default";
var itemCount = await _cart.GetItemCountAsync(sessionId);
var totalPrice = await _cart.GetTotalPriceAsync(sessionId);
var model = new CartSummary(itemCount, totalPrice);
return View(model); // → Views/Shared/Components/ShoppingCart/Default.cshtml
}
}
// Мінімальна модель для View
public record CartSummary(int ItemCount, decimal TotalPrice);
@model ShopApp.ViewComponents.CartSummary
<a href="/cart" class="nav-link position-relative">
<i class="bi bi-cart3 fs-5"></i>
@if (Model.ItemCount > 0)
{
@* Бейдж з кількістю товарів *@
<span class="position-absolute top-0 start-100 translate-middle badge rounded-pill bg-danger">
@(Model.ItemCount > 99 ? "99+" : Model.ItemCount.ToString())
<span class="visually-hidden">товарів у кошику</span>
</span>
}
</a>
Відображає дзвіночок з кількістю непрочитаних сповіщень для поточного користувача. Приймає userId як параметр.
namespace ShopApp.Services;
public interface INotificationService
{
Task<int> GetUnreadCountAsync(string userId);
Task<IReadOnlyList<NotificationItem>> GetRecentAsync(string userId, int limit);
}
// Тут, а не у ViewComponent — щоб сервіс міг їх повертати безпосередньо
public record NotificationItem(string Message, DateTime CreatedAt, bool IsRead);
namespace ShopApp.Services;
// Заглушка для демо — повертає статичні дані
public class StubNotificationService : INotificationService
{
private static readonly List<NotificationItem> _notifications =
[
new("Ваше замовлення #1042 відправлено", DateTime.Now.AddMinutes(-15), false),
new("Акція: знижка 20% на електроніку до кінця дня!", DateTime.Now.AddHours(-2), false),
new("Новий коментар до вашого відгуку", DateTime.Now.AddHours(-5), true),
new("Замовлення #1041 доставлено", DateTime.Now.AddDays(-1), true),
new("Вітаємо з реєстрацією!", DateTime.Now.AddDays(-7), true),
];
public Task<int> GetUnreadCountAsync(string userId)
{
var count = _notifications.Count(n => !n.IsRead);
return Task.FromResult(count);
}
public Task<IReadOnlyList<NotificationItem>> GetRecentAsync(string userId, int limit)
{
IReadOnlyList<NotificationItem> result = _notifications.Take(limit).ToList();
return Task.FromResult(result);
}
}
using Microsoft.AspNetCore.Mvc;
using ShopApp.Services;
namespace ShopApp.ViewComponents;
public class NotificationBellViewComponent : ViewComponent
{
private readonly INotificationService _notifications;
public NotificationBellViewComponent(INotificationService notifications)
{
_notifications = notifications;
}
// Параметр userId — передається при виклику з View
public async Task<IViewComponentResult> InvokeAsync(string userId)
{
// Гість — сповіщень немає
if (string.IsNullOrEmpty(userId) || userId == "guest")
{
return View(new NotificationSummary(0, []));
}
var unreadCount = await _notifications.GetUnreadCountAsync(userId);
var recentNotifications = await _notifications.GetRecentAsync(userId, limit: 5);
var model = new NotificationSummary(unreadCount, recentNotifications);
return View(model);
}
}
public record NotificationSummary(int UnreadCount, IReadOnlyList<NotificationItem> Recent);
@using ShopApp.Services
@model ShopApp.ViewComponents.NotificationSummary
<div class="dropdown">
<button class="btn btn-link nav-link position-relative dropdown-toggle"
data-bs-toggle="dropdown">
<i class="bi bi-bell fs-5"></i>
@if (Model.UnreadCount > 0)
{
<span class="position-absolute top-0 start-100 translate-middle badge rounded-pill bg-primary">
@Model.UnreadCount
</span>
}
</button>
<div class="dropdown-menu dropdown-menu-end shadow" style="min-width: 300px;">
<h6 class="dropdown-header">Сповіщення</h6>
@if (!Model.Recent.Any())
{
<div class="dropdown-item-text text-muted small">Немає нових сповіщень</div>
}
else
{
@foreach (var n in Model.Recent)
{
<div class="dropdown-item @(n.IsRead ? "" : "fw-bold")">
<div class="small">@n.Message</div>
<div class="text-muted" style="font-size: 0.75rem;">
@n.CreatedAt.ToString("dd MMM, HH:mm")
</div>
</div>
}
<div class="dropdown-divider"></div>
<a href="/notifications" class="dropdown-item text-center small">
Всі сповіщення →
</a>
}
</div>
</div>
Breadcrumbs — навігація «де я знаходжусь». Приймає список елементів і рендерить їх.
using Microsoft.AspNetCore.Mvc;
namespace ShopApp.ViewComponents;
public class BreadcrumbViewComponent : ViewComponent
{
// Цей компонент не потребує DI-сервісів — лише рендер переданих даних
// Але він все одно View Component (а не Partial View), бо може мати власну логіку
public IViewComponentResult Invoke(IReadOnlyList<BreadcrumbItem> items)
{
// Додаємо «Головна» на початок, якщо не передано
var allItems = items.Any() && items[0].Url == "/"
? items
: new[] { new BreadcrumbItem("Головна", "/") }.Concat(items).ToList();
return View(allItems);
}
}
// Синхронний Invoke — можна використовувати якщо немає async операцій
public record BreadcrumbItem(string Label, string? Url = null);
@model IReadOnlyList<ShopApp.ViewComponents.BreadcrumbItem>
@if (Model.Count > 1) // Показуємо лише якщо є більше одного елементу
{
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
@for (int i = 0; i < Model.Count; i++)
{
var item = Model[i];
bool isLast = i == Model.Count - 1;
if (isLast)
{
@* Поточна сторінка — без посилання *@
<li class="breadcrumb-item active" aria-current="page">@item.Label</li>
}
else
{
<li class="breadcrumb-item">
<a href="@item.Url">@item.Label</a>
</li>
}
}
</ol>
</nav>
}
<!DOCTYPE html>
<html lang="uk">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@ViewData["Title"] — ShopApp</title>
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" />
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" />
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container">
<a class="navbar-brand" href="/">ShopApp</a>
<div class="navbar-nav ms-auto align-items-center gap-2">
@* View Component викликається без жодних параметрів з Controller'ів *@
<vc:shopping-cart></vc:shopping-cart>
<vc:notification-bell user-id="demo-user"></vc:notification-bell>
</div>
</div>
</nav>
<div class="container mt-3">
@* Breadcrumb передається з конкретного View — тут лише місце для нього *@
@RenderSection("Breadcrumb", required: false)
<main role="main">
@RenderBody()
</main>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>
@using ShopApp.ViewComponents
@{
ViewData["Title"] = "Каталог товарів";
var breadcrumbs = new List<BreadcrumbItem>
{
new("Каталог", "/catalog"),
new("Електроніка", "/catalog/electronics"),
new("Ноутбук ProBook 450") // остання — без URL (поточна сторінка)
};
}
@section Breadcrumb {
<vc:breadcrumb items="@breadcrumbs"></vc:breadcrumb>
}
<h1>Ноутбук ProBook 450</h1>
<p class="text-muted">Демонстрація трьох View Components у дії.</p>
<div class="alert alert-info">
Подивіться на навбар: там працюють <strong>ShoppingCart</strong> та
<strong>NotificationBell</strong> View Components.<br/>
Вгорі — <strong>Breadcrumb</strong> View Component.
</div>
Program.csvar builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllersWithViews();
// Додаємо Session (потрібно для ShoppingCartViewComponent)
builder.Services.AddDistributedMemoryCache();
builder.Services.AddSession(options =>
{
options.IdleTimeout = TimeSpan.FromHours(1);
options.Cookie.HttpOnly = true;
options.Cookie.IsEssential = true;
});
// Сервіси, що використовуються у View Components
builder.Services.AddScoped<ICartService, SessionCartService>();
builder.Services.AddScoped<INotificationService, StubNotificationService>();
var app = builder.Build();
app.UseSession(); // ← обов'язково перед UseRouting
app.UseStaticFiles();
app.UseRouting();
app.UseAuthorization();
app.MapDefaultControllerRoute();
app.Run();
ViewComponent або мають суфікс ViewComponent.dotnet run
Відкрийте https://localhost:5001 — у навбарі побачите іконку кошика та дзвіночок зі сповіщеннями, вгорі сторінки — навігаційну стрічку breadcrumbs.
View Components добре піддаються кешуванню через Cache Tag Helper прямо у місці виклику:
@* Кешувати результат компонента на 5 хвилин *@
<cache expires-after="@TimeSpan.FromMinutes(5)">
<vc:shopping-cart></vc:shopping-cart>
</cache>
@* Кешувати окремо для кожного користувача *@
<cache vary-by-user="true" expires-after="@TimeSpan.FromMinutes(1)">
<vc:notification-bell user-id="@(User.Identity?.Name)"></vc:notification-bell>
</cache>
@* Breadcrumbs зазвичай не кешуємо — вони унікальні для кожної сторінки *@
<vc:breadcrumb items="@breadcrumbs"></vc:breadcrumb>
<cache> — це вбудований Tag Helper ASP.NET Core, що використовує IMemoryCache. Він є «обгорткою» навколо будь-якого HTML-контенту, включно з виводом View Components.
View Components можна організувати й у Area. Тоді шлях до шаблону:
Areas/{AreaName}/Views/Shared/Components/{ComponentName}/Default.cshtml
При цьому Component клас залишається у глобальному або Area-namespace — ASP.NET Core шукає шаблон спочатку в Area, потім у глобальному Views/Shared/Components/.
@* Це шаблон, специфічний для Admin Area *@
@model AdminStatsModel
<div class="admin-stats">...</div>
Завдання 1.1. Створіть LatestPostsViewComponent що отримує список із 3 останніх опублікованих статей через IArticleService і відображає їх як простий <ul> з посиланнями. Викличте його у _Layout.cshtml у сайдбарі через Tag Helper синтаксис <vc:latest-posts>.
Завдання 1.2. Модифікуйте LatestPostsViewComponent — додайте параметр int count = 3 у InvokeAsync. Тепер у деяких View виводьте 5 статей: <vc:latest-posts count="5">. Переконайтеся ДEFAULT значення 3 все ще працює без параметра.
Завдання 2.1. Реалізуйте CategoryMenuViewComponent для інтернет-магазину. Він має:
ICategoryService (Id, Name, Slug, ProductCount)ProductCount desclist-group, де у кожного елемента є назва та лічильник <span class="badge">@count</span>slug збігається з RouteData.Values["slug"]Завдання 2.2. Огорніть <vc:category-menu> у <cache vary-by-route="slug" expires-after="@TimeSpan.FromMinutes(10)">. Поясніть в коментарях: чому vary-by-route="slug" потрібен, і що станеться якщо його прибрати.
Завдання 3.1. Реалізуйте SearchSuggestionsViewComponent що повертає <ul> з підказками пошуку:
string query та string context (наприклад, "products" або "articles")query.Length < 2 — повертає Content("") (порожньо, без рендерингу шаблону)IProductSearchService або IArticleSearchService) через IServiceProvider та параметр context<ul class="suggestions-list"> з не більш ніж 5 результатамиComponent.InvokeAsync у AJAX-запиті з HTMX (hx-get="/search-suggestions?query=...") — Controller повертає PartialView що містить лише @await Component.InvokeAsync(...)ViewComponent) + шаблон Views/Shared/Components/{Name}/Default.cshtmlInvokeAsync(params) — вхідна точка компонента. Параметри довільні, передаються при виклику@await Component.InvokeAsync("Name", args) та Tag Helper <vc:name param="value"> (рекомендований)_ViewImports.cshtml: рядок @addTagHelper *, ShopApp — обов'язковий для Tag Helper синтаксису <vc:...><cache> Tag Helper прямо на місці виклику компонентаУ наступній статті — Display та Editor Templates: як оголосити власний спосіб відображення та редагування типу (наприклад, Money або DateRange) і автоматично застосовувати його через Html.DisplayFor() та Html.EditorFor().
Areas: структурування великих застосунків
Areas в ASP.NET Core MVC: структура папок, атрибут [Area], MapAreaControllerRoute, cross-area посилання через asp-area, конфлікти маршрутів. Демо: застосунок з Areas/Admin та Areas/Public — Dashboard, ArticleManager, ArticleList.
Display та Editor Templates
Display та Editor Templates в ASP.NET Core MVC: папки DisplayTemplates та EditorTemplates, Html.DisplayFor та Html.EditorFor, атрибут [UIHint], шаблони для складних типів (Money, DateRange, Address). Демо: ProductCard Display Template та MoneyEditor Editor Template.