この記事はN・S高等学校 Advent Calendar 2021の9日目の記事です。
こんにちは!
学校法人角川ドワンゴ学園N Code Labo 秋葉原教室所属講師の土川です。
N Code Laboでは小中高校生向けにUnityやPythonで機械学習やWebバックエンド、SwiftでのiOSアプリ開発など実践的なプログラミングコンテンツがあり、2,3人の生徒に対して1人の講師と1人1人の生徒の学びたいことができる環境を整えています。
今回は、Unityでも使われるC#というプログラミング言語でWebフロントエンド開発ができるフレームワークBlazor WebAssenblyとWindows98の見た目を再現できるCSSライブラリであるwin98.cssを用いてダサいアプリを作っていきます。
Blazor WebAssemblyとは?
まず、BlazorというのはC#とHTMLでWebフロントエンドが開発出来るフレームワークで、Blazor WebAssemblyとBlazor Serverの2種類があります。両者ともUIをHTMLで書けロジックをC#で書けるとまさにC#版React.js,Vue.jsな感じなのですが、動作原理は全く違います。
Blazor WebAssemblyは最近流行のWebAssemblyにてC#のランタイムを動かし、コンパイルされたILコードをユーザーのブラウザ上にて実行します。
Blazor ServerはフロントはHTMLにて動き、ロジックは全てSignalRにてサーバに送られ処理され実行されます。そのためPWAなどには対応していません。
上記特徴を踏まえて自分はBlazor WebAssembly(以降BlazorWASM)の方をよく利用しています。
ここまででなぜBlazor WebAssemblyを使うのか不思議に思っている人もいると思います。React.jsの方がよく使うしライブラリも多い。C#と同様、型が使えるtypescriptも利用出来る。Blazor WASMはReact.jsなどを置き換える物というよりは、特定の条件で使うと有用であるものです。
例えば以下の状況
- サーバー側をASP.NET(C#)で実装している
- クライアント側として既にC#を利用しているものがあるとき(特にUnityやWinアプリなど)
- 
C#をこよなく愛しているとき
特に最後が重要ですねw
Blazor WebAssemblyで開発をするには
.NET SDKをインストールする必要があります。
導入ですが、こちらの記事を参考にしてください(自分がサークルのアドベントカレンダーで書いた記事です)
プロジェクトを作成する
N高のアドベントカレンダーなのでWindows版のVisualStudioというよりはdotnetコマンドを使った方法で紹介していきます。
dotnet new blazorwasm -o Win98Dasai
出来たWin98DasaiフォルダをVSCodeなどで開きます。
開いた際に案内がでますが、拡張機能Microsoft.AspNetCore.Razor.VSCode.BlazorWasmDebuggingExtensionをインストールすることをおすすめします。
とりあえず実行してみましょう
dotnet run
info: Microsoft.Hosting.Lifetime[14]
      Now listening on: https://localhost:7037
info: Microsoft.Hosting.Lifetime[14]
      Now listening on: http://localhost:5079
以上のように出たので
localhost:5079をブラウザで開いてみましょう。

このように起動出来れば成功です。
では早速挙動を見ていきます。
/counterを見てみましょう。

Click meを押すことでカウンタを1ずつ増やせます。
ここのページのコードはPages/Counter.razorで以下のようになっています。
@page "/counter"
<PageTitle>Counter</PageTitle>
<h1>Counter</h1>
<p role="status">Current count: @currentCount</p>
<button class="btn btn-primary" @onclick="IncrementCount">Click me</button>
@code {
    private int currentCount = 0;
    private void IncrementCount()
    {
        currentCount++;
    }
}
@codeブロックがC#でロジックを書ける部分です。
IncrementCount()メソッドをbuttonの@onclick="IncrementCount"でクリックした際に動かすメソッドとして登録しています。
基本的にはこんな感じです。
Win98.cssの導入
GitHubのwin98.cssのページからstyle.cssの内容を全てコピーしてプロジェクトのwwwroot/css/app.cssに追記します。
本当はrazorファイルごとにスタイルを指定することができ便利なのですが、今回は全体に適用したいのでapp.cssに追記しています。app.cssのimportはwwwrootフォルダ内のindex.htmlに記述されています。
導入完了後、再度dotnet runで再実行すると適用されて既にダサさが見え始めていると思います。

さらにダサくするためにbuttonからclassbtn btn-primary削除します。

はい。良い感じになってきました。
このままの勢いでこのwin98.cssを用いてToDoリストアプリを作成します。
Pages/Index.razorを編集してアプリを作成していきます。
@page "/"
<PageTitle>ToDoアプリ</PageTitle>
<div class="window">
  <div class="title-bar">
    <div class="title-bar-text">ToDoリストアプリ</div>
    <div class="title-bar-controls">
      <button aria-label="Minimize">_</button>
      <button aria-label="Close">×</button>
    </div>
  </div>
  <div class="window-body container">
    <div class="row">
      <p>新規タスク登録</p>
    </div>
    <div class="row">
      <input class="col-11" type="text" @bind-value="newTaskTextInput" @bind-value:event="oninput" style="width: 490px;"/>
      <button class="col-1" @onclick="AddNewTask">登録</button>
    </div>
    <div class="row">
      <ul>
        @foreach (var todo in toDos)
      {
        <li>@todo.content</li>
      }
      </ul>
    </div>
  </div>
</div>
@code{
  private string newTaskTextInput;
  private List<ToDo> toDos = new();
  class ToDo
  {
    public string uuid{get;set;}
    public string content{get;set;}
  }
  private void AddNewTask()
  {
    toDos.Add(new(){
      uuid=Guid.NewGuid().ToString(),
      content=newTaskTextInput});
  }
}
こんな感じで一応ToDoアプリは作れますね。ただ、これではブラウザをリロードした際に情報が消えてしまいます。そこで今回はあまり良い方法ではありませんがlocalStrageを使います。
以下のコマンドでLocalStorageを扱うためのライブラリを取得します。
dotnet add package Blazored.LocalStorage
出来たら、以下をIndex.razor最初に追記します。
@inject Blazored.LocalStorage.ILocalStorageService localStorage
次にAddNewTaskを次のように書き換えます。
  private async void AddNewTask()
  {
    toDos.Add(new(){
      uuid=Guid.NewGuid().ToString(),
      content=newTaskTextInput});
    string jsonText = System.Text.Json.JsonSerializer.Serialize(toDos);
    await localStorage.SetItemAsync("todos",jsonText);
  }
これで保存がされるようになりました。
次にサイト読み込み時にLocalStorageからjsonを読み出してListに変換する処理を入れます。
以下の2つのメソッドを@codeブロック内に追記します。
  protected override void OnInitialized()
  {
    LoadData();
  }
  private async void LoadData()
  {
    string jsonText = await localStorage.GetItemAsync<string>("todos");
    toDos = System.Text.Json.JsonSerializer.Deserialize<List<ToDo>>(jsonText)!;
  }
更に、Program.cs内にusing Blazored.LocalStorage;とbuilder.Services.AddBlazoredLocalStorage();を追記します。
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using Win98Dasai;
using Blazored.LocalStorage;
var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.RootComponents.Add<App>("#app");
builder.RootComponents.Add<HeadOutlet>("head::after");
builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
builder.Services.AddBlazoredLocalStorage();
await builder.Build().RunAsync();
よし!これで動作するはずですね!ビルドして確認してみます。
おかしい...上手く動きませんね。localStorageには確実に含まれているのに。
こういうときはBlazor側が見た目にも影響する変数が変更されたことを検知していないのが原因なので
StateHasChanged();をLoadData()の最後にいれ、更新されるようにします。
はい。完成です。
おわり
今回は既存のデスクトップアプリやサーバーサイドのコードを流用しなかったので特にうま味ZEROでしたが、これが複数プラットフォームへ展開する際ならうま味が出てくるはずです。
おまけ
最後に今回書いたコードを記載しておきます。
@page "/"
@inject Blazored.LocalStorage.ILocalStorageService localStorage
<PageTitle>ToDoアプリ</PageTitle>
<div class="window">
  <div class="title-bar">
    <div class="title-bar-text">ToDoリストアプリ</div>
    <div class="title-bar-controls">
      <button aria-label="Minimize">_</button>
      <button aria-label="Close">×</button>
    </div>
  </div>
  <div class="window-body container">
    <div class="row">
      <p>新規タスク登録</p>
    </div>
    <div class="row">
      <input class="col-11" type="text" @bind-value="newTaskTextInput" @bind-value:event="oninput" style="width: 490px;"/>
      <button class="col-1" @onclick="AddNewTask">登録</button>
    </div>
    <div class="row">
      <ul>
        @foreach (var todo in toDos)
      {
        <li>@todo.content</li>
      }
      </ul>
    </div>
  </div>
</div>
@code{
  private string newTaskTextInput;
  private List<ToDo> toDos = new();
  class ToDo
  {
    public string uuid{get;set;}
    public string content{get;set;}
  }
  private async void AddNewTask()
  {
    toDos.Add(new(){
      uuid=Guid.NewGuid().ToString(),
      content=newTaskTextInput});
    string jsonText = System.Text.Json.JsonSerializer.Serialize(toDos);
    await localStorage.SetItemAsync("todos",jsonText);
  }
  protected override void OnInitialized()
  {
    LoadData();
  }
  private async void LoadData()
  {
    if(!await localStorage.ContainKeyAsync("todos"))return;
    string jsonText = await localStorage.GetItemAsync<string>("todos");
    toDos = System.Text.Json.JsonSerializer.Deserialize<List<ToDo>>(jsonText)!;
    StateHasChanged();
  }
}
