Идея о том, что можно писать веб приложение без JavaScript интересна многим, а особенно тем кто начинал свой путь в программирование с серверного языка. Внедрение Web Assembly может (наконец-то) позволить это полноценно реализовать. Писать всю логику приложения на одном языке — звучит довольно заманчиво. Тем более, если этот код компилируется в бинарник, а не в промежуточный язык.

Эту технологию хотелось бы увидеть в не типичных веб-приложениях. Например, как это — писать что-то более интерактивное на Web Assembly (Blazor). Предлагаю попробовать создать простую текстовую интерактивную игру. В игре есть возможность создать свой вопрос и отвечать на вопросы других игроков, набирая очки за правильные ответы. Всё на одной странице и с помощью Blazor.



Результат:

Github
Demo

Разговаривать о Blazor начали более двух лет назад и за это время был пройден путь от раннего прототипа до продукта, который можно полноценно использовать. Если вы совсем не в теме — можно просмотреть обзорную статью.

Приятно видеть, что технология уже рабочая и можно даже попробовать поиграть (или посмотреть код) в что-то конкретное, например — шахматы, астероиды, и даже Diablo.
Но пока не попробуешь сам — не поймешь. Для того чтобы создать новый проект на Blazor, воспользуемся IDE или же напишем в консоле:

dotnet new blazorserver -o BlazorGame

При использовании серверного хостинга, чтобы обновить страницу, надо сбегать на сервер по веб сокету и спросить какую часть страницы обновить на конкретное действие. Работает это довольно шустро, как ни странно.

Изначальная структура проекта проста, а именно 3 папки: Data, Pages, Shared. Для моделей, компонент и общих элементов соответственно.

Создание модели и сервиса


Удалим шаблонные классы из папки Data. И добавим туда новую модель для задания игры:

public class QuizItem
    {
        public Guid Id { get; set; }
        public string Question { get; set; }
        public string Answer { get; set; }
        public int Score { get; set; }
    }

А также примитивный сервис для заданий:

public class QuizService
    {
        private static readonly List<QuizItem> QuizItems;

        static QuizService()
        {
            QuizItems = new List<QuizItem> {
                new QuizItem
                {
                    Question = "4 + 7 = ?",
                    Answer = "11",
                    Score = 1
                },
                new QuizItem
                {
                    Question = "Where is the code of this application hosted?",
                    Answer = "Github",
                    Score = 5
                }
            };
        }

        public Task<List<QuizItem>> GetQuizesAsync()
        {
            return Task.FromResult(QuizItems);
        }
    }

Сервис зарегистрируем в ConfigureServices для того, чтобы достать его при необходимости, где потребуется.

Github commit #1

Создание интерфейса пользователя


Так как у нас есть базовая логика, можем приступить к UI. Как и в большинстве других UI фреймворков — Blazor использует компоненты в виде XML, из которого автоматические создается HTML для браузера. Переместимся в папку Pages, в которой поменяем страницу с примером (Index) на страницу для просмотра задания:

@page "/"

@using BlazorGame.Data
@inject QuizService QuizService

<p>Your current score is @currentScore</p>

@if (quiz == null)
{
    <p><em>Loading...</em></p>
}
else
{
    @foreach (var quizItem in quiz)
    {
        <section>
            <h3>@quizItem.Question</h3>
            <div>
                <input type="text" @oninput="@((eventArgs) => CheckAnswer(eventArgs.Value.ToString(), quizItem.Id))" />
            </div>
        </section>
    }
}

@code {
    List<QuizItem> quiz;
    int currentScore = 0;

    protected override async Task OnInitializedAsync()
    {
        quiz = await QuizService.GetQuizesAsync();
    }

    void CheckAnswer(string answer, Guid id)
    {
        var quizItem = quiz.SingleOrDefault(q => q.Id == id);
        var dbAnswer = quizItem?.Answer.ToLower();
        if (!string.IsNullOrEmpty(dbAnswer) && answer.ToLower().Contains(dbAnswer))
        {
            currentScore++;
            quiz.Remove(quizItem);
        }
    }
}

Как видим — у нас есть полноценный синтаксис серверного языка, который мы используем вместо JavaScript. Есть нюанс, что если писать небрежно — получился спагетти-код, но это решается разбиением на компоненты и извлечением кода из UI страницы в отдельный класс.

Github commit #2

Передача данных между компонентами


Сделаем базовый рефакторинг и разделим наш код на 2 компоненты:

  • родительская, в которой оставим логику доставания заданий и очки,
  • дочерняя, в которую перенесем одно конкретное задание.

