Blazor + MVVM = Silverlight наносит ответный удар, потому что древнее зло непобедимо

в 22:36, , рубрики: .net, ASP, ASP.NET, asp.net core, Blazor, C#, css, html, Программирование, Разработка веб-сайтов

Привет! Таки да, скоро выходит 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

Модели размещения

  1. На стороне клиента. Стандартное SPA которое можно раздавать различными способами. В моем примере я использовал шаблон в котором файлы приложения отдает браузеру сервер на asp.net core. Минус этого подхода в тех самых 21 мегабайтах которые нужно скачать браузеру.
  2. На стороне сервера. Все происходит на сервере, а клиенту через сокеты передается готовый 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; }
    }
}

  1. Так как это компонент нам тут не нужны "@page" и "@layout" потому что он не будет участвовать в маршрутизации а лайаут он будет использовать от родительского компонента
  2. С символа @ начинается C# код. Собственно так же как и в Razor
  3. @onclick=@(()=>ClickRow(item.Id))

    Привязывает событие нажатия на строку к методу ClickRow нашей ViewModel

  4. Указываем какие параметры будут передаваться из родительского компонента или страницы в наш с помощью атрибута [Parameter]
  5. Вызываем функцию обратного вызова которую получили из родительского компонента. Так родительский компонент узнает что в дочернем произошло какое-то событие. Функции можно передавать только завернутыми в EventCallback<> параметризованный EventArgs. Возможный список EventArgs можно посмотреть тут — docs.microsoft.com/ru-ru/aspnet/core/blazor/components?view=aspnetcore-3.0#event-handling
  6. Так как список возможных типов 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");
        }
    }

  1. Параметр маршрута "/todo/delete/{id:guid}" сюда передаться Guid если мы перейдем например по адресу localhost/todo/delete/ae434aae44...
  2. Инжектим сервисы из DI контейнера в нашу VM.
  3. Просто свойство нашей VM. Ее значение мы устанавливаем сами, какое хотим.
  4. Это метод вызывается автоматически при инициализации страницы. Тут мы устанавливаем нужные значения для свойств нашей VM
  5. Метод нашей VM. Мы можем привязать его например к событию нажатия какой нибудь кнопки нашей View
  6. Переход на другую страницу которая находиться по адресу "/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>
    }
    

    1. Указываем что эта страна будет доступна по адресу {корневой адрес нашего сайта} +"/todo/delete/"+{какой то Guid}. Например localhost/todo/delete/ae434aae44...
    2. Указываем что наша страница будет рендериться внутри MainLayout.razor
    3. Указываем что наша страница будет использовать свойства и методы класса DeleteTodoViewModel
    4. Узказываем что при нажатии на эту кнопку будет вызываться метод 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();
            }
        }
    

    1. Поддерживаются стандартные атрибуты валидации из System.ComponentModel.DataAnnotations. Конкретно тут мы указываем что это поле обязательное и тот текст который будет отображаться если пользователь не укажет значение в том input который будет связан с этим полем.
    2. Метод для обработки события с параметром. Этот метод будет обрабатывать событие из дочернего компонента
    3. Приводем аргумент к типу который мы создали в дочернем компоненте

    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>
    

    1. Вызываем дочерний компонент и передаем ему в качестве параметров свойства и методы нашей VM.
    2. Встроенный компонент формы с валидацией данных. Указываем в нем что в качеств е модели он будет использовать нашу VM и при отправке валидных данных он будет вызывать ее метод Create()
    3. Валидация будет выполняться с помощью атрибутов модели вроде [Requared] и т.п.
    4. Здесь буду отображаться общие ошибки валицадии
    5. Создаст input с валидацией. Список возможных тегов — InputText, InputTextArea, InputSelect, InputNumber, InputCheckbox, InputDate
    6. Здесь будут отображаться ошибки валидации для свойства public string NewTodo{get;set;}
    7. При нажатии на эту кнопку будет вызываться событие 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

Автор: Тимур Давлатов

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js