
Привет, Хабр! Продолжаю делать интернет магазин на Blazor. В этой части расскажу о том как добавил в него возможность просмотра корзины товаров и организовал работу с состоянием. За подробностями добро пожаловать под кат.
Содержание
- Blazor + MVVM = Silverlight наносит ответный удар, потому что древнее зло непобедимо
- Blazor Client Side Интернет Магазин: Часть 1 — Авторизация oidc (oauth2) + Identity Server4
- Blazor Client Side Интернет Магазин: Часть 2 — CI/CD
- Blazor Client Side Интернет Магазин: Часть 3 — Витрина товаров
- Blazor Client Side Интернет Магазин: Часть 4 — Добавления товара в корзину
- Blazor Client Side Интернет Магазин: Часть 5 — Просмотр корзины и работа с Stateful
Ссылки
→ Исходники
→ Образы на Docker Registry
Stateful
Мне не понравилось что при переходе между страницами теряется состояние. Например те поля по которым я отфильтровал товары. Чтобы решить эту проблему я перешел к stateful сервисам-синглтонам и зарегистрировал ViewModel страницы в DI контейнере как синглтон. По сути я использовал DI контейнер как хранилище состояния, а ViewModel начал инжектить в View как сервис.
Код
1) Models
public sealed class ProductModel { public Guid Id { get; set; } public string Version { get; set; } public string Title { get; set; } public decimal Price { get; set; } }
public class BasketLineModel { public uint Quantity { get; set; } public ProductModel Product { get; set; } }
public class BasketModel { public List<BasketLineModel> Lines { get; set; } = new List<BasketLineModel>(); }
2) Services
public class BasketService : IBasketService { private readonly IApiRepository _repository; private BasketModel _basket; public BasketService(IApiRepository repository) { _repository = repository; _basket = new BasketModel(); } public string Error { get; private set; } public IReadOnlyList<BasketLineModel> Model => _basket.Lines.AsReadOnly(); public event EventHandler OnBasketItemsCountChanged; public long ItemsCount => _basket?.Lines?.Sum(l => l.Quantity) ?? 0; public async Task Load() { var count = ItemsCount; var (r, e) = await _repository.GetBasket(); _basket = r; Error = e; if (string.IsNullOrWhiteSpace(Error) && count != ItemsCount) OnBasketItemsCountChanged?.Invoke(null, null); } public async Task Add(ProductModel product) { var (_, e) = await _repository.AddToBasket(product); Error = e; if (!string.IsNullOrWhiteSpace(e)) return; await Load(); } public async Task Remove(ProductModel product) { var (_, e) = await _repository.Remove(product.Id); Error = e; if (!string.IsNullOrWhiteSpace(e)) return; await Load(); } }
Тут надо рассказать про
public event EventHandler OnBasketItemsCountChanged;
Я хотел в заголовке страницы отображать текущее количество товаров в корзине. Проблема в том что заголовок не является дочерним элементом по отношению к странице корзины покупок поэтому обновлении ее стояния он игнорирует. Чтобы он перерисовывался я и добавил это событие, а в нем повесил вот такой обработчик:
@using BlazorEShop.Spa.BlazorWasm.Client.Core.Services @using Microsoft.AspNetCore.Components.Authorization @using Microsoft.AspNetCore.Components.WebAssembly.Authentication @inject NavigationManager Navigation @inject SignOutSessionStateManager SignOutManager @inject IBasketService Basket @implements IDisposable <AuthorizeView> <Authorized> <span class="text-success">Total Items In Basket: @TotalItemsCount </span> Hello, @context?.User?.Identity?.Name! <button class="nav-link btn btn-link" @onclick="BeginSignOut">Log out</button> </Authorized> <NotAuthorized> <a href="authentication/login">Log in</a> </NotAuthorized> </AuthorizeView> @code { public long TotalItemsCount { get; set; } protected override void OnInitialized() { Basket.OnBasketItemsCountChanged += Bind; TotalItemsCount = Basket.ItemsCount; base.OnInitialized(); } public void Dispose() { Basket.OnBasketItemsCountChanged -= Bind; } public void Bind(object s, EventArgs e) { if (TotalItemsCount == Basket.ItemsCount) return; TotalItemsCount = Basket.ItemsCount; this.StateHasChanged(); } private async Task BeginSignOut(MouseEventArgs args) { await SignOutManager.SetSignOutState(); Navigation.NavigateTo("authentication/logout"); } }
3) ViewModel
public class BasketViewModel { private bool _isInitialized; private readonly IBasketService _service; public BasketViewModel(IBasketService service) { _service = service; } public string Error => _service.Error; public IReadOnlyList<BasketLineModel> Model => _service.Model; public async Task OnInitializedAsync() { if (_isInitialized) return; Console.WriteLine("BASKET INIT!"); await _service.Load(); _isInitialized = true; } public Task Add(ProductModel product) => _service.Add(product); public Task Remove(ProductModel product) => _service.Remove(product); }
4) View
@page "/basket" @attribute [Authorize] @inject BasketViewModel ViewModel <h3>Basket</h3> <Error Model="@ViewModel.Error" /> <input type="button" class="btn btn-primary my-3" value="Create Order" /> <!--TODO: реализовать создание заказа--> <div class="table-responsive"> <table class="table"> <thead> <tr> <th>Title</th> <th>Price</th> <th>Quantity</th> <th></th> </tr> </thead> <tbody> @if (ViewModel.Model == null) { <tr> <td> <em>Loading...</em> </td> </tr> } else { foreach (var line in ViewModel.Model) { <tr> <td>@line.Product.Title</td> <td>@line.Product.Price</td> <td>@line.Quantity</td> <td> <input type="button" class="btn btn-success" @onclick="@(async x=>await ViewModel.Add(line.Product))" value="+" /> <input class="btn btn-warning" value="-" type="button" @onclick="@(async x=>await ViewModel.Remove(line.Product))" /> </td> </tr> } } </tbody> </table> </div> @code { protected override async Task OnInitializedAsync() { await ViewModel.OnInitializedAsync(); } }
5) Регистрация в DI контейнере
services.AddTransient<IApiRepository, ApiRepository>(); services.AddSingleton<IBasketService, BasketService>(); services.AddSingleton<BasketViewModel>();
Вариант на Angular 9
Пока что разработка на Blazor мне больше удовольствия приносит чем Ангуляр.

