ASP.NET Core Razor コンポーネントの仮想化

注意

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

重要

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

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

この記事では、ASP.NET Core Blazor アプリでコンポーネントの仮想化を使用する方法について説明します。

Virtualization

Virtualize<TItem> コンポーネントで Blazor フレームワークに組み込まれている仮想化サポートを使用して、コンポーネント レンダリングの体感パフォーマンスを向上させます。 仮想化は、UI レンダリングを現在表示されている部分のみに制限するための手法です。 たとえば、仮想化が有用なのは、アプリで項目の長いリストや項目の一部のみをレンダリングする必要があるが、表示する必要があるのはどんなときでも項目のサブセットのみである場合です。

Virtualize<TItem> コンポーネントを使用する場合:

  • ループ内の一連のデータ項目をレンダリングする。
  • スクロールが原因でほとんどの項目が表示されない。
  • レンダリングされる項目のサイズが同じ。

Virtualize<TItem> コンポーネントの項目リスト内の任意のポイントにユーザーがスクロールすると、コンポーネントによって表示可能な項目が計算されます。 非表示の項目はレンダリングされません。

仮想化を使用しない場合は、一般的なリストで、C# foreach ループを使用してリストの各項目を表示できます。 次に例を示します。

  • allFlights は、飛行機のフライトのコレクションです。
  • FlightSummary コンポーネントには各フライトの詳細が表示されます。
  • @key ディレクティブ属性によって、フライトの FlightId ごとにレンダリングされたフライトに対する各 FlightSummary コンポーネントの関係が保持されます。
<div style="height:500px;overflow-y:scroll">
    @foreach (var flight in allFlights)
    {
        <FlightSummary @key="flight.FlightId" Details="@flight.Summary" />
    }
</div>

コレクションに数千のフライトが含まれている場合、フライトのレンダリングに時間がかかり、ユーザーは UI の表示が明らかに遅いと感じます。 ほとんどのフライトは、<div> 要素の高さから外れているため、表示されません。

フライトのリスト全体を一度にレンダリングするのではなく、前の例の foreach ループを Virtualize<TItem> コンポーネントに置き換えます。

  • Virtualize<TItem>.Items の固定項目ソースとして allFlights を指定します。 現在表示されているフライトだけが、Virtualize<TItem> コンポーネントによってレンダリングされます。

    非ジェネリック コレクションが項目 (DataRow のコレクションなど) を指定する場合は、「項目プロバイダー デリゲート」セクションのガイダンスに従って項目を指定します。

  • Context パラメーターを使用して、各フライトのコンテキストを指定します。 次の例では、flight がコンテキストとして使用されています。これにより、各フライトのメンバーへのアクセスを提供します。

<div style="height:500px;overflow-y:scroll">
    <Virtualize Items="allFlights" Context="flight">
        <FlightSummary @key="flight.FlightId" Details="@flight.Summary" />
    </Virtualize>
</div>

コンテキストが Context パラメーターを使用して指定されていない場合は、項目コンテンツ テンプレート内の context の値を使用して、各フライトのメンバーにアクセスします。

<div style="height:500px;overflow-y:scroll">
    <Virtualize Items="allFlights">
        <FlightSummary @key="context.FlightId" Details="@context.Summary" />
    </Virtualize>
</div>

Virtualize<TItem> コンポーネント:

  • コンテナーの高さとレンダリングする項目のサイズに基づいて、レンダリングする項目の数を計算します。
  • ユーザーがスクロールすると、項目が再計算され、再レンダリングされます。
  • Items ではなく ItemsProvider が使われている場合、オーバースキャンを含め、現在表示されているリージョンに対応するレコードのスライスのみを外部 API からフェッチします (「項目プロバイダー デリゲート」セクションを参照してください)。

Virtualize<TItem> コンポーネントの項目コンテンツには次を含めることができます。

  • 前の例に含まれていたプレーン HTML および Razor コード。
  • 1 つまたは複数の Razor コンポーネント。
  • HTML/Razor および Razor コンポーネントの混合。

項目プロバイダー デリゲート

