Привет! Таки да, скоро выходит net core 3.0 и там будет шаблон проекта с Blazor как один из дефолтных. Название у фреймворка, по-моему, похоже на название какого-нибудь покемона. Блазор вступает в бой! Решил я значит глянуть что за зверь такой и с чем его едят поэтому сделал на нем Todo лист. Ну и на Vue.js тоже, для сравнения с сабжем потому что по моему они похожи система компонентов в обоих и реактивность и вот это все. Больше тудушек богу тудушек! По факту это Гайд для юных, не окрепших умов которым лень TypeScript или JavaScript учить а кнопочки и инпуты на сайте сделать хочется. Как в том меме -«Технарь хотел написать книгу но получилась инструкция». Кому интересны мои похождения в фронт энде или узнать что за Blazor такой добро пожаловать под кат.
Введение
Была когда-то у Майкрософт идея работы C# в браузере и звали эту идею Силверлайт. Не взлетело. Эти ваши тырнеты были тогда другие как собственно и браузеры. Почему я думаю что сейчас взлетит? Потому что сейчас веб ассембли есть во всех современных браузерах по дефолту. Нет необходимости в установке отдельного расширения. Другая проблема размеры приложения. Если на Vue.js SPA вести 1.7 мегабайт, то точно такое же на Blazor 21 мегабайт. Сейчас интернет быстрее и надежнее стал чем во времена Сильверлайта да и скачивать приложение надо один раз, а дальше там кеш и все дела. Вообще Blazor мне показался очень похожим на Vue.js. И так, как дань уважения Silverligtht, WPF и UWP да и просто потому что у шарпистов так принято я решил использовать паттерн MVVM для своего проекта. Так для справки — Я вообще бекэндшик и мне Blazor понравился. Слабонервных предупреждаю — Дизайн и верстка в моих примерах ужасные, а в проекте с Vue.js опытный фронтэндшик может узреть много говнокода. Ну и с орфографией и пунктуацией дела тоже так себе.
Ссылки
Пример Todo на Vue + Vuex — gitlab.com/VictorWinbringer/vuetodo
Привер Todo на Blazor — gitlab.com/VictorWinbringer/blazorexample
Модели размещения
- На стороне клиента. Стандартное SPA которое можно раздавать различными способами. В моем примере я использовал шаблон в котором файлы приложения отдает браузеру сервер на asp.net core. Минус этого подхода в тех самых 21 мегабайтах которые нужно скачать браузеру.
- На стороне сервера. Все происходит на сервере, а клиенту через сокеты передается готовый DOM. Браузеру вообще почти ничего не надо скачивать в начале, но зато вместо этого постоянно по кускам скачивать обновленный DOM. Ну и вся нагрузка по клиентскому коду внезапно взваливается на сервер.
Мне лично первый вариант больше нравиться и его можно использовать во всех тех случаях когда вам не нужно беспокоиться о конверсии пользователей. Например это какая-то внутренняя информационная система компании или специализированное B2B решение потому что Blazor долго скачивается в первый раз. Если ваши пользователи постоянно заходят в ваше приложение, то они не заметят никакой разницы с JS версией. Если пользователь заходит по рекламной ссылке просто глянуть что там за сайт какой-то скорее всего он не будет долго ждать пока сайт загрузиться и просто уйдет. В этом случае лучше использовать второй вариант размещения т.e. Server Side Blazor.
Создание проекта
Скачайте net core 3.0 dotnet.microsoft.com/download/dotnet-core/3.0
Выполните в терминале команду которая загрузить вам необходимые шаблоны.
dotnet new -i Microsoft.AspNetCore.Blazor.Templates
Для создания Server Side
dotnet new blazorserverside -o MyWebApp
Для Client Side файлики которого будет раздавать сервер asp.net core
dotnet new blazorhosted -o MyWebApp
Если вам захотелось экзотики и вдруг решили не использовать в качестве сервера asp.net core а что-то другое (А оно вам надо вообще?) можете создать только клиент без сервера вот такой командой.
dotnet new blazor -o MyWebApp
Биндинги
Поддерживается односторонняя привязка и двусторонняя. Таки да, не надо никаких OnPropertichanged как в WPF. При изменении Вью Модели разметка меняется автоматически.
<label>One way binding:</label>
<br />
<input type="text" value=@Text />
<br />
<label>Two way binding:</label>
<br />
<input type="text" @bind=@Text />
<br />
<label>Two way binding и смена события при которов будет меняться поле Text на событие oninput:</label>
<br />
<input type="text" @bind=@Text @bind:event="oninput" />
//ViewModel
@code{
string Text;
async Task InpuValueChanged()
{
Console.WriteLine("Input value changed");
}
}
И так, тут у нас есть ViewModel (анонимная) у которой есть поле Text.
В первом инпуте через «value=@Text» мы сделали одностороннюю привязку. Теперь когда мы изменим Text в коде тут же изменится текст внутри input. Только вот чтобы мы не печатали в нашем инпуте это никак не повлияет на нашу VM. Во втором input через "@bind=@Text" мы сделали двухстороннюю привязку. Теперь если мы напишем что-то новое в нашем input тут же поменяется наша VM, и обратное тоже верно т.е. если мы поменяем поле Text в коде то наш input тут же отобразит новое значение. Тут есть одно НО — по дефолту изменения привязаны к событию onchange нашего input поэтому VM поменяться только тогда когда мы завершим ввод. В третьем input "@bind:event=«oninput»" мы изменили событие для передачи данных VM на oninput теперь каждый раз когда мы печатаем какой-нибудь символ новое значение тут же передается нашей VM. Так же для DateTime можно указать формат например так.
<input @bind=@Today @bind:format="yyyy-MM-dd" />
View Model
Можно ее делать анонимкой тогда ее нужно помешать внутри блока "@code {}"
@page "/todo"
<p> Привет @UserName </p>
@code{
public string UserName{get; set;}
}
или можно вынести ее в отдельный файл. Тогда ее надо наследовать от ComponentBase и в начале страницы указать ссылку на нашу VM c помошью "@inherits"
Например
TodoViewModel.cs:
public class TodoViewModel: ComponentBase{
public string UserName{get; set;}
}
Todo.razor:
@page "/todo"
@inherits MyWebApp.ViewModels.TodoViewModel
<p> Привет @UserName </p>
Маршрутизация
Маршруты на которые будет реагировать страница указываются в ее начале с помощью "@page". Причем их может быть несколько. Будет выбран первый точно соответствующий в порядке сверху вниз. Например
@page "/todo"
@page "/todo/delete"
<h1> Hello!</h1>
Эта страница будет открываться по адресу "/todo" или «todo/delete»
Лайауты
В общем-то сюда обычно помещают одинаковые для нескольких страниц вещи. Вроде сайдбара и прочего.
Для того чтобы использовать лайаут во первых, нужно его создать. Он должен наследоваться от LayotComponentBase с помощью "@inherits". Например
@inherits LayoutComponentBase
<div class="sidebar">
<NavMenu />
</div>
<div class="main">
<div class="top-row px-4">
<a href="http://blazor.net" target="_blank" class="ml-md-auto">About</a>
</div>
<div class="content px-4">
@Body
</div>
</div>
Во вторых его нужно импортировать. Для этого в директории со страницами которые его будут использовать нужно создать файл _imports.razor потом добавить в этот файл строчку "@layout"
@layout MainLayout
@using System
В третьих можно у страницы указать какой именно лайаут она использует напрямую
@layout MainLayout
@page "/todo"
@inherits BlazorApp.Client.Presentation.TodoViewModel
<h3>Todo</h3>
Вообще _imports.razor и using в нем действуют на все страницы которые находятся с ним в одной папке.
Параметры маршрутов
Во первых — указать параметр и его тип в фигурных скобках в нашем маршруте (регистра независим). Поддерживаются стандартные типы дотнет. Таки да, опциональных параметров нет т.е. значение нужно передавать всегда.
Само значение можно получить создав у нашей ViewModel свойство с именем таким же как у параметра и с атрибутом [Parameter] БТВ — забегая в перед — данные и события в дочерние компоненты из родительских передаются тоже с помощью атрибута [Parameter] так же есть каскадные параметры. Они передаются от родительского компонента всем его дочерним компонентам и их дочерним компонентам. Они используются в основном для стилей а стили лучше все же просто делать в CSS поэтому ну его нафиг.
@page "/todo/delete/{id:guid}"
<h1> Hello!</h1>
@code{
[Parameter]
public Guid Id { get; set; }
}
DI
Все регистрируется в Startap.cs как в обычном asp.net core приложении. Тут ничего нового. А вот внедрение зависимостей для нашей VM таки происходит через публичные свойства а не через кноструктор. Свойство просто нужно декорировать отрибутом [Inject]
public class DeleteTodoViewModel : ComponentBase
{
[Parameter]
private Guid Id { get; set; }
[Inject]
public ICommandDispatcher CommandDispatcher { get; set; }
По умолчанию есть уже подключенных 3 сервиса. HttpClient — ну вы знаете зачем он. IJSRuntime — вызов JS кода из C#. IUriHelper — с помощью не его можно делать переадресацию на другие страницы.
Пример приложения
Компонет таблицы Todo
TodoTableComponent.razor:
//1)
<table class="table table-hover">
<thead>
<th>Задача выполнена</th>
<th>Название</th>
<th>Дата создания</th>
<th>Действия</th>
</thead>
<tbody>
//2)
@foreach (var item in Items)
{
//3)
<tr @onclick=@(()=>ClickRow(item.Id)) class="@(item.Id == Current?"table-primary":null)">
<td><input type="checkbox" checked="@item.IsComplite" disabled="disabled" /></td>
<td>@item.Name</td>
<td>@item.Created.ToString("dd.MM.yyyy HH:mm:ss")</td>
<td><a href="/todo/delete/@item.Id" class="btn btn-danger">Удалить</a></td>
</tr>
}
</tbody>
</table>
@code {
//4)
[Parameter]
private List<BlazorApp.Client.Presentation.TodoDto> Items { get; set; }
[Parameter]
private EventCallback<UIMouseEventArgs> OnClick { get; set; }
[Parameter]
private Guid Current { get; set; }
private async Task ClickRow(Guid id)
{
//5
await OnClick.InvokeAsync(CreateArgs(id));
}
private ClickTodoEventArgs CreateArgs(Guid id)
{
return new ClickTodoEventArgs { Id = id };
}
//6)
public class ClickTodoEventArgs : UIMouseEventArgs
{
public Guid Id { get; set; }
}
}
- Так как это компонент нам тут не нужны "@page" и "@layout" потому что он не будет участвовать в маршрутизации а лайаут он будет использовать от родительского компонента
- С символа @ начинается C# код. Собственно так же как и в Razor
-
@onclick=@(()=>ClickRow(item.Id))
Привязывает событие нажатия на строку к методу ClickRow нашей ViewModel
- Указываем какие параметры будут передаваться из родительского компонента или страницы в наш с помощью атрибута [Parameter]
- Вызываем функцию обратного вызова которую получили из родительского компонента. Так родительский компонент узнает что в дочернем произошло какое-то событие. Функции можно передавать только завернутыми в EventCallback<> параметризованный EventArgs. Возможный список EventArgs можно посмотреть тут — docs.microsoft.com/ru-ru/aspnet/core/blazor/components?view=aspnetcore-3.0#event-handling
- Так как список возможных типов EventArgs ограничен а нам нужно передать дополнительное свойство Id в обработчик события на стороне родительского компонента то мы создаем свой собственный класс параметра унаследованный от базового и передаем уже его в событие. Таки да — в родительский компонент, в функцию обработчик события прилетит обычный UIMouseEventArgs и его нужно будет привести к нашему типу например с помощью оператора as
Пример использования:
<TodoTableComponent Items=@Items OnClick=@Select Current=@(Selected?.Id??Guid.Empty)></TodoTableComponent>
Страница для удаления Todo
Наша ViewModel aka VM — DeleteTodoViewModel.cs:
public class DeleteTodoViewModel : ComponentBase
{
//1)
[Parameter]
private Guid Id { get; set; }
//2)
[Inject]
public ICommandDispatcher CommandDispatcher { get; set; }
[Inject]
public IQueryDispatcher QueryDispatcher { get; set; }
[Inject]
public IUriHelper UriHelper { get; set; }
//3)
public TodoDto Todo { get; set; }
protected override async Task OnInitAsync()
{
var todo = await QueryDispatcher.Execute<GetById,TodoItem>(new GetById(Id));
if (todo != null)
Todo = new TodoDto { Id = todo.Id, IsComplite = todo.IsComplite, Name = todo.Name, Created = todo.Created };
await base.OnInitAsync();
}
//4)
public async Task Delete()
{
if (Todo != null)
await CommandDispatcher.Execute(new Remove(Todo.Id));
Todo = null;
//5)
UriHelper.NavigateTo("/todo");
}
}
- Параметр маршрута "/todo/delete/{id:guid}" сюда передаться Guid если мы перейдем например по адресу localhost/todo/delete/ae434aae44...
- Инжектим сервисы из DI контейнера в нашу VM.
- Просто свойство нашей VM. Ее значение мы устанавливаем сами, какое хотим.
- Это метод вызывается автоматически при инициализации страницы. Тут мы устанавливаем нужные значения для свойств нашей VM
- Метод нашей VM. Мы можем привязать его например к событию нажатия какой нибудь кнопки нашей View
- Переход на другую страницу которая находиться по адресу "/todo" т.е. у нее в начале есть строчка "@page "/todo""
Наша View — DeleteTodo.razor:
//1) @page "/todo/delete/{id:guid}" @using BlazorApp.Client.TodoModule.Presentation @using BlazorApp.Client.Shared; //2) @layout MainLayout //3) @inherits DeleteTodoViewModel <h3>Удалить Todo </h3> @if (Todo != null) { <div class="row"> <div class="col"> <input type="checkbox" checked=@Todo.IsComplite disabled="disabled" /> <br /> <label>@Todo.Name</label> <br /> //4) <button class="btn btn-danger" onclick=@Delete>Удалить</button> </div> </div> } else { <p><em>Такой Todo не найден</em></p> }
- Указываем что эта страна будет доступна по адресу {корневой адрес нашего сайта} +"/todo/delete/"+{какой то Guid}. Например localhost/todo/delete/ae434aae44...
- Указываем что наша страница будет рендериться внутри MainLayout.razor
- Указываем что наша страница будет использовать свойства и методы класса DeleteTodoViewModel
- Узказываем что при нажатии на эту кнопку будет вызываться метод Delete() нашей VM
Главная страница Todo
TodoViewModel.cs:
public class TodoViewModel : ComponentBase { [Inject] public ICommandDispatcher CommandDispatcher { get; set; } [Inject] public IQueryDispatcher QueryDispatcher { get; set; } //1) [Required(ErrorMessage = "Введите название Todo")] public string NewTodo { get; set; } public List<TodoDto> Items { get; set; } public TodoDto Selected { get; set; } protected override async Task OnInitAsync() { await LoadTodos(); await base.OnInitAsync(); } public async Task Create() { await CommandDispatcher.Execute(new Add(NewTodo)); await LoadTodos(); NewTodo = string.Empty; } //2) public async Task Select(UIMouseEventArgs args) { //3) var e = args as TodoTableComponent.ClickTodoEventArgs; if (e == null) return; var todo = await QueryDispatcher.Execute<GetById, TodoItem>(new GetById(e.Id)); if (todo == null) { Selected = null; return; } Selected = new TodoDto { Id = todo.Id, IsComplite = todo.IsComplite, Name = todo.Name, Created = todo.Created }; } public void CanselEdit() { Selected = null; } public async Task Update() { await CommandDispatcher.Execute(new Update(Selected.Id, Selected.Name, Selected.IsComplite)); Selected = null; await LoadTodos(); } private async Task LoadTodos() { var todos = await QueryDispatcher.Execute<GetAll, List<TodoItem>>(new GetAll()); Items = todos.Select(t => new TodoDto { Id = t.Id, IsComplite = t.IsComplite, Name = t.Name, Created = t.Created }) .ToList(); } }
- Поддерживаются стандартные атрибуты валидации из System.ComponentModel.DataAnnotations. Конкретно тут мы указываем что это поле обязательное и тот текст который будет отображаться если пользователь не укажет значение в том input который будет связан с этим полем.
- Метод для обработки события с параметром. Этот метод будет обрабатывать событие из дочернего компонента
- Приводем аргумент к типу который мы создали в дочернем компоненте
Todo.razor:
@layout MainLayout @page "/todo" @inherits BlazorApp.Client.Presentation.TodoViewModel <h3>Todo</h3> <h4>Список</h4> <div class="row"> <div class="col"> @if (Items == null) { <p><em>Загрузка...</em></p> } else if (Items.Count == 0) { <p><em>Нет задач для отображения. Пожалуйсте добавте какую нибудь.</em></p> } else { //1) <TodoTableComponent Items=@Items OnClick=@Select Current=@(Selected?.Id??Guid.Empty)></TodoTableComponent> } </div> </div> <br /> <h4>Создать Todo</h4> <div class="row"> <div class="col"> @if (Items != null) { //2) <EditForm name="addForm" Model=@this OnValidSubmit=@Create> //3) <DataAnnotationsValidator /> //4) <ValidationSummary /> <div class="form-group"> //5) <InputText @bind-Value=@NewTodo /> //6) <ValidationMessage For="@(() => this. NewTodo)" /> //7) <button type="submit" class="btn btn-primary">Создать</button> </div> </EditForm> } </div> </div> <br /> <h4>Редактировать Todo</h4> <div class="row"> <div class="col"> @if (Items != null) { @if (Selected != null) { <EditForm name="editForm" Model=@Selected OnValidSubmit=@Update> <DataAnnotationsValidator /> <ValidationSummary /> <div class="form-group"> <InputCheckbox @bind-Value=@Selected.IsComplite /> <InputText @bind-Value=@Selected.Name /> <button type="submit" class="btn btn-primary">Сохранить</button> <button type="reset" class="btn btn-warning" @onclick=@CanselEdit>Отмена</button> </div> </EditForm> } else { <p><em>Кликните на задаче чтобы ее редактировать</em></p> } } </div> </div>
- Вызываем дочерний компонент и передаем ему в качестве параметров свойства и методы нашей VM.
- Встроенный компонент формы с валидацией данных. Указываем в нем что в качеств е модели он будет использовать нашу VM и при отправке валидных данных он будет вызывать ее метод Create()
- Валидация будет выполняться с помощью атрибутов модели вроде [Requared] и т.п.
- Здесь буду отображаться общие ошибки валицадии
- Создаст input с валидацией. Список возможных тегов — InputText, InputTextArea, InputSelect, InputNumber, InputCheckbox, InputDate
- Здесь будут отображаться ошибки валидации для свойства public string NewTodo{get;set;}
- При нажатии на эту кнопку будет вызываться событие OnValidSubmit нашей формы
Файл Startup.cs
Тут мы регистрируем наши сервисы
public class Startup { public void ConfigureServices(IServiceCollection services) { //Добавляем LocalStorage и SessionStorage как синглтоны чтобы сохранять данные на //стороне клиента в браузере // Тут нужно подключить черед Nuget пакет Blazor.Extensions.Storage services.AddStorage(); services.AddSingleton<ITodoRepository, TodoRepository>(); services.AddSingleton<ICommandDispatcher, CommandDispatcher>(); services.AddSingleton<IQueryDispatcher, QueryDispatcher>(); services.AddSingleton<IQueryHandler<GetAll, List<TodoItem>>, GetAllHandler>(); services.AddSingleton<IQueryHandler<GetById, TodoItem>, GetByIdHandler>(); services.AddSingleton<ICommandHandler<Add>, AddHandler>(); services.AddSingleton<ICommandHandler<Remove>, RemoveHandler>(); services.AddSingleton<ICommandHandler<Update>, UpdateHandler>(); } public void Configure(IComponentsApplicationBuilder app) { //Указываем что корневым компонентом нашего приложения будет App.razor // и его содержимое будет помещаться внутри тега <app></app> app.AddComponent<App>("app"); } }
Эпилог
Эта статься была написана чтобы разыграть аппетит и подтолкнуть к дальнейшему изучению Blazor. Надеюсь поставленной цели я достиг. Ну а чтобы изучить его получше рекомендую почитать официальное руководство от Майкрософт docs.microsoft.com/ru-ru/aspnet/core/blazor/?view=aspnetcore-3.0
Автор: Тимур Давлатов