Поскольку родительские компоненты рендерятся раньше чем компоненты-потомки, то изменить данные из дочернего компонента становится немного сложней. Один из подходов — это callback функция, как параметр дочернего компонента. Это дает возможность вызвать код родителя при необходимости. Воспользуемся этим подходом для того, чтобы поменять набранные пользователем очки(внешняя компонента) при правильном ответе на вопрос(внутренняя компонента). В результате получаются следующие файлы:

@page "/"

@using BlazorGame.Data
@inject QuizService QuizService

<p>Your current score is <b>@state.CurrentScore</b></p>

@if (quiz == null)
{
    <p><em>Loading...</em></p>
}
else
{
    @foreach (var quizItem in quiz)
    {
        <QuizViewer Item="@quizItem" State="@state" OnScoreChanged="ScoreChanged" />
    }
}

@code {
    List<QuizItem> quiz;
    UserState state = new UserState();

    protected override async Task OnInitializedAsync()
    {
        quiz = await QuizService.GetQuizesAsync(state.UserId);
    }

    public async void ScoreChanged(int score)
    {
        state.CurrentScore = score;
    }
}


@using BlazorGame.Data
@inject QuizService QuizService

<section style="@style" >
    <h3>@Item.Question</h3>
    <div>
        <input type="text" @oninput="@((eventArgs) => CheckAnswer(eventArgs.Value.ToString(), Item.Id))" />
    </div>
</section>

@code{
    [Parameter]
    public QuizItem Item { get; set; }

    [Parameter]
    public UserState State { get; set; }

    [Parameter]
    public EventCallback<int> OnScoreChanged { get; set; }

    string style = "";

    async void CheckAnswer(string answer, Guid quizItemId)
    {
        var dbAnswer = Item?.Answer.ToLower();
        if (!string.IsNullOrEmpty(dbAnswer) && answer.ToLower().Contains(dbAnswer))
        {
            await OnScoreChanged.InvokeAsync(++State.CurrentScore);

            await QuizService.MarkAsDoneAsync(State.UserId, quizItemId);
            style = "display:none";
        }
    }
}

Github commit #3

Управление состоянием


Для того, чтобы результат нашей игры не пропал после того, как страница была перезагружена требуется сохранить состояние. По скольку в браузере уже есть встроенная база данных, то проще всего будет использовать именно её. Для работы с LocalStorage воспользуемся альфа-версией библиотеки ProtectedBrowserStorage.

Тут есть несколько нюансов. Во-первых, придется выключить серверный пре-рендеринг, потому как Local Storage не доступен на этом этапе. А во-вторых, причина первого пункта в том, что под капотом эта библиотека использует вызовы JavaScript. На сколько я понимаю это связано с тем, что на данный момент не готово решение как из Blazor достучаться до Local Storage через Web Assembly.

Обойдемся пока простым вариантом и будем хранить и читать идентификатор пользователя примерно следующим образом:

state.UserId = await ProtectedLocalStorage.GetAsync<Guid>("userId");

Github commit #4

Добавим социализации


Чтобы выглядело повеселей, добавим возможность пользователю самому создавать задание вопрос-ответ. Для этого воспользуемся компонентой фреймворка EditForm.

<EditForm Model="@quizItem" OnValidSubmit="CreateNewItem">
        <DataAnnotationsValidator />
        <ValidationSummary />

        <div class="form-group">
            <label>You can create your own question here.</label>
            <InputText class="form-control" id="question" @bind-Value="quizItem.Question" placeholder="Question" />
        </div>
        <div class="form-group">
            <InputText class="form-control" id="answer" @bind-Value="quizItem.Answer" placeholder="Answer" />
        </div>

        <button type="submit" class="btn btn-primary">Create</button>
    </EditForm>

Github commit #5

Для того, чтобы все это работало — добавим базу данных и логику для её использования. Здесь ничего нового. Код такой же как и для любого .NET Core приложения.

Github commit #6

Следующий шаг — несколько изменений в пользовательском интерфейсе.

Github commit #7

Ну и для полноты проверки фреймворка — добавим простой чат.

Выводы


Все это выглядит довольно интересно. С одной стороны не привычно. С другой же, похоже, что работает все довольно слажено. Кроме того, я ожидал увидеть банальные возможности писать простые скрипты на C# вместо JavaScript, а оказалось что функциональности очень много и разной (серверный и клиентский хостинг, взаимодействие с JS interop, возможность часть приложения писать с помощью Blazor, а часть как-то по-старому, использование .NET framework внутри браузера). В результате получилось, что Blazor позволяет создавать приложения где много инструментов для интерактива. Возможно из всего этого когда-то получится Веб 3.0.

Результат:

Github
Demo