🦁

.NET 7 の ASP.NET Core Blazor の新機能試してみよう

2022/10/22に公開約11,200字

更新履歴

  • 2022/10/31
    • 10/29 に .NET 7 x FUN FAN F# | Hybrid で、この記事の内容で登壇してきたので、それについて記事の末尾に追記しました

本文

ついに .NET 7 RC2 まで出て、あと数週間で .NET 7 がリリースされる所まで来ました。
個人的に最近の .NET 系で好きなものは Blazor なのですが HTML/CSS と和解できていない悩みがあります…。

とりあえず、和解に向けて自分が歩み寄ろうと思うのですが、今回はそれはおいておいて .NET 7 で追加される Blazor 系の新機能をざっと試してみようと思います。

ネタ元はここらへんです。

https://devblogs.microsoft.com/dotnet/asp-net-core-updates-in-dotnet-7-preview-7/

https://github.com/dotnet/aspnetcore/issues/39504

WASM のデフォルトのローディングが新しくなった

これは、今までが味気なさ過ぎた感ありますよね…。

今までのローディング画面

新しいローディング画面

これは、あくまでデフォルトのプロジェクトテンプレートが出力するローディング画面がこうというだけなので、自分で差し替えてしまえばいいのですが新しいローディング画面のほうは CSS のカスタムプロパティで読み込みのパーセンテージなどが取得できるので、それを使って今までよりもいい感じのローディング画面を作れるようになっています。

以下の値が提供されているみたいです。

  • --blazor-load-percentage: アプリのファイルがロードされたパーセンテージ
  • --blazor-load-percentage-text: 小数点部分が四捨五入されたアプリのファイルがロードされたパーセンテージのテキスト

なので、例えば app.css に以下のような定義を追加して

wwwroot/app.css
.test1 {
    background-color: red;
    height: 10px;
    width: calc(var(--blazor-load-percentage, 0%));
}
.test2:after {
    content: var(--blazor-load-percentage-text, "Loading");
}

index.html の id が app の div タグを以下のように変更すると

wwwroot/index.html
<div id="app">
    <h3>Test1</h3>
    <div class="test1"></div>
    <h3>Test2</h3>
    <div class="test2"></div>
</div>

ロード画面を以下のように変更することが出来ます。

センスが無いので元より劣化していますが、ロード状況に応じた内容を表示することが簡単に出来るようになっていることが確認できると思います。

bind に get/set/after 修飾子が追加されました

Blazor のデータ バインディングが強化されて @bind:after, @bind:get, @bind:set が追加されました。

@bind:after は、かなりの人にとって使い勝手のいい機能です。データ バインディングで値が更新された後に呼び出されるメソッドを指定することが出来ます。例えば検索ボックスに検索条件が入力されたら自動的に検索を実行するといったようなことが出来ます。いいね。

実際に試してみましょう。
デフォルトのプロジェクトテンプレートの Index.razor を以下のようにして検索条件を入れた後にフォーカスを外すと検索結果が出るようなイメージの画面を作ってみました。Query プロパティを input タグにバインドしています。そして @bind:after="ExecuteAsync を設定して Query がデータバインディングによって更新されたタイミングで ExecuteAsync を呼び出すようにしています。

Index.razor
@page "/"

<PageTitle>Index</PageTitle>

<h1>Index</h1>

<input @bind="Query" @bind:after="ExecuteAsync" />

<div>@Message</div>

@if (SearchResults is not [])
{
    <ul>
        @foreach(var item in SearchResults)
        {
            <ul>@item</ul>
        }
    </ul>
}

@code {
    private string Query { get; set; } = "";

    private string[] SearchResults { get; set; } = Array.Empty<string>();

    private string Message { get; set; } = "";

    private async Task ExecuteAsync()
    {
        Message = "検索中...";
        SearchResults = Array.Empty<string>();
        await Task.Delay(1000);

        SearchResults = Enumerable.Range(0, Query.Length)
            .Select(x => $"Search result: {x}")
            .ToArray();
        Message = "";
    }
}

