3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

挙動そのままにパフォーマンス改善?!Blazorパフォーマンス改善手法のすすめ

Last updated at Posted at 2025-12-10

導入

Blazorは、非常に便利です。豊富な標準ライブラリー、使い慣れたC#をフロントエンドでも使える。
私は普段Blazor WebAssemblyを主に使用しておりますが、
初めて使ったときは、「なんかもっさりしてる」、「速度早くしてほしい」と延々と言われ続けた思い出があります。

増え続ける機能、削れないロジック、元から早いわけではないBlazor Wasm、今回はそんな環境に対抗するための手段の一つをお伝えしたいと思います。

今回の手法

結論だけ先に言うとBlazorのレンダリング回数を減らそうというものになります。

複雑なUIロジックが記載されてたとしてそれが10回から1回のレンダリング回数になればパフォーマンスが上がります。

具体的にはBlazorのレンダリングのルールを実験により理解し、最後は応用として参考例をお見せしたいと思います。

レンダリング回数の確認方法

調査したいコンポーネントのAfterRender(Async)にカウンターをつけると
実際のレンダリング回数を確認することができます。

    private int renderCounter = 0;
    protected override void OnAfterRender(bool firstRender)
    {
        Console.WriteLine($"{GetType().Name}: {++_renderCount}回目");
    }

前提:レンダリング回数を減らすには?StateHasChangedの伝播を理解する

まずはレンダリング回数を減らすためBlazorの仕組みを確認したいと思います。

StateHasChanged 押したとき更新範囲を把握してますか?

普段何気なく書いているStateHasChangedですが、これ具体的にどこを更新してるのでしょうか?

画面全体?仮想DOMの仕組みがあるからパラメータが更新されたコンポーネントだけ?

試してみましょう

実験:StateHasChangedの影響範囲を確認する

実験のため2つのコンポーネントを用意してみました。

親コンポーネントは、先ほど言ったレンダリング回数をカウントする処理と、それを画面に表示する処理が記述されています。

親コンポーネント
<div class="@(_renderCount % 2 == 0 ? "green" : "orange")" >
    <ChildComponent List="list"/>

    <button @onclick="EventUtil.AsNonRenderingEventHandler(UpdateView)">親コンポーネント</button>
    @_renderCount
</div>

@code {

    private List<string> list = new() { "apple" };

    private void UpdateView()
    {
        StateHasChanged();
    }

    private int _renderCount = 0;

    protected override void OnAfterRender(bool firstRender)
    {
        Console.WriteLine($"{GetType().Name}: {++_renderCount}回目");
    }


}
子コンポーネント
<div class="@(_renderCount % 2 == 0 ? "green" : "orange")" >
    <button @onclick="EventUtil.AsNonRenderingEventHandler(UpdateView)">子コンポーネント</button>
    @_renderCount
</div>

@code {

    [Parameter]
    public List<string> List { get; set; } = [];

    private void UpdateView()
    {
        StateHasChanged();
    }

    private int _renderCount = 0;

    protected override void OnAfterRender(bool firstRender)
    {
        Console.WriteLine($"{GetType().Name}: {++_renderCount}回目");
    }

}

コード内にあるEventUtilというクラスや使ってないprivate List<string> list = new() { "apple" };は、あとで重要な役割を見せます。

ですが、いったんは無視でお願いします。

解説のため各コンポーネント全体に色を付けており、「緑」と「オレンジ」がレンダリングのたびに切り替わるようにしています。

