📌

.NET 8 の ASP.NET Core Blazor 新機能オーバービュー

2023/11/18に公開2

はじめに

.NET 8 が先日リリースされて .NET Conf 2023 のセッション動画も YouTube の dotnet チャンネルに投降されています。
.NET Conf 2023 のプレイリストで全て確認できるので興味があるやつだけでも見てみると楽しいのでお勧めです。

https://www.youtube.com/playlist?list=PLdo4fOcmZ0oULyHSPBx-tQzePOYlhvrAU

今回は、その動画の中の Full stack web UI with Blazor in .NET 8 | .NET Conf 2023 の内容を解説する形で記事を書いていこうと思います。後半の認証や CRUD のページを自動生成してくれる機能については別途余裕があれば書こうと思います。また、記事内の図の多くは、この動画内のものから引用しています。

細かい新機能は色々ありますが、大体の大枠はこのセッションに詰まっているのでとても勉強になります。

Blazor

Blazor ですが、ASP.NET Core に完全に統合された、とても強力な再利用可能なコンポーネント モデルで開発が可能なフレームワークです。
C# で開発が出来て、.NET 8 で様々な新機能が追加されて今までは別々のアプリケーションとして開発しなければいけなかった Blazor Server と Blazor WebAssembly を単一のアプリケーションとしてページ単位・コンポーネント単位で選択可能になりました。

.NET 8 で追加された代表的な Blazor の新機能は以下の通りです。

  • Static server rendering
  • Enhanced navigation & form handling
  • Streaming rendering
  • Enable Interactivity per component or page
  • Auto select the render mode at runtime

日本語にすると、こんな感じでしょうか。

  • 静的サーバー レンダリング
  • 拡張されたナビゲーションとフォーム処理
  • ストリーミング レンダリング
  • コンポーネントまたはページごとの対話機能の有効化
  • 実行時にレンダリング モードを自動選択

これらの新機能が追加された結果 Blazor は今までイロモノ アプリケーション (WebSocket の接続が必要な Blazor Server の形式や WebAssembly 上で .NET ランタイムを起動して、その上で動く Blazor WebAssembly の形式) しか作れなかったのですが一般的な Web アプリケーションを作りつつ、高度な対話機能が必要になった場合には Blazor Server や Blazor WebAssembly を使うという形で開発が可能になりました。

ということで、.NET 8 では以下のように Server、WebAssembly、Static Server-side Rendering(SSR) という 3 つのレンダリング モードを持つようになりました。

Static SSR

Static SSR は、端的に言うと SPA 等が出てくる前からあった古き良き普通の Web アプリケーションの動きと同じになります。サーバーサイドで HTML が生成されて、それをブラウザーが受信してレンダリングを行うという形になります。

Blazor で Static SSR を使うと以下のようなことが特徴と出来ることがあります。

  • スケールしやすい
  • SEO に強い
  • 画面遷移
  • フォーム処理

基本的な Web アプリケーションの動きが出来ます。しかし、以下のような要件には対応できません。

  • リッチなユーザーとの対話機能
  • リアルタイムのデータ更新

このような機能が必要な場合には Blazor Server のモードで動く InteractiveServer や Blazor WebAssembly のモードで動く InteractiveWebAssembly を使うことになります。

Blazor アプリの作成

Visual Studio 2022 17.8.0 では Blazor のプロジェクト テンプレートとして Blazor Web App というものが追加されています。

このプロジェクト テンプレートでほぼ全ての Blazor のアプリケーションを作ることが出来ます。唯一の例外は、静的なコンテンツを配信するようなサーバーで ASP.NET Core Blazor WebAssembly で作ったアプリケーションを配信するケースです。この場合は Blazor Web App だと ASP.NET Core がサーバーサイドで動く必要があるので使えません。その場合には Blazor WebAssembly アプリのプロジェクト テンプレートを使いますが、基本的にはレアケースだと思います。そのため、基本的には Blazor Web App プロジェクトテンプレートを使うということだけ覚えていれば大丈夫です。

この Blazor Web App プロジェクト テンプレートを選択してプロジェクト名などを入力すると、以下のような選択肢が表示されます。