すべての項目をメモリに読み込みたいわけではない場合や、コレクションがジェネリック ICollection<T> でない場合は、要求された項目をオンデマンドで非同期的に取得するコンポーネントの Virtualize<TItem>.ItemsProvider パラメーターに項目プロバイダー デリゲート メソッドを指定できます。 次の例では、LoadEmployees メソッドによって、項目が Virtualize<TItem> コンポーネントに提供されます。

<Virtualize Context="employee" ItemsProvider="LoadEmployees">
    <p>
        @employee.FirstName @employee.LastName has the 
        job title of @employee.JobTitle.
    </p>
</Virtualize>

項目プロバイダーは、特定の開始インデックスを開始位置として、必要な項目数を指定する ItemsProviderRequest を受け取ります。 次に、項目プロバイダーにより、要求される項目がデータベースまたは他のサービスから取得され、それらが ItemsProviderResult<TItem> として、合計項目数と共に返されます。 項目プロバイダーでは、項目を要求ごとに取得するか、すぐに使用できるようにキャッシュするかを選択できます。

Virtualize<TItem> コンポーネントでは、そのパラメーターから 1 つの項目ソースのみを受け入れることができるので、項目プロバイダーを同時に使用して Items にコレクションを割り当てることは避けてください。 両方が割り当てられている場合は、コンポーネントのパラメーターが実行時に設定されると InvalidOperationException がスローされます。

次の例では、EmployeeService から従業員が読み込まれます (表示されません)。

private async ValueTask<ItemsProviderResult<Employee>> LoadEmployees(
    ItemsProviderRequest request)
{
    var numEmployees = Math.Min(request.Count, totalEmployees - request.StartIndex);
    var employees = await EmployeesService.GetEmployeesAsync(request.StartIndex, 
        numEmployees, request.CancellationToken);

    return new ItemsProviderResult<Employee>(employees, totalEmployees);
}

次の例では、DataRow のコレクションが非ジェネリック コレクションであるため、仮想化に項目プロバイダー デリゲートが使用されます。

<Virtualize Context="row" ItemsProvider="GetRows">
    ...
</Virtualize>

@code{
    ...

    private ValueTask<ItemsProviderResult<DataRow>> GetRows(ItemsProviderRequest request)
    {
        return new(new ItemsProviderResult<DataRow>(
            dataTable.Rows.OfType<DataRow>().Skip(request.StartIndex).Take(request.Count),
            dataTable.Rows.Count));
    }
}

ItemsProvider にデータを再要求するように、Virtualize<TItem>.RefreshDataAsync からコンポーネントに指示が出されます。 これは、外部データが変更される場合に便利です。 Items を使用するときは、通常、RefreshDataAsync を呼び出す必要はありません。

RefreshDataAsync によって、再レンダリングを発生させずに Virtualize<TItem> コンポーネントのデータが更新されます。 RefreshDataAsync が Blazor イベント ハンドラーまたはコンポーネントのライフサイクル メソッドから呼び出される場合は、イベント ハンドラーまたはライフサイクル メソッドの最後でレンダリングが自動的にトリガーされるため、レンダリングをトリガーする必要はありません。 次の ForecastUpdated デリゲートなど、バックグラウンド タスクまたはイベントとは別に RefreshDataAsync がトリガーされる場合は、バックグラウンド タスクまたはイベントの最後で StateHasChanged を呼び出して UI を更新します。

<Virtualize ... @ref="virtualizeComponent">
    ...
</Virtualize>

...

private Virtualize<FetchData>? virtualizeComponent;

protected override void OnInitialized()
{
    WeatherForecastSource.ForecastUpdated += async () => 
    {
        await InvokeAsync(async () =>
        {
            await virtualizeComponent?.RefreshDataAsync();
            StateHasChanged();
        });
    });
}

前の例の場合:

  • Virtualize<TItem> コンポーネントの新しいデータを取得するために、RefreshDataAsync が最初に呼び出されます。
  • コンポーネントを再レンダリングするために、StateHasChanged が呼び出されます。

プレースホルダー

