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

注意

これは、この記事の最新バージョンではありません。 現在のリリースについては、この記事の .NET 8 バージョンを参照してください。

重要

この情報はリリース前の製品に関する事項であり、正式版がリリースされるまでに大幅に変更される可能性があります。 Microsoft はここに示されている情報について、明示か黙示かを問わず、一切保証しません。

現在のリリースについては、この記事の .NET 8 バージョンを参照してください。

この記事では、レンダーするコンポーネントを手動でトリガーするために 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 の実装は常に true を返します)。

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

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

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

ストリーミング レンダリング

静的サーバー側レンダリング (静的 SSR) またはプリレンダリングで "ストリーミング レンダリング" を使用すると、応答ストリーム上でコンテンツの更新がストリーミングされ、完全にレンダリングするために実行時間の長い非同期タスクを実行するコンポーネントのユーザー エクスペリエンスが向上します。

たとえば、ページの読み込み時にデータをレンダリングするために、データベース クエリまたは Web API 呼び出しを長時間実行するコンポーネントを考えてみます。 通常、サーバー側コンポーネントのレンダリングの一部として実行される非同期タスクは、レンダリングされた応答が送信される前に完了する必要があるため、ページの読み込みが遅れる可能性があります。 ページのレンダリングに大幅な遅延が生じると、ユーザー エクスペリエンスが低下します。 ユーザー エクスペリエンスを向上させるために、ストリーミング レンダリングは、非同期操作の実行中にプレースホルダー コンテンツを使用してページ全体をすばやくレンダリングします。 操作が完了すると、更新されたコンテンツが同じ応答接続でクライアントに送信され、DOM にパッチが適用されます。

ストリーミング レンダリングを行うには、出力のバッファリングを回避するためにサーバーが必要です。 応答データは、データの生成時にクライアントに流れる必要があります。 バッファリングを適用するホストの場合、ストリーミング レンダリングの提供レベルは適切に下がり、ストリーミング レンダリングなしでページが読み込まれます。

静的サーバー側レンダリング (静的 SSR) またはプリレンダリングを使用するときにコンテンツの更新をストリーミングするには、[StreamRendering(true)] 属性をコンポーネントに適用します。 更新プログラムをストリーミングすることよりページ上のコンテンツがシフトする可能性があるため、ストリーミング レンダリングを明示的に有効にする必要があります。 属性を持たないコンポーネントでは、親コンポーネントでこの機能が使用されている場合、ストリーミング レンダリングが自動的に採用されます。 子コンポーネントの属性に false を渡して、その時点で機能を無効にし、コンポーネント サブツリーの下に進みます。 属性は、Razor クラス ライブラリによって提供されるコンポーネントに適用される場合に機能します。

次の例は、Blazor Web アプリ プロジェクト テンプレートから作成されたアプリの Weather コンポーネントに基づいています。 Task.Delay を呼び出すと、気象データの取得が非同期的にシミュレートされます。 コンポーネントは、非同期遅延の完了を待たずに、最初にプレースホルダー コンテンツ (「Loading...」) をレンダリングします。 非同期遅延が完了し、気象データ コンテンツが生成されると、コンテンツは応答にストリーミングされ、天気予報テーブルにパッチが適用されます。

Weather.razor:

@page "/weather"
@attribute [StreamRendering(true)]

...

@if (forecasts == null)
{
    <p><em>Loading...</em></p>
}
else
{
    <table class="table">
        ...
        <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()
    {
        await Task.Delay(500);

        ...

        forecasts = ...
    }
}

UI 更新の抑制 (ShouldRender)

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

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

ControlRender.razor:

@page "/control-render"

<PageTitle>Control Render</PageTitle>

<h1>Control Render Example</h1>

<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++;
    }
}
@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 を呼び出します。

IncrementCount メソッドが実行されるたびにカウントを 4 回更新する以下の CounterState1 コンポーネントについて考えてみましょう。

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

CounterState1.razor:

@page "/counter-state-1"

<PageTitle>Counter State 1</PageTitle>

<h1>Counter State Example 1</h1>

<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
    }
}
@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 に実行を切り替えます"

CounterState2.razor:

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

<PageTitle>Counter State 2</PageTitle>

<h1>Counter State Example 2</h1>

<p>
    This counter demonstrates <code>Timer</code> disposal.
</p>

<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(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 レンダリングおよびイベント処理システムの外部の何かからの呼び出しを受信する」を参照してください。

Blazor Web アプリの WebAssembly 読み込み進行状況インジケーター

読み込み進行状況インジケーターは、Blazor Web アプリ プロジェクト テンプレートから作成されたアプリには存在しません。 .NET の将来のリリースでは、新しい読み込み進行状況インジケーター機能が計画されています。 それまでの間、アプリはカスタム コードを採用して、読み込み進行状況インジケーターを作成できます。 詳しくは、「ASP.NET Core Blazor の起動」をご覧ください。