ここの選択肢で Interactive render mode と Interactive location がポイントになります。それぞれ以下のような選択肢があります。

  • Interactive render mode
    • None と Server と WebAssembly と Auto (Server and WebAssembly) が選択可能です。これはユーザーとの対話機能が必要な場合に、Blazor Server と Blazor WebAssembly のどちらを使うかを選択します。Auto を選択すると、どちらも選択することが出来るようになるのと、後述する自動で Blazor Server と Blazor WebAssembly を切り替える機能を使うことが出来るようになります。None を選択すると、ユーザーとの高度な対話機能は使用せずに Static SSR だけで開発を行うことになります。
  • Interactive location
    • Per page/component と Global が選択可能です。これは、Interactive render mode をどの単位で設定するかということを設定します。Global を設定するとページ全体で単一のレンダリング モードが設定されます。レンダリング モードは、親の要素で設定されている場合には子要素で別のものを設定できないという制約があるので、Global で Server を指定すると、アプリ内では WebAssembly のコンポーネントを部分的に出すといったようなことは出来ません。Per page/component はページ単位やコンポーネント単位で個別に設定が可能になるモードです。Global を設定すると、.NET 7 まであった従来の Blazor Server や ASP.NET Core Hosted の Blazor WebAssembly のような動きになります。

Global と Per page/component を選択したときの違いを図にしたものが以下の図になります。

特に拘りがなければ Interactive render mode は Auto か Server あたりを選んでおいて、Interactive location は Per page/component にしておくと良いと思います。

Static SSR の動き

Blazor Web App で Include sample pages にチェックを入れると、いくつかのサンプルページが作成されるので、それを見てみましょう。プロジェクト名/Components/Pages/Home.razor が最初に表示されるページになります。
このページは Interactivity は設定されていないので Static SSR で動作しているページになります。このページを読み込む際にブラウザーの開発者ツールのネットワークを見てみると以下のように完全に HTML/CSS/JS などの静的なコンテンツが返されていることが確認できます。

Counter ページに移動すると以下のように WebSocket の接続が作成されます。一番したの行の _blazor?id=xxxx.... で始まる行が WebSocket の接続になります。時間の列が保留中になっているのでアクティブな接続であるということがわかります。

このプロジェクトは Interactivity mode を Auto にして作成しました。その場合には、Counter ページは Auto モードでレンダリングが行われます。詳細は後述しますが WebSocket の接続の上に System.wasm や System.Xml.wasm などの大量のファイルがダウンロードされていることがわかります。これは、裏で Blazor WebAssembly のためのファイルがダウンロードされていて、次回以降の Counter ページのアクセス時には Blazor Server ではなく Blazor WebAssembly で動作するようになります。これが Auto モードの動きになります。

では、この状態でもう一度 Home のページに戻りましょう。そうすると以下のように WebSocket の接続の時間の列が保留中ではなくなって切断されたことが確認できます。Home のページは Static SSR で動作するので WebSocket の接続は必要ないので自動的に切断されます。

ここでポイントなのは、画面遷移時に普通の Static SSR の Web アプリケーションでは起きるはずのページの全体のリフレッシュが行われていないということです。これは、app.css などのページを表示するために必要なコンテンツを取得するためのリクエストが行われていないことから確認できます。Blazor はリンクがクリックされた際に、それをインターセプトして fetch でリクエストを行いページの HTML を取得して差分更新を行います。こうすることで、ページ全体のリフレッシュを防いでユーザーにとって快適な動きになるようにしています。これが Enhanced navigation と呼ばれる機能になります。

Streaming SSR

次は、Streaming SSR について説明します。SSR のページでは一般的にページを表示するために必要なデータを取得して、そのデータを元にレンダリングを行います。そのためデータ取得に 3 秒かかると、ユーザーにページが表示されるまで最低 3 秒かかるということになります。Streaming SSR は、このようなケースでもデータ取得前の段階で一度ページの表示を行い、ページを表示するのに必要なデータが揃ったら差分更新を行うという動きになります。これにより、ユーザーにとっては初回のページの表示が早くなります。

この動きは Blazor Web App のプロジェクトテンプレートの Weather ページで確認できます。このページは、メモリ上でデータを組み立てているので実際に表示するまでには時間がかからないのですが意図的に await Task.Delay(500); というコードを入れて 500 ミリ秒の遅延を入れています。これにより、データ取得に 500 ミリ秒かかるという状況を作り出しています。

Weather.razor
@page "/weather"
@attribute [StreamRendering]

<PageTitle>Weather</PageTitle>

<h1>Weather</h1>

<p>This component demonstrates showing data.</p>

@if (forecasts == null)
{
    <p><em>Loading...</em></p>
}
else
{
    <table class="table">
        <thead>
            <tr>
                <th>Date</th>
                <th>Temp. (C)</th>
                <th>Temp. (F)</th>
                <th>Summary</th>
            </tr>
        </thead>
        <tbody>
            @foreach (var forecast in forecasts)
            {
                <tr>
                    <td>@forecast.Date.ToShortDateString()</td>
                    <td>@forecast.TemperatureC</td>
                    <td>@forecast.TemperatureF</td>
                    <td>@forecast.Summary</td>
                </tr>
            }
        </tbody>
    </table>
}