リモート データ ソースに項目を要求には時間がかかる場合があるため、項目のコンテンツを含むプレースホルダーをレンダリングするオプションがあります。

  • 項目データが使用可能になるまでコンテンツを表示するには、Placeholder (<Placeholder>...</Placeholder>) を使用します。
  • リストの項目テンプレートを設定するには、Virtualize<TItem>.ItemContent を使用します。
<Virtualize Context="employee" ItemsProvider="LoadEmployees">
    <ItemContent>
        <p>
            @employee.FirstName @employee.LastName has the 
            job title of @employee.JobTitle.
        </p>
    </ItemContent>
    <Placeholder>
        <p>
            Loading&hellip;
        </p>
    </Placeholder>
</Virtualize>

空のコンテンツ

コンポーネントが読み込まれ、Items が空であるか、ItemsProviderResult<TItem>.TotalItemCount が 0 の場合に、EmptyContent パラメーターを使用してコンテンツを指定します。

EmptyContent.razor:

@page "/empty-content"

<PageTitle>Empty Content</PageTitle>

<h1>Empty Content Example</h1>

<Virtualize Items="@stringList">
    <ItemContent>
        <p>
            @context
        </p>
    </ItemContent>
    <EmptyContent>
        <p>
            There are no strings to display.
        </p>
    </EmptyContent>
</Virtualize>

@code {
    private List<string>? stringList;

    protected override void OnInitialized() => stringList ??= new();
}

コンポーネントの表示文字列を表示するには、OnInitialized メソッドのラムダ式を次のように変更します。

protected override void OnInitialized() =>
    stringList ??= new() { "Here's a string!", "Here's another string!" };

項目のサイズ

各項目の高さ (ピクセル単位) は Virtualize<TItem>.ItemSize で設定できます (既定値: 50)。 次の例では、各項目の高さを既定値の 50 ピクセルから 25 ピクセルに変更します。

<Virtualize Context="employee" Items="employees" ItemSize="25">
    ...
</Virtualize>

既定では、初期レンダリングが行われた "" に、Virtualize<TItem> コンポーネントによって個々の項目のレンダリング サイズ (高さ) が測定されます。 正確な初期レンダリングのパフォーマンスを支援し、ページを再読み込みするための正しいスクロール位置を確保するために、ItemSize を使用して事前に正確な項目のサイズを提供します。 既定の ItemSize によって一部の項目が現在表示されているビューの外側にレンダーされる場合、2 回目の再レンダリングがトリガーされます。 仮想化されたリストでブラウザーのスクロール位置を正しく維持するには、最初のレンダリングが正しいことが必要です。 そうでない場合、間違った項目がユーザーに表示されるおそれがあります。

オーバースキャン数

Virtualize<TItem>.OverscanCount を使用して、表示領域の前後にレンダリングされる追加項目の数を指定します。 この設定は、スクロール中のレンダリングの頻度を減らすのに利用できます。 ただし、値を大きくすると、ページにレンダリングされる要素が多くなります (既定値: 3)。 次の例では、オーバースキャン数を既定の 3 項目から 4 項目に変更します。

<Virtualize Context="employee" Items="employees" OverscanCount="4">
    ...
</Virtualize>

状態変更

Virtualize<TItem> コンポーネントによってレンダリングされる項目を変更する場合は、StateHasChanged を呼び出して、強制的にコンポーネントを再評価して再レンダリングします。 詳しくは、「ASP.NET Core Razor コンポーネントのレンダリング」をご覧ください。

キーボード スクロールのサポート

ユーザーがキーボードを使って仮想化されたコンテンツをスクロールできるようにするには、仮想化された要素またはスクロール コンテナー自体にフォーカスを設定できるようにします。 これを行わないと、Chromium ベースのブラウザーではキーボード スクロールが機能しません。

たとえば、スクロール コンテナーの tabindex 属性を使用できます。

<div style="height:500px; overflow-y:scroll" tabindex="-1">
    <Virtualize Items="allFlights">
        <div class="flight-info">...</div>
    </Virtualize>
</div>

tabindex の値 -10、またはその他の値の意味について詳しくは、tabindex (MDN ドキュメント) をご覧ください。

高度なスタイルとスクロールの検出

