Blazor のコンポーネントのステートについて
Created at:
これは Blazor Advent Calendar 2020 の25日目のエントリーです。
Blazor はお手軽に Single Page Application を作れるのですがコンポーネントのライフサイクル、ステート関連について知らないと若干不思議な仕組みで動いているように見えます。
例えばプロジェクトテンプレートの Counter は @onclick="IncrementCount"
でプライベート変数 currentCount
をインクリメントするとページのカウントがアップしますが、これは初見ではなかなか不思議な挙動です。Blazor は currentCount
の変更をどうやって知ったのか?としばらく不思議に思っていました。そういったこともライフサイクルを知ることで理解できます。
基本的には ASP.NET Core Blazor ライフサイクル というドキュメントに書いてあるのですがこれはそれを補完する目的のエントリーです。
基本的な流れ
Blazor コンポーネントはコンポーネントのステート(状態)が変更されるたびに再レンダリングを行うという流れが基本となります。これ自体は他のフレームワークと大きく変わるところではないと思います。
- 初期化
- レンダリング
- ステート変更
- 再レンダリング
- ステート変更
- 再レンダリング
- …
これらの多くのライフサイクルに関連するものは Microsoft.AspNetCore.Components.ComponentBase クラス に実装されています。
初期化/初回レンダリング
コンポーネントの初回のレンダリング、つまり初めてページが表示されたときの処理の流れは次のようになっています。
- コンストラクター
- SetParametersAsync
- OnInitialized
- OnInitializedAsync
- OnParametersSet
- OnParametersSetAsync
- BuildRenderTree
- (ここに子コンポーネントの一式が入る)
- OnAfterRender
- OnAfterRenderAsync
初めに SetParametersAsync が呼び出され、初期化とパラメータのセットを行います。この OnInitializedAsync (初期化), OnParameterSetAsync (パラメータセット) では非同期処理を行うことができますが、その場合は Task の完了を待たずにレンダリングが行われて完了次第再レンダリングが行われます。
つまり非同期初期化の場合、コンポーネントの HTML をレンダリングが行われるタイミングではまだ値がそろっていない場合がある点には注意が必要です。
private NanikaObject _nanika;
protected override async Task OnInitializedAsync()
{
await Task.Delay(1000);
_nanika = new NanikaObject();
await base.OnInitializedAsync();
}
...
@* OnInitializedAsync が終わるまで _nanika は null *@
<h1>@_nanika.Description</h1>
BuildRenderTree メソッドは Blazor 上のドキュメントツリー構造(仮想DOM的なもの)を構築するものです。通常は Razor Component (.razor) ファイルから自動生成され、手でツリーを作るようなカスタムコンポーネントを作らない限りは触ることはありません。中身が気になる場合には obj ディレクトリの中を覗いてみると生成物を確認できます。
なお Server-side Blazor に関してはレンダリングモードが ServerPrerendered の場合、 SetParametersAsync + BuildRenderTree が2回呼び出されます。1回目はプリレンダリングのための実行です。またプリレンダリングはサーバーサイドで完成系を返す都合、SetParametersAsync の完了を待ってからのレンダリングとなります。
OnInitialized と OnParametersSet の違い
OnInitialized{Async} と OnParametersSet{Async} の違いは、OnInitialized がコンポーネントの初期化であるのに対して、OnParametersSet はプロパティの変更です。
つまり [Parameter]
として受けている値が変わったときに呼び出されるもので React で言うなら props の変更のような感じでしょうか。
例えば次のようなコンポーネントを用意して…
<p>@Count</p>
@code {
[Parameter]
public int Count { get; set; }
protected override Task OnParametersSetAsync()
{
Console.WriteLine($"Count={Count}");
return base.OnParametersSetAsync();
}
}
Counter.razor にぶら下げてみます。
<Nantoka Count="currentCount" />
これでカウンターを更新すると OnParametersSetAsync が呼び出されることを確認できます。
ステート(状態)と再レンダリング
そもそも Blazor におけるステートとは何かステートという特別な入れ物があるわけでもなく、あるのは何らかの何らかステートが変更されたということを通知する仕組みです。
データそのものはプライベート変数であったり、Parameter であったりするかもしれませんが Blazor としては「何か状態が変わったことを知る」ということが重要になります。
Blazor はステートの変更通知を受けると、コンポーネントの再レンダリングを行い、表示を更新します。
ステート変更通知は何によって引き起こされるのか
ステートの変更通知はどのようなタイミングで発生するのかですが、おおまかに下記の3パターンで発生します。
- StateHasChanged メソッドを明示的に呼び出したとき
- イベント発火時 (
@bind
や@onclick
とか) - Parameter が変わったとき
3パターンと書きましたが2つ目と3つ目は暗黙的に StateHasChanged を呼び出しているというのが実際のところです。
StateHasChanged を呼び出したとき
StateHasChanged はステートが変更されたことを通知するメソッドです。このメソッドを呼び出すと再レンダリング候補としてマークされます。何が起こるのかは後ほど少し説明します。
多くのケースでは StateHasChanged を明示的に呼び出す必要はありませんが、ロジック側から表示を更新したいケースでは呼び出しを必要とします。
例えばサーバーからのデータ受信やタイマー処理といったユーザー操作によるイベントではないが表示を更新するようなパターンです。この場合は自らステートが変更されたので再レンダリングしてほしいという意思を伝えるために StateHasChanged を呼び出す必要があります。
イベント発火時 (@bind や @onclick とか)
冒頭でプロジェクトテンプレートの Counter でクリックしてプライベート変数を更新するだけでページの表示が変わって不思議、と書きましたがそのような挙動になる理由はここにあります。
Blazor の ComponentBase ではイベントハンドラーの実行時に StateHasChanged を呼び出しています。
https://github.com/dotnet/aspnetcore/blob/v5.0.0/src/Components/Components/src/ComponentBase.cs#L313
Task IHandleEvent.HandleEventAsync(EventCallbackWorkItem callback, object? arg)
{
var task = callback.InvokeAsync(arg);
var shouldAwaitTask = task.Status != TaskStatus.RanToCompletion &&
task.Status != TaskStatus.Canceled;
// After each event, we synchronously re-render (unless !ShouldRender())
// This just saves the developer the trouble of putting "StateHasChanged();"
// at the end of every event callback.
StateHasChanged();
return shouldAwaitTask ?
CallStateHasChangedOnAsyncCompletion(task) :
Task.CompletedTask;
}
これにより「クリックイベントが発生する」→「プライベート変数を更新する」→「StateHasChanged を呼び出す」→「再レンダリング」→「値が表示に反映される」という流れになっているというわけです。
変数を監視していたり、プロキシになっていたりするわけではなくイベントが発生したら StateHasChanged (=ステート変更通知)が呼び出されているだけという単純な仕組みです。
Parameter が変わったとき
コンポーネントの Parameter に渡される値が変化した場合もステートの変更がありと認識されます。
これも ComponentBase.SetParametersAsync の中で StateHasChanged を呼びだしているので、値の変更があると SetParametersAsync が呼び出されることで結果的にステートの変更があった扱いになります。
https://github.com/dotnet/aspnetcore/blob/v5.0.0/src/Components/Components/src/ComponentBase.cs#L277
private Task CallOnParametersSetAsync()
{
OnParametersSet();
var task = OnParametersSetAsync();
// If no async work is to be performed, i.e. the task has already ran to completion
// or was canceled by the time we got to inspect it, avoid going async and re-invoking
// StateHasChanged at the culmination of the async work.
var shouldAwaitTask = task.Status != TaskStatus.RanToCompletion &&
task.Status != TaskStatus.Canceled;
// We always call StateHasChanged here as we want to trigger a rerender after OnParametersSet and
// the synchronous part of OnParametersSetAsync has run.
StateHasChanged();
return shouldAwaitTask ?
CallStateHasChangedOnAsyncCompletion(task) :
Task.CompletedTask;
}
ステートが変更されると (StateHasChanged が呼ばれると) 何が起こるのか
StateHasChanged メソッドを呼ぶと次のような流れで処理が実行されます。
- ShouldRender
- BuildRenderTree
- ステートの変更が子コンポーネントのParameterも変更した場合
- SetParametersAsync
- OnParameterSet
- OnParameterSetAsync
- ShouldRender
- BuildRenderTree
- SetParametersAsync
- OnAfterRender (firstRender = false)
- OnAfterRenderAsync (firstRender = false)
初回と似ていますが異なるのは初期化周りのメソッド(OnInitialized)が呼び出されないことと ShouldRender が呼ばれることです。
ShouldRender メソッドはレンダリングする必要があるかどうかを返すことができるものです。このメソッドのデフォルト実装は常に true
を返しますが、カスタムコンポーネントは再レンダリングを抑える目的でオーバーライドできます。
StateHasChanged の詳細
StateHasChanged の実装は ComponentBase クラスにあり、protected として公開されています。
https://github.com/dotnet/aspnetcore/blob/v5.0.0/src/Components/Components/src/ComponentBase.cs#L100
このメソッド自体は比較的シンプルでやっていることは次のようになっています。
- 未レンダリング状態または ShouldRender が true かどうかチェック
- RenderHandle (のキュー) に RenderFragment を登録する
- RenderFragment は BuildRenderTree を呼び出す
先ほどの流れで ShouldRender の後に BuildRenderTree が呼び出されると書いていましたが、実際は一度レンダラーのキューに詰めてから BuildRenderTree が呼び出されます。
まとめ
Blazor のライフサイクルやレンダリングでは StateHasChanged が重要な役割となります。
しかしイベントハンドラーのような暗黙的に呼び出されているケースもあり、Blazor の仕組みをあまり知らずに使っていても画面が更新されるので思わぬところでハマる可能性もあります。そういった罠を踏む前にある程度流れを抑えておくことをお勧めします。