😸

ASP.NET Core Blazor のイベント ハンドリングをカスタマイズする

2022/09/20に公開

あまり需要がないかもしれませんが、Blazor でボタンクリックなどのイベントが発生した際の処理をカスタマイズする方法についてメモっておこうと思います。

イベントが呼ばれたときの処理をかすめとる

Blazor でボタンクリックなどのイベントが発生したときに、一応ユーザーが割り込むことが出来るポイントが提供されています。
Blazor でイベントが発生した場合イベントハンドラのターゲット(イベントハンドラーのデリゲートの Target プロパティ)かレシーバー(大体のケースでは Blazor のコンポーネントになるはず)の IHandleEvent#HandleEventAsync メソッドで実際の処理が行われています。

https://learn.microsoft.com/ja-jp/dotnet/api/microsoft.aspnetcore.components.ihandleevent?view=aspnetcore-6.0

この IHandleEvent インターフェースは Blazor のコンポーネントの基本クラスの ComponentBase クラスで以下のように実装されています。

private async Task CallStateHasChangedOnAsyncCompletion(Task task)
{
    try
    {
        await task;
    }
    catch // avoiding exception filters for AOT runtime support
    {
        // Ignore exceptions from task cancellations, but don't bother issuing a state change.
        if (task.IsCanceled)
        {
            return;
        }

        throw;
    }

    StateHasChanged();
}

Task IHandleEvent.HandleEventAsync(EventCallbackWorkItem callback, object? arg)
{
    var task = callback.InvokeAsync(arg);
    var shouldAwaitTask = task.Status != TaskStatus.RanToCompletion &&
        task.Status != TaskStatus.Canceled;

    // After each event, we synchronously re-render (unless !ShouldRender())
    // This just saves the developer the trouble of putting "StateHasChanged();"
    // at the end of every event callback.
    StateHasChanged();

    return shouldAwaitTask ?
        CallStateHasChangedOnAsyncCompletion(task) :
        Task.CompletedTask;
}

簡単に説明すると、イベントの処理を呼び出して Taskawait せずに StateHasChanged を呼び出して画面の再描画を行うようになっています。そして Task が完了していない場合は CallStateHasChangedOnAsyncCompletion を呼び出して await を行った後に再度 StateHasChanged を呼び出しています。
このような実装になっているため Web API を呼び出すような非同期処理をイベントハンドラーで行っている場合に、イベントハンドラーで await をする直前までの状態で一度コンポーネントが再描画されて、await が終わってイベントハンドラーの中身が完了したタイミングで再度コンポーネントが再描画されるといった動きをしています。

つまり、コンポーネントで IHandleEvent#HandleEventAsync を自分で実装することでこの動作を変えることが出来ます。やってみましょう。Blazor Server (WASM でも可) のプロジェクトを作って Counter コンポーネントを以下のように変更して HandleEventAsyncStateHasChanged を呼び出さない実装にします。

@page "/counter"
@using System.Diagnostics
@implements IHandleEvent

<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++;
        Debug.WriteLine($"IncrementCount が呼び出されました: {currentCount}");
    }

    // 単純にイベントハンドラを呼び出すだけで StateHasChanged を呼ばない!
    Task IHandleEvent.HandleEventAsync(Microsoft.AspNetCore.Components.EventCallbackWorkItem item, object? arg)
    {
        return item.InvokeAsync(arg);
    }
}

こうすると IncrementCount は呼び出されているにも関わらず画面上は一切再描画されないという動きが実現できます! (嬉しくない)

実行してボタンを何回かクリックしてみると以下のようにイベントが実行されたというログは出ますが画面上は初期値の 0 のまま更新されていないことが確認できます。

HandleEvetnAsync を以下のように何もしないようにすると、ボタンをいくらクリックしてもイベントハンドラーが呼び出されないという動きも実装できます。 (嬉しくない)

