ASP.NET Core Blazor WebAssembly でコンポーネントの仮想化を試してみた

7 min read読了の目安(約6400字

はじめに

.NET 5 の ASP.NET Core Blazor の新機能の一覧を眺めてたらコンポーネントの仮想化が気になりました。

ドキュメントはこちらです。

https://docs.microsoft.com/ja-jp/aspnet/core/blazor/components/virtualization?view=aspnetcore-5.0

一番簡単な方法

一番簡単な仮想化のやりかたは Virtualize コンポーネントを使って Items プロパティに表示するデータのコレクションを設定して Context プロパティで Virtualize コンポーネント内で使用するコレクション内の1要素を表す変数名を設定する方法です。そして、Virtualize コンポーネントタグの中でリストの 1 項目の見た目を定義していきます。

例えば record Person(int Id, string Name); のようなレコード型の配列を Virtualize コンポーネントで表示するには以下のような感じでできます。

Index.razor
@page "/"

<h1>仮想リスト</h1>

<Virtualize Items="@People" Context="person">
    <div>@person.Name</div>
</Virtualize>

@code {
    public Person[] People { get; } = Enumerable.Range(1, 10000)
        .Select(x => new Person(Guid.NewGuid().ToString(), $"Tanaka {x}"))
        .ToArray();
}

こうすることで、画面に出ている部分だけレンダリングされていて、それ以外の部分は画面がスクロールされたりして実際に表示されるタイミングになったらレンダリングされます。

実際に、上のコードを実行してブラウザーの開発者ツールで覗いてみたら、データは 10000 件あるのに、データを表示している div タグは以下のように 32 件しか無いことが確認できます。

この状態で下にスクロールすると div タグの中身が以下のように書き換わっていきます。

このように、データが大量にあっても表示に必要な部分だけレンダリングすることで描画が原因で性能が悪くなりにくくなります。

データをちょっとずつ読みたい

実際に巨大なデータを表示するときは、一気に読み込むのではなく必要に応じてサーバーから API でデータをとってきたりすることが一般的です。Virtualize のコンポーネントでは ItemsProvider にデータを読み込むメソッドを設定することで、必要に応じて ItemsProvider に設定されたメソッドが呼び出されてデータが必要になったタイミングで取得することができるようになっています。

試してみましょう。
VirtualizeItemsProvider プロパティには引数が ItemsProviderRequest でデータ読み込みの開始位置と、読み込む必要のあるデータの件数が渡されてきます。戻り値は ItemsProviderResult<T> 型で読み込んだデータとトータルのデータ件数を返します。

先ほどのページを ItemsProvider を使うように書き換えるとこんな感じになります。

Index.razor
@page "/"

<h1>仮想リスト</h1>

<Virtualize Context="person" ItemsProvider="GetPeopleAsync">
    <div>@person.Name</div>
</Virtualize>

@code {
    private int Total { get; } = 10000;

    private async ValueTask<ItemsProviderResult<Person>> GetPeopleAsync(ItemsProviderRequest request)
    {
        // 動きを確認するためのログ
        Console.WriteLine($"StartIndex: {request.StartIndex}, Count: {request.Count}");

        // このメソッドで読み込む必要のあるデータ件数
        var numPeople = Math.Min(request.Count, Total - request.StartIndex);

        // データを読み込んで返す
        var data = Enumerable.Range(request.StartIndex, numPeople)
            .Select(x => new Person(Guid.NewGuid().ToString(), $"Tanaka {x}"));
        return new(data, Total);
    }
}

これを実行すると、最初の例と同じように仮想化されて表示されます。さらに、スクロールをしていくと ItemsProvider に設定したメソッドが呼び出されている様子がログに出力されていることも確認できます。

スクロールをして表示されたログは以下のような感じになっています。

ちゃんと必要に応じてデータの取得が行われていることが確認できます。

データの読み込みに時間がかかるケースへの対応

データをサーバーから取得するようなケースでは即座にデータが取れないケースもあります。そんな時用に、データの読み込み中の状態を表示する方法も Virtualize コンポーネントは持っています。

Virtualize コンポーネントの下に ItemContent タグでデータの表示方法を定義して Placeholder タグで読み込み中を表す表示を定義します。

先ほどの例を少し改造して、データの読み込みに 5 秒くらいかかるようにして Placeholder も設定して動作を確認してみましょう。以下のようにコードをちょっと変えてみました。

Index.razor
@page "/"

<h1>仮想リスト</h1>

<Virtualize Context="person" ItemsProvider="GetPeopleAsync">
    <ItemContent>
        <div>@person.Name</div>
    </ItemContent>
    <Placeholder>
        <div>★★★ 読み込み中だよ!!! ★★★</div>
    </Placeholder>
</Virtualize>

@code {
    private int Total { get; } = 10000;

    private async ValueTask<ItemsProviderResult<Person>> GetPeopleAsync(ItemsProviderRequest request)
    {
        Console.WriteLine($"StartIndex: {request.StartIndex}, Count: {request.Count}");
        var numPeople = Math.Min(request.Count, Total - request.StartIndex);
        var data = Enumerable.Range(request.StartIndex, numPeople)
            .Select(x => new Person(Guid.NewGuid().ToString(), $"Tanaka {x}"));
        await Task.Delay(5000); // データの読み込みに時間がかかる!!!
        return new(data, Total);
    }
}

この状態で、ページを表示してスクロールすると、データ読込中のデータは Placeholder で設定した内容が表示されています。データの読込が完了すると、ちゃんとデータに置き換わります。

さらに、スクロールしたときにスムーズにデータが表示されるようにするために OverscanCount というプロパティも定義されています。ここに設定した件数だけ、データの先読みをしておいてスクロール時にスムーズに動くようにできます。

ItemSize プロパティを設定すると、1項目を表示するのに必要な行の高さもピクセル単位で設定できます。50 px から大きく外れるような大きな見た目で表示するときは、このプロパティを設定しておくと実際のスクロールバーの長さが実際のデータを流し込んだときのスクロールの長さに近くなります。

初回表示がイケてない件についての対応

ItemsProvider でのデータ取得に時間がかかっていると、初回に表示するデータの場合には Placeholder が何も表示されないという問題があります。例えば、先ほどのように 5 秒間データ取得に時間がかかる場合は、5秒間 Virtualize コンポーネントのところは真っ白になってしまいます。

気に入らない回避策なのですが、初回の ItemsProvider の呼び出しの場合には実際のデータの取得は行わずデータ件数だけ返すようにすると、とりあえずは Placeholder に表示した内容が表示されました。

Index.razor
@page "/"

<h1>仮想リスト</h1>

<Virtualize Context="person" ItemsProvider="GetPeopleAsync">
    <ItemContent>
        <div>@person.Name</div>
    </ItemContent>
    <Placeholder>
        <div>★★★ 読み込み中だよ!!! ★★★</div>
    </Placeholder>
</Virtualize>

@code {
    private int Total { get; } = 10000;

    // 初回呼び出しかどうかのフラグ
    private bool IsInitialCall { get; set; } = true;

    private async ValueTask<ItemsProviderResult<Person>> GetPeopleAsync(ItemsProviderRequest request)
    {
        Console.WriteLine($"StartIndex: {request.StartIndex}, Count: {request.Count}");
        // 初回呼び出しのときはデータ件数だけ返す
        if (IsInitialCall)
        {
            IsInitialCall = false;
            return new(Enumerable.Empty<Person>(), Total);
        }

        // 2 回目以降の呼び出しのときは実際のデータ取得もやる
        var numPeople = Math.Min(request.Count, Total - request.StartIndex);
        var data = Enumerable.Range(request.StartIndex, numPeople)
            .Select(x => new Person(Guid.NewGuid().ToString(), $"Tanaka {x}"));
        await Task.Delay(5000); // データの読み込みに時間がかかる!!!
        return new(data, Total);
    }
}

実行直後は以下のような感じになってます。

初回呼び出しかどうかのフラグを持つのが気に入りませんが、今考えた範囲ではこの対処法になるのかなぁ…?と思います。

まとめ

データ取得に時間がかかるようなケースだと Virtualize コンポーネントがデータ表示まで何も表示されないという点が個人的に気に入りませんが、これだけ簡単にリストの仮想化ができるのはいい感じだと思いました。

まぁでも一回は ItemsProvider に設定したメソッドが呼ばれないと何を表示していいのかわからなので、致し方ないのかなぁとも思います。

データ取得に時間がかかって Virtualize コンポーネントが表示されないのが気になる場合は初回のデータ呼び出しまでは別のコンポーネントを表示するようにするか、今回のようにとりあえず件数だけ返しちゃうといった方法での対応が必要だと思います。

ということで、Virtualize コンポーネントためしてみた記事でした!

おまけ

この記事は YouTube で配信しながらネタ決めして確認して書いてみました。執筆の様子はこちらから確認できます。

https://youtu.be/7pNK4Xgu_fo