<div class="@(_renderCount % 2 == 0 ? "green" : "orange")">`
.green {
    background-color: lightgreen;
}

.orange {
    background-color: orange;
}

こちらを出力すると下記のように表示されます。

image-4.png

次に子コンポーネントのボタンをクリックしてみましょう

image-5.png

子コンポーネントはレンダリング回数が1回増えましたが親コンポーネントは0回(緑)のままです

次に親コンポーネントのボタンをクリックしてみましょう

image-6.png

親と子両方のコンポーネントが更新されました。

結論:StateHasChangedは下方向にしか伝播しない

実はBlazorのStateHasChangedの更新は親から子方向への一方通行であり、親が更新されると子側も更新されるという挙動になります。

子→親は更新されません!

EventCallbackの挙動を把握する

今の説明少し、おかしなことがあります。

いきなりですが、この画面をご覧ください。

image-7.png

利用規約に同意しないと先に進めないサイト、よくあるやつです。
サンプルにコードを記述してみました。

<label>
    <InputCheckbox @bind-Value="AgreeTheTermsOfUse" />
    <a herf="terms-of-service.html">利用規約</a>に同意する
</label>

<br />
<button disabled="@(!AgreeTheTermsOfUse)">アカウントを作成する</button>

@code {

    private bool AgreeTheTermsOfUse { get; set; }

    private void CreateAccount()
    {
        // アカウント作成処理
    }

}

もし親から子方向にしかStateHasChangedが聞かないならInputCheckboxコンポーネントによって兄弟であるアカウントを作成するボタンがdisabledになるのはおかしいです。

これだと子が親側を更新できてることになります。

なぜこれが実現出来るのでしょうか?

答えはEventCallbackにあります。

実験:EventCallBackによるレンダリングを観察する

こちらは先ほどでてきた親コンポーネントからStateHasChangedをコメントアウトしたものです。

親コンポーネント
<div class="@(_renderCount % 2 == 0 ? "green" : "orange")">
    <ChildComponent List="list"/>

    <button @onclick="EventUtil.AsNonRenderingEventHandler(UpdateView)">親コンポーネント</button>
    @_renderCount
</div>

@code {

    private List<string> list = new() { "apple" };

    private void UpdateView()
    {
        // StateHasChanged(); ←コメントアウト
    }

    private int _renderCount = 0;

    protected override void OnAfterRender(bool firstRender)
    {
        Console.WriteLine($"{GetType().Name}: {++_renderCount}回目");
    }

}

image-8.png

親コンポーネントのボタンをいくら押してもレンダリングが実行されません

では次にコメントアウトはそのままに先ほどからでてるEventUtilクラスを消して実行してみましょう。

@onclick="EventUtil.AsNonRenderingEventHandler(UpdateView)"@onclick="UpdateView"

image-9.png

親コンポーネントをクリックすると反応します。

どういうことでしょうか?

結論:EventCallBackは呼び出し側コンポーネントのStateHasChangedを発動する

実はEventCallBackには、アタッチされているイベントハンドラーが同期処理なら処理完了後に1回、非同期処理なら最初のawaitの時と、処理完了後にそれぞれ1回、自動でイベントハンドラー(親コンポーネント)側のStateHasChangedを呼ぶ仕組みが存在します。

EventUtilはこれを無効化するためのクラスです。

最初の実験ではStateHasChangedの親子関係がわかりづらくなるので無効化させていただきました。

標準ライブラリーではなくひっそりとこちらのドキュメント内に記載されてる便利クラスとなります。

ASP.NET Core Blazor レンダリングパフォーマンスのベスト プラクティス

親から子のレンダリングが行われないパターンを把握する

最初の実験でStateHasChangedは親から子に伝播すると話ましたが、これが止められる手段があります。

実験:パラメータを渡さなかった場合を観察する

親コンポーネント
<div class="@(_renderCount % 2 == 0 ? "green" : "orange")">
    <ChildComponent />

    <button @onclick="EventUtil.AsNonRenderingEventHandler(UpdateView)">親コンポーネント</button>
    @_renderCount
</div>

@code {

    // private List<string> list = new() { "apple" }; ←コメントアウト

    private void UpdateView()
    {
        StateHasChanged();
    }

    private int _renderCount = 0;

    protected override void OnAfterRender(bool firstRender)
    {
        Console.WriteLine($"{GetType().Name}: {++_renderCount}回目");
    }

}
子コンポーネント
<div class="@(_renderCount % 2 == 0 ? "green" : "orange")">
    <button @onclick="EventUtil.AsNonRenderingEventHandler(UpdateView)">子コンポーネント</button>
    @_renderCount
</div>

@code {

    // [Parameter]
    // public List<string> List { get; set; } = []; ←コメントアウト

    private void UpdateView()
    {
        StateHasChanged();
    }

    private int _renderCount = 0;

    protected override void OnAfterRender(bool firstRender)
    {
        Console.WriteLine($"{GetType().Name}: {++_renderCount}回目");
    }

}

何度目かもわからない親コンポーネントと子コンポーネントに登場いただきました。

コンポーネントからパラメータをすべて消した場合、どういう挙動を示すでしょうか?

image-12.png

親コンポーネントをクリックすると親だけが更新され子が更新されなくなりました。

実験:プリミティブ型のint型をパラメータに渡した場合を観察する

親コンポーネント
<div class="@(_renderCount % 2 == 0 ? "green" : "orange")">
    <ChildComponent Number="number" />

    <button @onclick="EventUtil.AsNonRenderingEventHandler(UpdateView)">親コンポーネント</button>
    @_renderCount
</div>

@code {

    private int number = 1; // リストから数値に

    private void UpdateView()
    {
        StateHasChanged();
    }

    private int _renderCount = 0;

    protected override void OnAfterRender(bool firstRender)
    {
        Console.WriteLine($"{GetType().Name}: {++_renderCount}回目");
    }

}
子コンポーネント
<div class="@(_renderCount % 2 == 0 ? "green" : "orange")">
    <button @onclick="EventUtil.AsNonRenderingEventHandler(UpdateView)">子コンポーネント</button>
    @_renderCount
</div>

@code {

    [Parameter]
    public int Number { get; set; } = 0; // Listから数値に

    private void UpdateView()
    {
        StateHasChanged();
    }

    private int _renderCount = 0;

    protected override void OnAfterRender(bool firstRender)
    {
        Console.WriteLine($"{GetType().Name}: {++_renderCount}回目");
    }

}

もう一パターン

private List<string> list = new() { "apple" };public int Number { get; set; } = 0;とint型に置き換えてみました。

ここで挙動を見てみましょう

image-10.png

パラメータが無かった時と同様親コンポーネントだけが更新されます。

さらにもう一つ実験です。

先ほどの親コンポーネントの数値を大きくする処理を追加してみました。

親コンポーネント
<div class="@(_renderCount % 2 == 0 ? "green" : "orange")">
    <ChildComponent Number="number" />

    <button @onclick="EventUtil.AsNonRenderingEventHandler(UpdateView)">親コンポーネント</button>
    @_renderCount
</div>

@code {

    private int number = 1;

    private void UpdateView()
    {
        number++; // 数値を大きくする
        StateHasChanged();
    }

    private int _renderCount = 0;

    protected override void OnAfterRender(bool firstRender)
    {
        Console.WriteLine($"{GetType().Name}: {++_renderCount}回目");
    }

}

もう一回クリックしてみましょう。

image-11.png

親コンポーネントをクリックすると親、子、共にに反応します。

実験:参照型のListで値を変更せずにボタンを連打してみる

親コンポーネント
<div class="@(_renderCount % 2 == 0 ? "green" : "orange")" >
    <ChildComponent List="list"/>

    <button @onclick="EventUtil.AsNonRenderingEventHandler(UpdateView)">親コンポーネント</button>
    @_renderCount
</div>

@code {

    private List<string> list = new() { "apple" };

    private void UpdateView()
    {
        StateHasChanged();
    }

    private int _renderCount = 0;

    protected override void OnAfterRender(bool firstRender)
    {
        Console.WriteLine($"{GetType().Name}: {++_renderCount}回目");
    }


}

このコードは一番最初の実験で使ったコードです。

先ほどまでの実験でint型の場合は値が変更された場合に子が更新されることがわかりました。

ではこちらを連打した場合どういう挙動になるでしょうか?

親側のカウントだけ増え続ける?

やってみましょう。

0回目

image-13.png

1回目

image-14.png

2回目

image-15.png

3回目

image-16.png

どういうことでしょうか?
Listの中身を更新してないし、List自身も更新してないのに親→子へ伝播しています。

結論:親→子へのStateHasChangedの伝播はパラメータが更新された時に発動する。ただし参照型のプロパティは値の変更の有無を言わさず更新される

BlazorのStateHasChangedによる更新は子コンポーネントの[Parameter]つきプロパティによって左右されます。

まず全くないとき、StateHasChangedは親→子へ伝播しません。
次に、プリミティブ型(int,bool)の場合、値が変わった場合のみに子コンポーネントにStateHasChangedが伝播します。

最後に参照型の場合問答無用で伝播します。

最初のテストで記述があった、private List<string> list = new() { "apple" };は実は参照型のパラメータであれば何でも良くコンポーネントの更新を親→子へ伝えるためにセットされたものでした。

前提まとめ:Blazorの更新ルールについて

  1. StateHasChangedは親から子へ伝播する
  2. プリミティブ型の場合、Parameterの値が更新された場合に親から子にStateHasChangedが伝播する
  3. 参照型の場合、値の更新の有無にかかわらずStateHasChangedが伝播する
  4. EventCallbackが呼ばれると、同期処理なら処理終了時に、非同期処理なら最初のawaitと処理終了時のタイミングでイベントハンドラーのメソッドが記述された側(親コンポーネント)のStateHasChangedが自動で呼ばれる
  5. EventUtilを使うと4. の挙動を無効化できる

まとめ:前提を理解した上でどうするか?

親→子へのStateHasChangedの伝播という特性を理解したうえで、私が提唱するのはこちらです。

画面全体の更新から→部分更新へ 

image-1.png

試しに上記のようなSNSのようなヘッダーの検索欄について検討してみましょう。

このヘッダーはMainLayout.razorファイルに記述された想定です。
MainLayout.razorはコンポーネントの中でも画面全体に影響を与えます。
ここでStateHasChangedが呼ばれたら、画面上のすべてのコンポーネントへ親→子の法則で伝播していくわけです。

@Body ←ここに各ページコンポーネントが描画されるため

2025/12/11 追記:
投稿後に気が付いたのですが、MainLayoutでStateHasChangedが呼ばれてもページ側のコンポーネントは何もしなければ伝播しないことを確認しました。
訂正いたします。
MainLayoutのStateHasChangedが反応するパターンに関しては発見したため別途付録として添付します。

改善前

検索欄のオートコンプリート更新のため@oninputが使われています。


<header>

    シンプルSNS

    <input value="@SearchValue" @oninput="HandleInputSearchValue" />
</header>

@Body

@code {
    private string SearchValue { get; set; } = "";

    private void HandleInputSearchValue(ChangeEventArgs e) {
        SearchValue = e.Value as string ?? "";
        UpdateAutoComplete();
    }
}

改善後

入力欄とオートコンプリートをコンポーネントで囲みます。


    シンプルSNS

    <AutoCompleteSearch OnSearch="HandleSearch">

    ……以下省略


@code {
    private void HandleSearch(string value) {
        // 画面遷移
    }
}

コンポーネントに囲む事で、入力中のオートコンプリート更新のための@inputによる更新は子コンポーネント内で完結さます。

そして、@changeイベントの発動時や、Enterキーを押し実際に検索するときだけ親に伝播させることで、
基本は子コンポーネント(AutoCompleteSearch)側で更新、親を含めた更新が必要な時だけEventCallback経由で親側のStateHasChangedを発動させるということができます。

終わりに

Blazorの挙動について実験し、そこから子コンポーネントに処理を委譲する手法について提案しました。

ぜひ、他の手法があるなどあればコメント欄で教えていただけると嬉しいです。

補足:プリミティブ型によるレンダリングの回避を実践的に使えるか?

実験の中でプリミティブ型の場合、値が変わったときにのみ親→子のレンダリングが発生すると伝えました。

これを使ってパフォーマンスを改善することはできるでしょうか?

私は難しいと考えます。

理由は参照型を使わないというのが事実上不可能だからです。

RenderFragmentやコレクション型をパラメータにセットできないという制約はかなり厳しいです。

付録 MainLaytoutでStateHasChangedが伝播するパターンについて

記事投稿時点では勘違いをしていたのですが、MainLaytoutでは通常Parameter属性に値を代入できないためStateHasChangedが伝播しないことを確認しました。

例外ケースとして[CascadingParameter]を使ったときだけ反応したためそのケースについて掲載します。

実験:CascadingParameterでMainLayoutからページのコンポーネントへ値を渡す

MainLayout.razor
@inherits LayoutComponentBase
<div class="@(_renderCount % 2 == 0 ? "green" : "orange")">
    <CascadingValue Value="vlaue">
        @Body @*← MainLayoutよりCascadingValueで子コンポーネントに値を渡す *@
    </CascadingValue>
    <button @onclick="StateHasChanged">MainLayoutでCascadingValue</button>
    @_renderCount
</div>

@code {

    private List<string> vlaue { get; set; } = new List<string>() { "apple" };

    private int _renderCount = 0;

    protected override void OnAfterRender(bool firstRender)
    {
        Console.WriteLine($"{GetType().Name}: {++_renderCount}回目");
    }
    
}
親コンポーネント
<div class="@(_renderCount % 2 == 0 ? "green" : "orange")">
    <ChildComponent List="list" />
    <button @onclick="EventUtil.AsNonRenderingEventHandler(UpdateView)">親コンポーネント</button>
    @_renderCount
</div>

@code {

    [CascadingParameter]
    public List<string>? Cascading { get; set; }   // ← 親コンポーネントにCascadingParameterを記載

    private List<string> list = new() { "apple" };

    private void UpdateView()
    {
        StateHasChanged();
    }

    private int _renderCount = 0;

    protected override void OnAfterRender(bool firstRender)
    {
        Console.WriteLine($"{GetType().Name}: {++_renderCount}回目");
    }

}
子コンポーネント
<div class="@(_renderCount % 2 == 0 ? "green" : "orange")">
    <button @onclick="EventUtil.AsNonRenderingEventHandler(UpdateView)">子コンポーネント</button>
    @_renderCount
</div>

@code {

    [Parameter]
    public List<string> List { get; set; } 

    private void UpdateView()
    {
        StateHasChanged();
    }

    private int _renderCount = 0;

    protected override void OnAfterRender(bool firstRender)
    {
        Console.WriteLine($"{GetType().Name}: {++_renderCount}回目");
    }

}

初期状態
image.png

MainLaytoutのボタンを押す
image.png

全部が反応する

実験:CascadingParameterでMainLayoutからページのコンポーネントへ値を渡すかつ、親コンポーネントから子コンポーネントにパラメータを渡さないようにする

親コンポーネント
@using LessenCode.Components
@page "/"

<div class="@(_renderCount % 2 == 0 ? "green" : "orange")">
    <ChildComponent /> @* ← 子コンポーネントにパラメータを渡さないようにする(new!) *@
    <button @onclick="EventUtil.AsNonRenderingEventHandler(UpdateView)">親コンポーネント</button>
    @_renderCount
</div>

@code {

    [CascadingParameter]
    public List<string>? Cascading { get; set; }   // ← 親コンポーネントにCascadingParameterを記載したまま

    private List<string> list = new() { "apple" };

    private void UpdateView()
    {
        StateHasChanged();
    }

    private int _renderCount = 0;

    protected override void OnAfterRender(bool firstRender)
    {
        Console.WriteLine($"{GetType().Name}: {++_renderCount}回目");
    }

}

初期状態
image.png

MainLayoutのボタンを押す
image.png

パラメータを渡さなくなった子コンポーネントへの伝播は防がれている
実際にMainLayoutからStateHasChangedが伝播したのは[CascadingParameter]を書いた親コンポーネントだけ

実験:CascadingParameterでMainLayoutからページのコンポーネントへ値を渡すかつ、子コンポーネント側でCascadingParameterを受け取る。親は受け取らない

親コンポーネント
<div class="@(_renderCount % 2 == 0 ? "green" : "orange")">
    <ChildComponent /> @* ← 子コンポーネントにパラメータを渡さないようにする*@
    <button @onclick="EventUtil.AsNonRenderingEventHandler(UpdateView)">親コンポーネント</button>
    @_renderCount
</div>

@code {

    // [CascadingParameter]
    // public List<string>? Cascading { get; set; }    ← コメントアウト

    private List<string> list = new() { "apple" };

    private void UpdateView()
    {
        StateHasChanged();
    }

    private int _renderCount = 0;

    protected override void OnAfterRender(bool firstRender)
    {
        Console.WriteLine($"{GetType().Name}: {++_renderCount}回目");
    }

}
子コンポーネント
<div class="@(_renderCount % 2 == 0 ? "green" : "orange")">
    <button @onclick="EventUtil.AsNonRenderingEventHandler(UpdateView)">子コンポーネント</button>
    @_renderCount
</div>

@code {

    [CascadingParameter]
    public List<string>? Cascading { get; set; }  // ← 追加


    [Parameter]
    public List<string> List { get; set; }

    private void UpdateView()
    {
        StateHasChanged();
    }

    private int _renderCount = 0;

    protected override void OnAfterRender(bool firstRender)
    {
        Console.WriteLine($"{GetType().Name}: {++_renderCount}回目");
    }

}

初期状態

image.png

MainLayoutのボタンを押す

image.png

親をスキップして子コンポーネントがレンダリングされた

3
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?