実行すると以下のように動きます。テキストボックスに適当にテキストを入力してフォーカスを外すと、自動的に検索が行われるようになっています。

個人的にはデフォルトのデータバインディングの挙動の onchange のタイミングで値の変更を反映するタイミングだといまいち感はぬぐえないですが oninput イベントにすると Blazor Server でガンガン通信が走っちゃうので悩ましい所…。どうするのがいいんでしょうね?JavaScript で oninput をハンドリングして1秒くらい値の更新が無かったらイベントをトリガーするみたいなものを入れるといった感じでしょうか…?めんどくさそう。

ちなみに、元ネタのブログが input タグのテキスト入力に応じてメソッドを呼び出すというタイプだったので、ここの例でも、それを使いましたが個人的には都道府県をドロップダウンで選ぶと、次のドロップダウンで町を選ぶといったような UI を作るときに、この機能が凄くいいと思いました。

こんな感じですね。

Index.razor
@page "/"

<PageTitle>Index</PageTitle>

<h1>Index</h1>

<div>
    <label>Top category</label>
    <select @bind="SelectedTopCategory" @bind:after="TopCategorySelected">
        <option value="">未選択</option>
        @foreach (var item in TopCategories)
        {
            <option value="@item">@item</option>
        }
    </select>
</div>

<div>
    <label>Sub category</label>
    <select @bind="SelectedSubCategory">
        <option value="">未選択</option>
        @foreach (var item in SubCategories)
        {
            <option value="@item">@item</option>
        }
    </select>
</div>

<h3>選択結果</h3>

@if (string.IsNullOrEmpty(SelectedTopCategory))
{
    <p>Top category と Sub category を選択してください。</p>
}
else
{
    <p>@SelectedTopCategory - @SelectedSubCategory</p>
}


@code {
    private string[] TopCategories { get; } = new[]
        {
            "Item1", "Item2", "Item3",
    };

    private Dictionary<string, string[]> SubCategoriesStore { get; } = new()
        {
            ["Item1"] = new[] { "Item1-1", "Item1-2", "Item1-3" },
            ["Item2"] = new[] { "Item2-1", "Item2-2", "Item2-3" },
            ["Item3"] = new[] { "Item3-1", "Item3-2", "Item3-3" },
        };

    private string[] SubCategories { get; set; } = Array.Empty<string>();

    private string SelectedTopCategory { get; set; } = "";
    private string SelectedSubCategory { get; set; } = "";

    private void TopCategorySelected()
    {
        SelectedSubCategory = "";
        SubCategoriesStore.TryGetValue(SelectedTopCategory, out var subCategories);
        SubCategories = subCategories ?? Array.Empty<string>();
    }
}

実行するとこんな感じになります。

こういう機能が作りやすくなったのはいいですね。

次の @bind:get@bind:set はペアで使います。これは親コンポーネントから値を受け取って双方向バインディングを行うようなコンポーネントを作るのを簡略化してくれます。双方向バインディングに対応したプロパティを持ったコンポーネントを作るには、そのプロパティと プロパティ名Changed という名前の値が変更されたときに呼び出される EventCallback<T> 型のプロパティを持っている必要があります。

実装自体はそこまでめんどくさいものではないのですが、@bind:get@bind:set を使うとこんな感じでサクッと作れます。

MyInput.razor
<div>
    <h3>MyInput</h3>
    <input @bind:get="Value" @bind:set="ValueChanged" />
</div>

@code {
    [Parameter]
    public string? Value { get; set; }
    [Parameter]
    public EventCallback<string?> ValueChanged { get; set; }
}

このコンポーネントの Value プロパティは双方向バインディングが可能です。今までは変更タイミングで自分で ValueChanged を呼ばないといけなかったので地味に楽になりました。