Task IHandleEvent.HandleEventAsync(Microsoft.AspNetCore.Components.EventCallbackWorkItem item, object? arg)
{
    return Task.CompletedTask;
}

次にイベントハンドラー渡すデリゲートをコンポーネント以外にするケースについて考えてみたいと思います。
以下のようなケースになります。

@page "/counter"

<PageTitle>Counter</PageTitle>

<h1>Counter</h1>

<p role="status">Current count: @counterState.CurrentCount</p>

@* onclick に counterState.IncrementCount のように CounterState クラスのメソッドを渡す *@
<button class="btn btn-primary" @onclick="counterState.IncrementCount">Click me</button>

@code {
    private readonly CounterState counterState = new();

    class CounterState
    {
        public int CurrentCount { get; set; }
        public void IncrementCount() => CurrentCount++;
    }
}

ここで最初の方に書いた「Blazor でイベントが発生した場合イベントハンドラのターゲット(イベントハンドラーのデリゲートの Target プロパティ)かレシーバー(大体のケースでは Blazor のコンポーネントになるはず)の IHandleEvent#HandleEventAsync メソッドで実際の処理が行われています。」を思い出してほしいのですが、このケースでは Target は CounterState になってレシーバーが Counter コンポーネントになります。なので CounterState クラスで IHandleEvent インターフェースを実装することでイベントが実行されたときの処理をかすめ取ることが出来ます。以下のようにすると、1ミリも嬉しくないのですがボタンを押しても何も起こらない動きを実現できます。

@page "/counter"

<PageTitle>Counter</PageTitle>

<h1>Counter</h1>

<p role="status">Current count: @counterState.CurrentCount</p>

@* onclick に counterState.IncrementCount のように CounterState クラスのメソッドを渡す *@
<button class="btn btn-primary" @onclick="counterState.IncrementCount">Click me</button>

@code {
    private readonly CounterState counterState = new();

    class CounterState : IHandleEvent
    {
        public int CurrentCount { get; set; }
        public void IncrementCount() => CurrentCount++;

        Task IHandleEvent.HandleEventAsync(Microsoft.AspNetCore.Components.EventCallbackWorkItem item, object? arg)
        {
            // ここでイベントがトリガーされたときの処理をかすめ取れる
            // 何もしていないのでボタンをクリックしても何も起きない
            return Task.CompletedTask;
        }
    }
}

実用的な使い方

ここまで正直 1 ミリも嬉しくない例を示してきましたがちゃんと実用的な利用法があります。
デフォルトの IHandleEvent の実装だと必ず StateHasChanged が呼ばれてコンポーネントの再描画のロジックがトリガーされます。そのため再描画が不要な場合に IHandleEvent を実装して StateHasChanged を呼ばないようにすることで再描画を抑制してパフォーマンスを良くするといった事が可能です。

以下のドキュメントの「イベントの処理後に状態を変更しないで再レンダリングを回避する」のセクションにその方法について書いてあります。

https://learn.microsoft.com/ja-jp/aspnet/core/blazor/performance?view=aspnetcore-6.0

まとめ

Blazor でイベントが発火されたときに、デフォルトでどのような処理が行われているのかということを確認して、その処理をカスタマイズする方法について説明しました。
この記事内の例は実用性がほぼゼロですが、実際のユースケースとしてパフォーマンスチューニングの例を紹介しました。この記事に書いてある内容を理解していれば、すんなりと理解できると思います。というか、ドキュメント読んで、これは何でこう動くの?という疑問が出てきたので調べたのがこの記事のきっかけだったりします…。

パフォーマンスチューニングとしての利用方法以外に、割と時間のかかる処理で定期的に画面をリフレッシュしたいといったケースももしかしたら汎用的に実装できるかもしれません。(未検証)
こういった割り込みポイントがあるのは、アプリケーションの全体の方式を考える立場の人にとっては嬉しいことだと思います。改めて Blazor はよくできてるなと思いました。

Microsoft (有志)

Discussion