ASP.NET Core Razor コンポーネントのレンダリング

この記事では、レンダーするコンポーネントを手動でトリガーするために StateHasChanged を呼び出すタイミングなど、ASP.NET Core Blazor アプリの Razor コンポーネント レンダリングについて説明します。

コンポーネントは、親コンポーネントによってコンポーネント階層に最初に追加されるときにレンダリングされる "必要" があります。 コンポーネントのレンダリングが必要なのは、このときだけです。 コンポーネントでは、その独自のロジックと規則に従って、他のタイミングでレンダリングすることが "できます"。

ComponentBase のレンダリング規則

既定では、Razor コンポーネントは ComponentBase 基底クラスから継承されます。これには次のタイミングで再レンダリングをトリガーするロジックが含まれています。

次のいずれかに該当する場合、ComponentBase から継承されるコンポーネントの再レンダリングは、パラメーターの更新が理由でスキップされます。

  • すべてのパラメーターは、既知の型のセット† からのものか、前のパラメーター セットが設定されてから変更されていない任意のプリミティブ型です。

    †Blazor フレームワークでは、変更検出のために、一連の組み込み規則と明示的なパラメーター型のチェックが使われます。 これらの規則と型はいつでも変更される可能性があります。 詳細については、ASP.NET Core リファレンス ソースの ChangeDetection API を参照してください。

    注意

    通常、.NET 参照ソースへのドキュメント リンクを使用すると、リポジトリの既定のブランチが読み込まれます。このブランチは、.NET の次回リリースに向けて行われている現在の開発を表します。 特定のリリースのタグを選択するには、[Switch branches or tags](ブランチまたはタグの切り替え) ドロップダウン リストを使います。 詳細については、「ASP.NET Core ソース コードのバージョン タグを選択する方法」 (dotnet/AspNetCore.Docs #26205) を参照してください。

  • コンポーネントの ShouldRender メソッドから false が返される。

レンダリング フローを制御する

ほとんどの場合、イベントの発生後は ComponentBase 規則に従って適切な一部のコンポーネント再レンダリングが行われます。 通常、開発者は、再レンダリングするコンポーネントと、それらを再レンダリングするタイミングをフレームワークに指示する手動のロジックを用意する必要はありません。 フレームワークの規則の全体的な効果は、イベントを受信したコンポーネントが自動的に再レンダリングされることにあります。これにより、パラメーター値が変更された可能性のある子孫コンポーネントの再レンダリングが再帰的にトリガーされます。

フレームワークの規則がパフォーマンスに及ぼす影響と、レンダリングのためにアプリのコンポーネント階層を最適化する方法について詳しくは、「ASP.NET Core Blazor パフォーマンスに関するベスト プラクティス」をご覧ください。

UI 更新の抑制 (ShouldRender)

ShouldRender は、コンポーネントがレンダリングされるたびに呼び出されます。 ShouldRender をオーバーライドして、UI の更新を管理します。 実装によって true が返された場合は、UI が更新されます。

ShouldRender がオーバーライドされる場合でも、コンポーネントは常に最初にレンダリングされます。

Pages/ControlRender.razor:

@page "/control-render"

<label>
    <input type="checkbox" @bind="shouldRender" />
    Should Render?
</label>

<p>Current count: @currentCount</p>

<p>
    <button @onclick="IncrementCount">Click me</button>
</p>

@code {
    private int currentCount = 0;
    private bool shouldRender = true;

    protected override bool ShouldRender()
    {
        return shouldRender;
    }

    private void IncrementCount()
    {
        currentCount++;
    }
}
@page "/control-render"

<label>
    <input type="checkbox" @bind="shouldRender" />
    Should Render?
</label>

<p>Current count: @currentCount</p>

<p>
    <button @onclick="IncrementCount">Click me</button>
</p>

@code {
    private int currentCount = 0;
    private bool shouldRender = true;

    protected override bool ShouldRender()
    {
        return shouldRender;
    }

    private void IncrementCount()
    {
        currentCount++;
    }
}
@page "/control-render"

<label>
    <input type="checkbox" @bind="shouldRender" />
    Should Render?
</label>

<p>Current count: @currentCount</p>

<p>
    <button @onclick="IncrementCount">Click me</button>
</p>

@code {
    private int currentCount = 0;
    private bool shouldRender = true;

    protected override bool ShouldRender()
    {
        return shouldRender;
    }

    private void IncrementCount()
    {
        currentCount++;
    }
}
@page "/control-render"

<label>
    <input type="checkbox" @bind="shouldRender" />
    Should Render?
</label>

<p>Current count: @currentCount</p>

<p>
    <button @onclick="IncrementCount">Click me</button>
</p>

@code {
    private int currentCount = 0;
    private bool shouldRender = true;

    protected override bool ShouldRender()
    {
        return shouldRender;
    }

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

ShouldRender に関連するパフォーマンスのベスト プラクティスについて詳しくは、「ASP.NET Core Blazor パフォーマンスに関するベスト プラクティス」をご覧ください。

StateHasChanged を呼び出すタイミング

StateHasChanged を呼び出すと、いつでもレンダリングをトリガーできます。 ただし、StateHasChanged を不必要に呼び出さないように注意してください。これはよくある間違いで、不必要なレンダリング コストがかかる原因になります。

次の場合は、コードで StateHasChanged を呼び出す必要はありません。

  • ComponentBase によってほとんどのルーチン イベント ハンドラーに対するレンダリングがトリガーされるため、同期的または非同期的にかかわらず、イベントを定期的に処理する。
  • によって典型的なライフサイクル イベントに対するレンダリングがトリガーされるため、同期的または非同期的にかかわらず、OnInitializedComponentBaseOnParametersSetAsync などの典型的なライフサイクル ロジックを実装する。

ただし、この記事の次のセクションで説明するケースでは、StateHasChanged を呼び出すことが理にかなっている場合があります。

非同期ハンドラーに複数の非同期フェーズが含まれる

.NET でのタスクの定義方法が理由で、Task の受信側で観察できるのは、中間の非同期状態ではなく、最終的な完了だけとなっています。 したがって、ComponentBase で再レンダリングをトリガーできるのは、Task が最初に返されたときと、Task が最終的に完了したときに限られます。 フレームワークは、IAsyncEnumerable<T> が一連の中間Taskでデータを返す場合など、他の中間ポイントでコンポーネントを再レンダリングすることを認識することはできません。 中間ポイントで再レンダリングを行いたい場合は、それらのポイントで StateHasChanged を呼び出します。

クリックのたびにカウントを 4 回更新する以下の CounterState1 コンポーネントについて考えてみましょう。

  • 自動レンダリングは、currentCount の最初と最後のインクリメントの後に行われます。
  • 手動レンダリングは、currentCount がインクリメントされる中間処理ポイントでフレームワークによって再レンダリングが自動的にトリガーされない場合に、StateHasChanged の呼び出しによってトリガーされます。

Pages/CounterState1.razor:

@page "/counter-state-1"

<p>
    Current count: @currentCount
</p>

<p>
    <button class="btn btn-primary" @onclick="IncrementCount">Click me</button>
</p>

@code {
    private int currentCount = 0;

    private async Task IncrementCount()
    {
        currentCount++;
        // Renders here automatically

        await Task.Delay(1000);
        currentCount++;
        StateHasChanged();

        await Task.Delay(1000);
        currentCount++;
        StateHasChanged();

        await Task.Delay(1000);
        currentCount++;
        // Renders here automatically
    }
}
@page "/counter-state-1"

<p>
    Current count: @currentCount
</p>

<p>
    <button class="btn btn-primary" @onclick="IncrementCount">Click me</button>
</p>

@code {
    private int currentCount = 0;

    private async Task IncrementCount()
    {
        currentCount++;
        // Renders here automatically

        await Task.Delay(1000);
        currentCount++;
        StateHasChanged();

        await Task.Delay(1000);
        currentCount++;
        StateHasChanged();

        await Task.Delay(1000);
        currentCount++;
        // Renders here automatically
    }
}
@page "/counter-state-1"

<p>
    Current count: @currentCount
</p>

<p>
    <button class="btn btn-primary" @onclick="IncrementCount">Click me</button>
</p>

@code {
    private int currentCount = 0;

    private async Task IncrementCount()
    {
        currentCount++;
        // Renders here automatically

        await Task.Delay(1000);
        currentCount++;
        StateHasChanged();

        await Task.Delay(1000);
        currentCount++;
        StateHasChanged();

        await Task.Delay(1000);
        currentCount++;
        // Renders here automatically
    }
}
@page "/counter-state-1"

<p>
    Current count: @currentCount
</p>

<p>
    <button class="btn btn-primary" @onclick="IncrementCount">Click me</button>
</p>

@code {
    private int currentCount = 0;

    private async Task IncrementCount()
    {
        currentCount++;
        // Renders here automatically

        await Task.Delay(1000);
        currentCount++;
        StateHasChanged();

        await Task.Delay(1000);
        currentCount++;
        StateHasChanged();

        await Task.Delay(1000);
        currentCount++;
        // Renders here automatically
    }
}

Blazor 再レンダリングおよびイベント処理システムの外部の何かからの呼び出しを受信する

ComponentBase によって認識されるのは、その独自のライフサイクル メソッドと Blazor でトリガーされるイベントのみです。 コード内で発生する可能性がある他のイベントについては、ComponentBase によって認識されません。 たとえば、C# カスタム データ ストアによって発生したイベントは、Blazor によって認識されません。 そのようなイベントで再レンダリングをトリガーして、更新された値を UI に表示するためには、StateHasChanged を呼び出します。

System.Timers.Timer を使用してカウントを一定の間隔で更新し、StateHasChanged を呼び出して UI を更新する次の CounterState2 コンポーネントについて考えてみましょう。

  • OnTimerCallback は、Blazor で管理される再レンダリング フローまたはイベント通知の外部で実行されます。 したがって、コールバックでの currentCount への変更は Blazor によって認識されないため、OnTimerCallbackStateHasChanged を呼び出す必要があります。
  • コンポーネントによって IDisposable が実装されます。この場合、フレームワークによって Dispose メソッドが呼び出されると、Timer が破棄されます。 詳しくは、「ASP.NET Core Razor コンポーネントのライフサイクル」をご覧ください。

コールバックは Blazor の同期コンテキストの外部で呼び出されるため、コンポーネントでは ComponentBase.InvokeAsync 内の OnTimerCallback のロジックをラップして、それをレンダラーの同期コンテキストに移動することが必要です。 これは、他の UI フレームワーク内の UI スレッドへのマーシャリングに相当します。 StateHasChanged を呼び出すことができるのは、レンダラーの同期コンテキストからのみであり、それ以外の場合は例外がスローされます。

System.InvalidOperationException: "現在のスレッドは Dispatcher に関連付けられていません。 レンダリングまたはコンポーネントの状態をトリガーするとき、InvokeAsync() を使用して Dispatcher に実行を切り替えます"

Pages/CounterState2.razor:

@page "/counter-state-2"
@using System.Timers
@implements IDisposable

<h1>Counter with <code>Timer</code> disposal</h1>

<p>
    Current count: @currentCount
</p>

@code {
    private int currentCount = 0;
    private Timer timer = new(1000);

    protected override void OnInitialized()
    {
        timer.Elapsed += (sender, eventArgs) => OnTimerCallback();
        timer.Start();
    }

    private void OnTimerCallback()
    {
        _ = InvokeAsync(() =>
        {
            currentCount++;
            StateHasChanged();
        });
    }

    public void Dispose() => timer.Dispose();
}
@page "/counter-state-2"
@using System.Timers
@implements IDisposable

<h1>Counter with <code>Timer</code> disposal</h1>

<p>
    Current count: @currentCount
</p>

@code {
    private int currentCount = 0;
    private Timer timer = new(1000);

    protected override void OnInitialized()
    {
        timer.Elapsed += (sender, eventArgs) => OnTimerCallback();
        timer.Start();
    }

    private void OnTimerCallback()
    {
        _ = InvokeAsync(() =>
        {
            currentCount++;
            StateHasChanged();
        });
    }

    public void Dispose() => timer.Dispose();
}
@page "/counter-state-2"
@using System.Timers
@implements IDisposable

<h1>Counter with <code>Timer</code> disposal</h1>

<p>
    Current count: @currentCount
</p>

@code {
    private int currentCount = 0;
    private Timer timer = new(1000);

    protected override void OnInitialized()
    {
        timer.Elapsed += (sender, eventArgs) => OnTimerCallback();
        timer.Start();
    }

    private void OnTimerCallback()
    {
        _ = InvokeAsync(() =>
        {
            currentCount++;
            StateHasChanged();
        });
    }

    public void Dispose() => timer.Dispose();
}
@page "/counter-state-2"
@using System.Timers
@implements IDisposable

<h1>Counter with <code>Timer</code> disposal</h1>

<p>
    Current count: @currentCount
</p>

@code {
    private int currentCount = 0;
    private Timer timer = new Timer(1000);

    protected override void OnInitialized()
    {
        timer.Elapsed += (sender, eventArgs) => OnTimerCallback();
        timer.Start();
    }

    private void OnTimerCallback()
    {
        _ = InvokeAsync(() =>
        {
            currentCount++;
            StateHasChanged();
        });
    }

    public void Dispose() => timer.Dispose();
}

特定のイベントによって再レンダリングされるサブツリーの外部でコンポーネントをレンダリングするには

UI では次のことが必要な場合があります。

  1. 1 つのコンポーネントにイベントをディスパッチする。
  2. 一部の状態を変更する。
  3. イベントを受け取るコンポーネントの子孫ではないまったく別のコンポーネントを再レンダリングする。

このシナリオに対処する方法の 1 つは、複数のコンポーネントに挿入される "状態管理" クラスを、多くの場合は依存関係の挿入 (DI) サービスとして提供することです。 状態マネージャー上で 1 つのコンポーネントによってメソッドが呼び出されると、別個のコンポーネントによって受信される C# イベントがその状態マネージャーによって引き起こされます。

状態を管理する方法については、次のリソースを参照してください。

状態マネージャーの方法の場合、C# イベントは Blazor レンダリング パイプラインの外側で発生します。 状態マネージャーのイベントに応答してレンダリングしたい他のコンポーネントで StateHasChanged を呼び出します。

状態マネージャーの方法は、前のセクションSystem.Timers.Timer に関する前のケースと似ています。 実行コール スタックは一般にレンダラーの同期コンテキスト上に残っているため、InvokeAsync の呼び出しは通常必要ありません。 InvokeAsync の呼び出しは、ロジックによって同期コンテキストがエスケープされる場合にのみ必要です (Task 上で ContinueWith が呼び出される場合や、ConfigureAwait(false) を使用して Task が待機される場合など)。 詳細については、「Blazor レンダリングおよびイベント処理システムの外部の何かからの呼び出しを受信する」を参照してください。