Virtualize<TItem> コンポーネントは、特定の要素のレイアウト メカニズムをサポートするためだけに設計されています。 どの要素のレイアウトが正しく機能するかを理解するため、以下では正しい配置のために表示する必要がある要素を Virtualize が検出する方法について説明します。

次のようなソース コードがあるとします。

<div style="height:500px; overflow-y:scroll" tabindex="-1">
    <Virtualize Items="allFlights" ItemSize="100">
        <div class="flight-info">Flight @context.Id</div>
    </Virtualize>
</div>

実行時には、Virtualize<TItem> コンポーネントによって次のような DOM 構造がレンダリングされます。

<div style="height:500px; overflow-y:scroll" tabindex="-1">
    <div style="height:1100px"></div>
    <div class="flight-info">Flight 12</div>
    <div class="flight-info">Flight 13</div>
    <div class="flight-info">Flight 14</div>
    <div class="flight-info">Flight 15</div>
    <div class="flight-info">Flight 16</div>
    <div style="height:3400px"></div>
</div>

実際にレンダリングされる行数とスペーサーのサイズは、スタイルと Items コレクションのサイズによって異なります。 ただし、コンテンツの前後にスペーサーの div 要素が挿入されていることに注意してください。 これらには 2 つの目的があります。

  • コンテンツの前と後のオフセットを提供し、現在表示されている項目がスクロール範囲内の正しい位置に表示され、スクロール範囲自体がすべてのコンテンツの合計サイズを表すようにします。
  • ユーザーが現在の表示範囲を超えてスクロールし、異なるコンテンツをレンダリングする必要があることを検出します。

注意

スペーサー HTML 要素タグを制御する方法については、この記事で後述する「スペーサー要素タグ名の制御」セクションを参照してください。

スペーサー要素は、内部的に Intersection Observer を使用して、表示されるようになったら通知を受け取ります。 Virtualize はこれらのイベントの受信に依存します。

Virtualize は次の条件下で動作します。

  • プレースホルダー コンテンツを含め、レンダリングされるすべてのコンテンツ項目の高さは同じです。 これにより、最初にすべてのデータ項目をフェッチして DOM 要素にデータをレンダリングすることなく、特定のスクロール位置に対応するコンテンツを計算できます。

  • スペーサーとコンテンツ行の両方が、1 つの垂直方向のスタックに表示され、すべての項目が横幅全体を埋めます。 通常、これが既定の状態です。 div 要素の通常のケースでは、Virtualize は既定で動作します。 CSS を使用してより高度なレイアウトを作成する場合は、次の要件に注意してください。

    • スクロール コンテナーのスタイル設定には、次のいずれかの値を持つ display が必要です。
      • block (div の既定値)。
      • table-row-group (tbody の既定値)。
      • flex-directioncolumn に設定された flexVirtualize<TItem> コンポーネントの直下の子がフレックス ルールで圧縮されないようにします。 たとえば、.mycontainer > div { flex-shrink: 0 } を追加します。
    • コンテンツの行のスタイル設定には、次のいずれかの値を持つ display が必要です。
      • block (div の既定値)。
      • table-row (tr の既定値)。
    • CSS を使用して、スペーサー要素のレイアウトに干渉しないでください。 既定では、スペーサー要素の display の値は block です。ただし、親がテーブル行グループの場合は例外で、この場合の既定値は table-row です。 たとえば、境界線や content 擬似要素を設定することにより、スペーサー要素の幅または高さに影響を与えないようにしてください。

スペーサーとコンテンツ要素が 1 つの垂直スタックとして表示されるのを妨げたり、コンテンツ項目の高さばらばらにしたりすると、Virtualize<TItem> コンポーネントが正しく機能しなくなります。

ルートレベルの仮想化

Virtualize<TItem> コンポーネントでは、overflow-y: scroll を使用する他の要素を持つ代わりに、ドキュメント自体をスクロール ルートとして使用することをサポートしています。 次の例では、<html> または <body> 要素は、overflow-y: scroll を使用するコンポーネントでスタイル設定されています。