@code {
    private WeatherForecast[]? forecasts;

    protected override async Task OnInitializedAsync()
    {
        // Simulate asynchronous loading to demonstrate streaming rendering
        await Task.Delay(500);

        var startDate = DateOnly.FromDateTime(DateTime.Now);
        var summaries = new[] { "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" };
        forecasts = Enumerable.Range(1, 5).Select(index => new WeatherForecast
        {
            Date = startDate.AddDays(index),
            TemperatureC = Random.Shared.Next(-20, 55),
            Summary = summaries[Random.Shared.Next(summaries.Length)]
        }).ToArray();
    }

    private class WeatherForecast
    {
        public DateOnly Date { get; set; }
        public int TemperatureC { get; set; }
        public string? Summary { get; set; }
        public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
    }
}

このページを表示すると以下のように最初に Loading... と表示されます。これは、データ取得前の段階で表示されるものです。

そして、500 ミリ秒後にデータ取得が完了すると以下のようにデータが表示されます。

データ取得前のページとデータ取得後のページの両方で崩れないようにしないといけないという手間はかかりますがユーザーにとっては初回のページの表示が早くなるので良いと思います。

Streaming SSR の内部

この Streaming SSR の動きをくわしく見るために以下のようなシンプルなページを作成してみます。

Streaming.razor
@page "/streaming"

<h3>Streaming</h3>

<p>Count: @_count</p>

@code {
    private int _count = 0;

    protected override async Task OnInitializedAsync()
    {
        for (int i = 0; i < 3; i++)
        {
            await Task.Delay(1000);
            _count++;
            StateHasChanged();
        }
    }
}

このページは、1 秒ごとにカウントアップしていくというものです。現時点では、まだ Streaming SSR を有効化していないのでアクセスすると 3 秒後に Count: 3 と表示されます。curl コマンドでこのページの URL を叩いてみるとわかりやすいです。(見やすいように整形および省略をしています)

> curl https://localhost:7232/streaming
<!DOCTYPE html>
<html lang="en"><head><meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <base href="/">
    <link rel="stylesheet" href="bootstrap/bootstrap.min.css">
    <link rel="stylesheet" href="app.css">
    <link rel="stylesheet" href="BlazorApp34.styles.css">
    <link rel="icon" type="image/png" href="favicon.png">
    </head>

<body>

...中略... 

<p>Count: 3</p></article></main></div>

...中略... 

<script src="_framework/blazor.web.js"></script>
</body>
</html>

このように、Count: 3 というループが完全に終わった後の HTML が素直に返されていることがわかります。実際に curl コマンドを叩いてから 3 秒程待たないと結果が返ってきません。

次に Streaming SSR を有効にして動きを見てみます。以下のように @attribute [StreamRendering] 属性をつけて Streaming SSR を有効化します。

Streaming.razor
@page "/streaming"
@attribute [StreamRendering]
<h3>Streaming</h3>

<p>Count: @_count</p>

@code {
    private int _count = 0;

    protected override async Task OnInitializedAsync()
    {
        for (int i = 0; i < 3; i++)
        {
            await Task.Delay(1000);
            _count++;
            StateHasChanged();
        }
    }
}

この状態で curl コマンドを叩くと以下のようになります。(見やすいように整形および省略をしています)

> curl https://localhost:7232/streaming
<!DOCTYPE html>
<html lang="en"><head><meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <base href="/">
    <link rel="stylesheet" href="bootstrap/bootstrap.min.css">
    <link rel="stylesheet" href="app.css">
    <link rel="stylesheet" href="BlazorApp34.styles.css">
    <link rel="icon" type="image/png" href="favicon.png">
    </head>

<body>

...中略...

<p>Count: 0</p><!--/bl:13--></article></main></div>

...中略...

    <script src="_framework/blazor.web.js"></script>
</body></html>

<blazor-ssr><template blazor-component-id="13"><h3>Streaming</h3>

<p>Count: 1</p></template><blazor-ssr-end></blazor-ssr-end></blazor-ssr>
<blazor-ssr><template blazor-component-id="13"><h3>Streaming</h3>

<p>Count: 2</p></template><blazor-ssr-end></blazor-ssr-end></blazor-ssr>

<blazor-ssr><template blazor-component-id="13"><h3>Streaming</h3>

<p>Count: 3</p></template><blazor-ssr-end></blazor-ssr-end></blazor-ssr><blazor-ssr><template blazor-component-id="13"><h3>Streaming</h3>