この MyInput コンポーネントはこんな感じで使えます。

Index.razor
@page "/"

<PageTitle>Index</PageTitle>

<h1>Index</h1>

<MyInput @bind-Value="IndexValue" />

<p>@IndexValue</p>

@code {
    private string IndexValue { get; set; } = "";
}

実行して MyInput コンポーネントにあるテキストボックスに適当に値を入れてフォーカスを外すと IndexValue の値も更新されていることが確認できます。

地味系便利機能ですね。

仮想化の改善

これは、個人的には @bind:get, @bind:set よりさらに地味度が高いです。仮想化をするには Virtualize コンポーネントを使うのですが、スクロールで見えない場所のスクロールを埋めるために div タグが使われていたのですが、このタグを div タグ以外にすることが出来るようになりました。

SpacerElement プロパティに tr などを設定すると div タグのかわりに tr タグが使われます。

HTML 的に div タグを許してくれない場所に Virtualize コンポーネントで仮想化をしようとする場合に便利です。ただ、多くの Web ブラウザーでは div タグでも動いてくれるので、この機能がないと凄く困る…!というわけではなさそう。でも、大事ですよね。

ナビゲーションの改善

画面遷移に割り込んでキャンセルすることが出来る機能

これは痒い所に手が届く系の改善だと思ってます。個人的にアツいのは画面遷移をしようとしているときに、ユーザーに確認ダイアログを出せるようになったところです。
この機能は NavigationLock コンポーネントを画面においていくつかのプロパティを設定して使います。この NavigationLock には 2 つのプロパティが定義されています。

  • OnBeforeInternalNavigation: アプリ内の画面遷移を行う前に呼ばれる処理。この引数にわたってくる LocationChangingContextPreventNavigation メソッドを呼ぶことでナビゲーションをキャンセルすることが出来ます。
  • ConfirmExternalNavigation: このプロパティを true にすると外部サイトへの移動前に確認ダイアログを出すことができます。デフォルト値は false になります。

簡単にですが使ってみましょう。画面遷移前に確認ダイアログを出すようにしてみます。

Index.razor
@page "/"
@inject IJSRuntime _jsRuntime

<PageTitle>Index</PageTitle>

<NavigationLock OnBeforeInternalNavigation="BeforeInternalNavigation" 
    ConfirmExternalNavigation=true />

<h1>Hello, world!</h1>

@code {
    private bool ConfirmExternalNavigation { get; set; }
    private bool ConfirmInternalNavigation { get; set; }

    private async Task BeforeInternalNavigation(LocationChangingContext context)
    {
        bool result = await _jsRuntime.InvokeAsync<bool>("confirm", "いいの?");
        if (!result)
        {
            context.PreventNavigation();
        }
    }
}

OnBeforeInternalNavigation で JavaScript の confirm で画面遷移をしてもいいかどうか確認するようにしています。ConfirmExternalNavigation を true に設定して外部サイトへの画面遷移時にも確認をするようにしました。

アプリを起動して、ブラウザーで何か操作をした状態でアドレスバーに google.com と打ち込むと以下のように確認ダイアログが出てきます。

よく見るやつですね。

OnBeforeInternalNavigation でアプリ内画面遷移でも確認ダイアログを出すようにしているので、例えば Counter ページなどに遷移しようとすると以下のような確認ダイアログが出てきます。

いいですね。これまでは、なかなか画面遷移に気軽に割り込むことは出来なかったと思うのでありがたいです。

もし、この画面遷移の確認ダイアログが不要な場合は NavigationLock コンポーネント自体を表示しないような if 分を定義すればよいです。こんな感じですね @if(xxx) { <NavigationLock .... /> }

画面遷移時にステータスを渡すことが出来る

これも痒い所に手が届く系です。今までは画面遷移時にパラメーターを渡そうと思ったらクエリパラメーターを指定するか、アプリ内で static な領域のどこかに値を保持するとか(あまりよくないけど)など、一工夫が必要でした。