<HeadContent>
    <style>
        html, body { overflow-y: scroll }
    </style>
</HeadContent>

Virtualize<TItem> コンポーネントでは、overflow-y: scroll を使用する他の要素を持つ代わりに、ドキュメント自体をスクロール ルートとして使用することをサポートしています。 ドキュメントをスクロール ルートとして使用する場合は、交差オブザーバーがウィンドウ ビューポートだけでなく、ページのスクロール可能な高さ全体を可視領域として扱うようになるため、<html> または <body> 要素を overflow-y: scroll でスタイル指定しないでください。

この問題を再現するには、仮想化された大規模なリスト (たとえば、100,000 項目) を作成し、ページの CSS スタイルで html { overflow-y: scroll } を使用してドキュメントをスクロール ルートとして使用してみてください。 正しく機能する場合もありますが、ブラウザーはレンダリングの開始時に少なくとも 1 回は 100,000 項目すべてをレンダリングしようとするため、ブラウザーのタブがロックアップする可能性があります。

.NET 7 のリリースの前にこの問題を回避するには、overflow-y: scroll を使用した <html>/<body> 要素のスタイル設定を避けるか、代替アプローチを採用します。 次の例では、<html> 要素の高さがビューポートの高さの 100% を超える値に設定されています。

<HeadContent>
    <style>
        html { min-height: calc(100vh + 0.3px) }
    </style>
</HeadContent>

Virtualize<TItem> コンポーネントでは、overflow-y: scroll を使用する他の要素を持つ代わりに、ドキュメント自体をスクロール ルートとして使用することをサポートしています。 ドキュメントをスクロール ルートとして使用する場合、ウィンドウ ビューポートだけでなく、ページのスクロール可能な高さ全体を可視領域として扱うようになるため、<html> または <body> 要素を overflow-y: scroll でスタイル指定しないでください。

この問題を再現するには、仮想化された大規模なリスト (たとえば、100,000 項目) を作成し、ページの CSS スタイルで html { overflow-y: scroll } を使用してドキュメントをスクロール ルートとして使用してみてください。 正しく機能する場合もありますが、ブラウザーはレンダリングの開始時に少なくとも 1 回は 100,000 項目すべてをレンダリングしようとするため、ブラウザーのタブがロックアップする可能性があります。

.NET 7 のリリースの前にこの問題を回避するには、overflow-y: scroll を使用した <html>/<body> 要素のスタイル設定を避けるか、代替アプローチを採用します。 次の例では、<html> 要素の高さがビューポートの高さの 100% を超える値に設定されています。

<style>
    html { min-height: calc(100vh + 0.3px) }
</style>

スペーサー要素タグ名を制御する

特定の子タグ名を必要とする要素内に Virtualize<TItem> コンポーネントが配置されている場合、SpacerElement を使用すると、仮想化スペーサー タグ名を取得または設定できます。 既定値は div です。 次の例では、Virtualize<TItem> コンポーネントがテーブル本体要素 (tbody) 内にレンダリングされるため、テーブル行 (tr) の適切な子要素がスペーサーとして設定されます。

VirtualizedTable.razor:

@page "/virtualized-table"

<PageTitle>Virtualized Table</PageTitle>

<HeadContent>
    <style>
        html, body {
            overflow-y: scroll
        }
    </style>
</HeadContent>

<h1>Virtualized Table Example</h1>

<table id="virtualized-table">
    <thead style="position: sticky; top: 0; background-color: silver">
        <tr>
            <th>Item</th>
            <th>Another column</th>
        </tr>
    </thead>
    <tbody>
        <Virtualize Items="fixedItems" ItemSize="30" SpacerElement="tr">
            <tr @key="context" style="height: 30px;" id="row-@context">
                <td>Item @context</td>
                <td>Another value</td>
            </tr>
        </Virtualize>
    </tbody>
</table>

@code {
    private List<int> fixedItems = Enumerable.Range(0, 1000).ToList();
}

前の例では、ドキュメント ルートがスクロール コンテナーとして使用されているため、html 要素と body 要素は overflow-y: scroll でスタイル設定されています。 詳細については、次のリソースを参照してください。