<p>Count: 3</p></template><blazor-ssr-end></blazor-ssr-end></blazor-ssr>
...中略...

このように、リクエストの最初の方では Count: 0 という HTML が返されて、後ろの方で <blazor-ssr> タグでページの HTML が返されています。Streaming SSR では OnInitializedAsync メソッドで StateHasChanged() を呼び出したタイミングや OnInitializedAsync メソッドが終わったタイミングでページが更新されます。更新もページ全体の書き換えではなく差分のレンダリングになります。賢いですね。

ということで Streaming SSR についてまとめると以下のようになります。

  • 初期表示が早く、必要に応じて差分更新が行われる
  • 静的コンテンツは通常の Web アプリと同じように並列で取得できる

デメリットというか注意点としては以下の点があります。

  • コンテンツが無い状態とコンテンツがある状態の両方のデザインを準備する必要がある

データを取得するページでは基本的には有効にしていい機能だと思います。

Enhanced navigation & form handling

次は Enhanced navigation と Enhanced form handling についてです。Enhanced navigation は先ほど解説した通りリンクのクリックなどを Blazor がインターセプトして fetch リクエストでデータを取得してページを差分更新してくれるものです。

Enhanced navigation をすると、ページが書き換えられるのですが、その際に更新してほしくない要素などもあると思います。例えば以下のようにメニューエリアに検索ボックスのような入力フォームがあるとします。

このフォームに以下のようにテキストを入れます。

その状態で別のページに移動すると、Enhanced navigation によるページの書き換えで中身が消えてしまいます。

このように Enhanced navigation で書き換えて欲しくない部分には data-permanent 属性を指定します。今回の場合は form タグの中身を書き換えて欲しくないので以下のように指定します。

<div class="nav-item px-3">
    <form data-permanent>
        <input type="text" />
        <input type="submit" />
    </form>
</div>

このようにすると Enhanced navigation で画面遷移をしてもフォームの中身が消えることはなくなります。

Enhanced navaigation はデフォルトで有効ですが、Enhanced form handling は、デフォルトでは有効になっていません。明示的なオプトインが必要です。まずはオプトインしていない状態での動きを見てみます。以下のように Home.razor に単純なフォームを 1 つ起きます。

Home.razor
@page "/"

<PageTitle>Home</PageTitle>

<h1>Hello, world!</h1>

<EditForm Model="FormDataModel" FormName="Form1">
    <InputText @bind-Value="FormDataModel.Input" />
    <input type="submit" />
</EditForm>

@if (!string.IsNullOrEmpty(FormDataModel.Input))
{
    <p>入力されたデータ: @FormDataModel.Input</p>
}

@code {
    // FormData クラスは string 型の Input プロパティを持つだけのただのクラス
    [SupplyParameterFromForm]
    private FormData FormDataModel { get; set; } = new();
}

この状態でフォームとナビゲーション領域のフォームにも適当な文字を入れます。

この状態でページのフォームのボタンを押すと以下のようになります。

Enhanced navigation で消えないようにした左側のナビゲーションエリアのフォームのデータが消えてしまいました。
これはデフォルトではフォームが Enhanced form handling ではないからです。Enhanced form handling を有効にする場合は EditFormEnhanced 属性を追加します。追加したコードを以下に示します。

Home.razor
@page "/"
@attribute [StreamRendering]

<PageTitle>Home</PageTitle>

<h1>Hello, world!</h1>

<EditForm Model="FormDataModel" FormName="Form1" OnSubmit="OnSubmit" Enhance>
    <InputText @bind-Value="FormDataModel.Input" />
    <input type="submit" disabled="@_isProcessing" />
</EditForm>

@if (!string.IsNullOrEmpty(FormDataModel.Input))
{
    <p>入力されたデータ: @FormDataModel.Input</p>
}

@code {
    // FormData クラスは string 型の Input プロパティを持つだけのただのクラス
    [SupplyParameterFromForm]
    private FormData FormDataModel { get; set; } = new();

    private bool _isProcessing = false;
    private async Task OnSubmit(EditContext context)
    {
        _isProcessing = true;
        await Task.Delay(3000);
        _isProcessing = false;
    }
}

Enhanced の追加以外にも OnSubmit イベントで 3 秒ほどスリープをしています。その際に _isProcessing フィールドの値を true にして処理が終わったら false にしています。この _isProcessing フィールドはフォームのサブミットボタンの disabled 属性にバインドしていて、処理中にはフォームのボタンが押されない様にしています。そして、リクエストの処理中でも速やかにレンダリング出来るように @attribute [StreamRendering] 属性をつけてページが Streaming SSR になるようにしています。