.NET 7 からは NavigationManagerNavigateTo の第二引数で渡す NavigationOptionsHistoryEntryState というプロパティが追加されています。この string? 型のプロパティに設定した値は画面遷移先でも読み取ることが出来ます。画面遷移先では NavigationManagerHistoryEntryState プロパティから、この値を読み込むことが出来ます。

例えば Index.razor から Counter.razor に画面遷移するためのボタンを2つ用意して、片方は 10 始まり、もう一方は 100 始まりのカウンターページに飛ぶような処理は以下のような感じで実装できます。

Index.razor
@page "/"
@inject NavigationManager _navigationManager

<PageTitle>Index</PageTitle>

<button @onclick="NavigateTo10">カウンターページに遷移 (初期値10)</button>
<button @onclick="NavigateTo100">カウンターページに遷移 (初期値100)</button>

@code {
    private void NavigateTo10()
    {
        _navigationManager.NavigateTo("/counter", new NavigationOptions
        {
            // ここで状態を渡す
            HistoryEntryState = "10",
        });
    }

    private void NavigateTo100()
    {
        _navigationManager.NavigateTo("/counter", new NavigationOptions
        {
            // ここで状態を渡す
            HistoryEntryState = "100",
        });
    }
}
Counter.razor
@page "/counter"
@inject NavigationManager _navigationManager

<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;

    protected override void OnInitialized()
    {
        // ここで状態を読み取る
        var state = _navigationManager.HistoryEntryState;
        if (int.TryParse(state, out var count))
        {
            currentCount = count;
        }
    }

    private void IncrementCount()
    {
        currentCount++;
    }
}

デバッグ実行をすると以下のように 2 つのボタンを持った Index.razor が表示されるようになります。

左のボタンを押すと以下のように 10 始まりのカウンターページに移動します。

100 のほうのボタンをオスと 100 始まりのカウンターページに移動します。

ここでポイントとなるのは、ブラウザーの戻るボタンで画面を戻った場合でも HistoryEntryState の状態は保存されているため初期値 10 のカウンターページとして遷移したページに戻るときは、ちゃんと 10 始まりのカウンターページになります。
結構便利ですね。

URL をブックマークして、その URL に戻った時に状態を復元したいというケースではクエリパラメーターを使用する必要がありますが、そうじゃないケースでは、今回追加された HistoryEntryState を使用するのが簡単なので良さそうです。

認証の動作のカスタムが簡単になってる

認証は、それだけで大変なので別の機会に…

まとめ

ということで、ざっとですが新機能を眺めてみました。
個人的な感想は地味だな…というのが正直なところですが、地味なりにも細かい使い勝手を改善するというフェーズにきていて Blazor 自体がそこそこ成熟してきてるのではないかと勝手に信じてます。

ここでは触れていませんが実験的機能として WebAssembly でのマルチスレッドのサポートが入ってきているので、.NET 8 あたりで WASM でもマルチスレッドいけたりするようにならないか期待しています。Reactive Extensions を WASM で使うと結構簡単にマルチスレッドをサポートしていないことに起因するエラーが出たりするので…。

https://devblogs.microsoft.com/dotnet/asp-net-core-updates-in-dotnet-7-rc-2/

その他には Blazor とは独立して .NET を JavaScript から使えるようになる機能なども面白そうではあります。ただ、個人的に使いたいケースが思いつかない…。

https://devblogs.microsoft.com/dotnet/use-net-7-from-any-javascript-app-in-net-7/

登壇資料など

この記事の内容で登壇してきました。

発表資料

https://www.slideshare.net/okazuki0130/net-7-aspnet-core-blazor

セッション動画

序盤ちょっと配信トラブルがあったのですが自己紹介が終わったあたりからちゃんとなっています。m(_ _)m

https://youtu.be/bhXDUpG8xSg?t=10004

Discussion

ログインするとコメントできます