ASP.NET Core Blazor WebAssembly でページのレンダリングが重いときの軽減方法

5 min read

ASP.NET Core Blazor WebAssembly (今回書く内容は Server でも同じだと思う) で沢山コンポーネントを置いたページを表示しようと思うと、どうしてももっさりすることがあります。

例えば、カウンターを表示する <CounterComponent /> を 3000 個くらい置くような以下のようなコードを書いたとします。

Counter.razor
@page "/counter"
@using WebApplication11.Components

@foreach(var _ in Enumerable.Range(1, 1000))
{
    <CounterComponent />
}

@foreach(var _ in Enumerable.Range(1, 1000))
{
    <CounterComponent />
}

@foreach(var _ in Enumerable.Range(1, 1000))
{
    <CounterComponent />
}

こんなページを書いてしまうと、このページのリンクを押してから画面が表示されるまで1秒か2秒くらい画面が無反応になります。

まぁ、これ自体はしょうがないというか、そんなページ作るな感が満載なのですが、それにしてもリンクをクリックしてから表示されるまで一瞬固まったようになるのはちょっといただけないです。

一応回避策はあるので(正しい方法かどうかは自身がありません…)その方法だけメモっておこうと思います。

愚直にレンダリングを遅らせる方法

ということでレンダリングが重いので、画面遷移が固まっているように見えるということはレンダリングしなけれ画面遷移でもたつくことがなくなります!!やってみましょう。

Counter.razor
@page "/counter"
@using System.Threading
@using WebApplication11.Components

<h1>My Counter page</h1>

@if (Phase >= 1)
{
    @foreach (var _ in Enumerable.Range(1, 1000))
    {
        <CounterComponent />
    }
}

@if (Phase >= 2)
{
    @foreach (var _ in Enumerable.Range(1, 1000))
    {
        <CounterComponent />
    }
}

@if (Phase >= 3)
{
    @foreach (var _ in Enumerable.Range(1, 1000))
    {
        <CounterComponent />
    }
}


@code {
    private int Phase { get; set; }

    protected override void OnAfterRender(bool firstRender)
    {
        if (this.Phase < 3)
        {
            this.Phase++;
            _ = Task.Run(() => this.StateHasChanged());
        }
    }
}

あまり行儀がいいとは言えませんが OnAfterRender で再度レンダリングを要求しています。そのまま StatehasChanged を呼ぶのはダメなので Task.Run を使って OnAfterRender とは別の所で再レンダリングを要求しています。その際に Phase を 1 つインクリメントしています。このフェーズによってレンダリング範囲を少しずつ広げていく感じになっています。

Phase が 0 の時は一番上にある h1 タグの内容しか表示されないので凄く速く画面のレンダリングが終わります。動かしてみましょう。

ページ全体が表示されるのには時間がかかっていますが、とりあえず画面遷移はサクっと行くようになりました!!やったね!!

コンポーネント化

まぁ大した処理じゃないにしても、同じような処理を複数ページに埋め込むのはダルイので、このロジックをコンポーネント化してみました。先ほど書いたロジックを以下のように書けるコンポーネントです。

Counter.razor
@page "/counter"
@using System.Threading
@using WebApplication11.Components

<h1>My Counter page</h1>

<LazyRenderer>
    <RenderSection Phase="1">
        @foreach (var _ in Enumerable.Range(1, 1000))
        {
            <CounterComponent />
        }
    </RenderSection>

    <RenderSection Phase="2">
        @foreach (var _ in Enumerable.Range(1, 1000))
        {
            <CounterComponent />
        }
    </RenderSection>

    <RenderSection Phase="3">
        @foreach (var _ in Enumerable.Range(1, 1000))
        {
            <CounterComponent />
        }
    </RenderSection>
</LazyRenderer>

すっきりですね!!ちょっと親子関係があってお互いがお互いを知らないといけないように作ってしまったのでちょっと複雑になってしまいました。
具体的には LazyRenderer では CascadingParameter で自分自身を子コンポーネントである RenderSection に共有して、RenderSection では親コンポーネントに自分自身がレンダリングが完了するまでの間は「まだ終わってないよ!!」という自己アピールするようにしました。

LazyRenderer.razor
<CascadingValue Value="this" IsFixed="true">
    @ChildContent
</CascadingValue>

@code {
    public int CurrentPhase { get; private set; }

    [Parameter]
    public RenderFragment ChildContent { get; set; }

    public List<RenderSection> PendingSections { get; } = new();

    protected override void OnAfterRender(bool firstRender)
    {
        if (this.PendingSections.Any())
        {
            this.CurrentPhase++;
            _ = Task.Run(() => StateHasChanged());
        }
    }
}
RenderSection.razor
@implements IDisposable

@if (IsRenderingPhase)
{
    @ChildContent
}

@code {
    [Parameter]
    public int Phase { get; set; }

    [Parameter]
    public RenderFragment ChildContent { get; set; }

    [CascadingParameter]
    public LazyRenderer Parent { get; set; }

    private bool IsRenderingPhase => Parent.CurrentPhase >= Phase;

    protected override void OnInitialized()
    {
        this.Parent.PendingSections.Add(this);
    }

    protected override void OnAfterRender(bool firstRender)
    {
        if (this.IsRenderingPhase)
        {
            this.Parent.PendingSections.Remove(this);
        }
    }

    public void Dispose() => this.Parent.PendingSections.Remove(this);
}

この状態で実行すると、また画面遷移がさくっと行くようになります。

イイ感じですね。

まとめ

そもそも、大量にコンポーネントがあるようなページを作らないのがベストなんですが、そうはいってもちょっと多めのコンポーネントがあって、最初のレンダリングは早くしたいという要望はあるのかなぁと思います。

そんなときに、とりあえず初回レンダリングではレンダリングせずに後でレンダリングするというアプローチはありなのかなと思います。

今回は試してみて動いたので、コンポーネント化までしました。まぁ出来れば公式でほしいところですね…。