この状態で動かすと、以下のように左側のフォームのデータが消えなくなり、なおかつフォームのボタンを押して 3 秒間はボタンが押せなくなります。

データを入力します。

ボタンを押します。即座にボタンが押せなくなります。左側のフォームのデータも消えていません。

3 秒たつと以下のようにボタンが再度押せるようになります。

普通の form タグを使う場合は以下のように書くことで同じことが出来ます。data-enhance 属性で Enhanced form handling を有効にしています。フォームの名前は @formname 属性で指定しています。

Home.razor
@page "/"
@attribute [StreamRendering]

<PageTitle>Home</PageTitle>

<h1>Hello, world!</h1>

<form method="post" @formname="Form1" @onsubmit="OnSubmit" data-enhance>
    <AntiforgeryToken />
    <InputText @bind-Value="FormDataModel.Input" />
    <input type="submit" disabled="@_isProcessing" />
</form>

@if (!string.IsNullOrEmpty(FormDataModel.Input))
{
    <p>入力されたデータ: @FormDataModel.Input</p>
}

@code {
    // FormData クラスは string 型の Input プロパティを持つだけのただのクラス
    [SupplyParameterFromForm]
    private FormData FormDataModel { get; set; } = new();

    private bool _isProcessing = false;
    private async Task OnSubmit()
    {
        _isProcessing = true;
        await Task.Delay(3000);
        _isProcessing = false;
    }
}

Interactive Server/WASM/Auto

最後に Interactivity について説明します。
Blazor Web App に含まれる Counter のページのようにユーザーの操作に対してインタラクティブに対応する必要がある場合には Static SSR や Streaming SSR では対応できないので Blazor Server か Blazor WebAssembly を使う必要があります。Blazor Web App では、これらのどちらを使うかを Interactive render mode で選択することが出来ます。

この指定方法は簡単で、ページに @rendermode ディレクティブで InteractiveServerInteractiveWebAssemblyInteractiveAuto を指定するだけです。それぞれ以下のような動きになります。

  • InteractiveServer
    • Blazor Server で動きます。
  • InteractiveWebAssembly
    • Blazor WebAssembly で動きます。
  • InteractiveAuto
    • 初回は Blazor Server で動き、裏で WebAssembly が動くのに必要なアセットのダウンロードを行います。2回目以降のアクセスでは Blazor WebAssembly で動きます。

@rendermode ディレクティブでページ単位で指定することも出来ますがコンポーネントに <Counter @rendermode="InteractiveServer" /> のように属性として指定することで部分的に Interactive render mode を変えることが出来ます。

Blazor Web App の Counter コンポーネントは、Interactive render mode を Auto にしてプロジェクトを作成すると @rendermode ディレクティブで InteractiveAuto が指定されています。そのため、初回は Blazor Server で動き、2 回目以降は Blazor WebAssembly で動くようになります。
そのため Counter コンポーネントは以下のように末尾に .Client がつくプロジェクトで作られています。このプロジェクトは Blazor WebAssembly のプロジェクトです。InteractiveAutoInteractiveWebAssembly を指定する場合には Blazor WebAssembly で動くように作らないといけないので、ここでコンポーネントを作る必要があります。

また、注意点としては、ページの表示には基本的には Server Side Pre-rendering が行われるというところです。このため WebAssembly でも初回表示が早くなるというメリットがありますが、初回はサーバーサイドでレンダリングされるためサーバーで動いても、ちゃんと動くようにしておく必要がある点に注意してください。

また InteractiveServer が設定されているコンポーネントの子要素では InteractiveWebAssembly などのように別の Interactive render mode を指定することは出来ません。逆もまたしかりです。

まとめ

最後のほうは少し力尽きてしまいましたが、書きたかったことはかけたと思います。
まだまだ、細かいところでは色々な機能が追加されています。Blazor は .NET 8 で大きく生まれ変わったフレームワークなので、これからも目が離せないと個人的に思っています。

まさか、こんな形に進化していくとは…。

Microsoft (有志)

Discussion

なす38円なす38円

こうゆう解説が読めるところがどこにもなくなったので本当に助かります。UIはもう全部.razorに統一して書けるようになったということでしょうか??あと、このアプリで、クライアント側コードが使用するAPIのコードもあると嬉しいです。お願いします!

Kazuki OtaKazuki Ota

@なす38円 さん
一応 Web アプリの UI は .razor で全部書けるようにはなっていると思います。
とはいえ出たばかりなので、使い込んでいく中でノウハウ不足なところや、もしかしたら機能不足の所が出てくることはあると思います。