Blazor の StreamRendering の動作を調べてみた
はじめに
.NET 8 で ASP.NET Core Blazor がかなり強化されます。
今までは SignalR でサーバーと繋いだ Blazor Server や WebAssembly 上で動かす Blazor WebAssembly がありましたが .NET 8 ではサーバー サイド レンダリング (SSR) に対応しました。さらにストリーム レンダリングという画面表示のためのデータの取得に対して時間がかかるようなときには、一度データとかが無い状態でレンダリングが行われて、データが取得できたらそのデータを使って再度レンダリングが行われるという機能が追加されました。
SSR を使った場合は Blazor Server や WASM のようにユーザーとの対話操作を行うことができません。
フォームを配置することで、ユーザーがデータを入力してボタンを押すことでサーバーサイドで処理をすることは出来ます。普通の古き良き Web アプリケーションと同じですね。従来通り、SPA みたいにリッチなユーザーとの対話操作をしたい場合は Blazor Server や WASM をコンポーネント単位で有効化することが出来るので、そちらを使うようなイメージです。
確認に使ったコード
Blazor Web アプリのプロジェクトで新規作成をして Components/Pages/Home.razor
に以下のようなコードを書いて確認しました。
このコードの @attribute [StreamRendering(true)]
の部分を書き換えてストリーム レンダリングのオンオフを切り替えて確認しています。
@page "/"
@inject ILogger<Home> Logger
@inject IHttpContextAccessor HttpContextAccessor
@implements IDisposable
@attribute [StreamRendering(true)]
<PageTitle>Home</PageTitle>
<h1>Hello, world!</h1>
Welcome to your new app.
<form method="post" @formname="person" @onsubmit="OnSubmit">
<AntiforgeryToken />
<input type="hidden" name="Count" @bind="Count" />
<p>Count: @Count</p>
<input type="submit" value="インクリメント" class="btn btn-primary" />
</form>
<ul>
@foreach (var item in _lifecycleLogs)
{
<li>@item</li>
}
</ul>
@code {
[SupplyParameterFromForm]
public int Count { get; set; } = 0;
private readonly List<string> _lifecycleLogs = [];
private async Task OnSubmit()
{
Logger.LogInformation(nameof(OnSubmit));
Logger.LogInformation("HTTP Method: {httpMethod}", HttpContextAccessor.HttpContext?.Request.Method);
_lifecycleLogs.Add("OnSubmit await 前");
await Task.Delay(10000);
_lifecycleLogs.Add("OnSubmit await 後");
Count++;
}
protected override void OnInitialized()
{
Logger.LogInformation(nameof(OnInitialized));
Logger.LogInformation("HTTP Method: {httpMethod}", HttpContextAccessor.HttpContext?.Request.Method);
_lifecycleLogs.Add("OnInitialized");
base.OnInitialized();
}
protected override async Task OnInitializedAsync()
{
Logger.LogInformation(nameof(OnInitializedAsync));
Logger.LogInformation("HTTP Method: {httpMethod}", HttpContextAccessor.HttpContext?.Request.Method);
_lifecycleLogs.Add("OnInitializedAsync await 前");
await Task.Delay(10000);
_lifecycleLogs.Add("OnInitializedAsync await 後");
await base.OnInitializedAsync();
}
protected override void OnParametersSet()
{
Logger.LogInformation(nameof(OnParametersSet));
Logger.LogInformation("HTTP Method: {httpMethod}", HttpContextAccessor.HttpContext?.Request.Method);
_lifecycleLogs.Add("OnParametersSet");
base.OnParametersSet();
}
protected override async Task OnParametersSetAsync()
{
Logger.LogInformation(nameof(OnParametersSetAsync));
Logger.LogInformation("HTTP Method: {httpMethod}", HttpContextAccessor.HttpContext?.Request.Method);
_lifecycleLogs.Add("OnParametersSetAsync");
await base.OnParametersSetAsync();
}
protected override void OnAfterRender(bool firstRender)
{
Logger.LogInformation(nameof(OnAfterRender));
Logger.LogInformation("HTTP Method: {httpMethod}", HttpContextAccessor.HttpContext?.Request.Method);
_lifecycleLogs.Add("OnAfterRender");
base.OnAfterRender(firstRender);
}
protected override Task OnAfterRenderAsync(bool firstRender)
{
Logger.LogInformation(nameof(OnAfterRenderAsync));
Logger.LogInformation("HTTP Method: {httpMethod}", HttpContextAccessor.HttpContext?.Request.Method);
_lifecycleLogs.Add("OnAfterRenderAsync");
return base.OnAfterRenderAsync(firstRender);
}
public override Task SetParametersAsync(ParameterView parameters)
{
Logger.LogInformation(nameof(SetParametersAsync));
Logger.LogInformation("HTTP Method: {httpMethod}", HttpContextAccessor.HttpContext?.Request.Method);
_lifecycleLogs.Add("SetParametersAsync");
return base.SetParametersAsync(parameters);
}
protected override bool ShouldRender()
{
Logger.LogInformation(nameof(ShouldRender));
Logger.LogInformation("HTTP Method: {httpMethod}", HttpContextAccessor.HttpContext?.Request.Method);
_lifecycleLogs.Add("ShouldRender");
return base.ShouldRender();
}
void IDisposable.Dispose()
{
Logger.LogInformation(nameof(IDisposable.Dispose));
Logger.LogInformation("HTTP Method: {httpMethod}", HttpContextAccessor.HttpContext?.Request.Method);
}
}
内部で IHttpContextAccessor
を使っているので Program.cs
に以下の 1 行も追加しています。
builder.Services.AddHttpContextAccessor();
SSR/ストリーム レンダリングの時のコンポーネントのライフサイクル
そこで気になったのが SSR/ストリーム レンダリング時のコンポーネントのライフサイクルです。基本的に Blazor Server や WASM の時は以下のような順番で呼び出されます。
Async
メソッドで非同期処理が呼ばれたときには、厳密には同期処理のほうのメソッドが呼ばれたりと細かなところでは違いがありますが、大まかには以下のような順番で呼ばれます。
- SetParametersAsync
- OnInitialized
- OnInitializedAsync
- OnParameterSet
- OnParameterSetAsync
- ShouldRender
- OnAfterRender
- OnAfterRenderAsync
- Dispose/DisposeAsync
ストリーム レンダリング時のライフサイクル
SSR でストリーム レンダリングをオンにしたページで実行すると以下のような実行結果になりました。
- SetParametersAsync
- OnInitialized
- OnInitializedAsync
- OnParameterSet
- OnParameterSetAsync
- Dispose/DisposeAsync
OnAfterRender
系のメソッドが呼ばれないところが大きな違いになります。ストリーム レンダリングで OnInitializedAsync
メソッドで非同期処理を行うと以下のような結果になります。ポイントとしては ShouldRender
が増えている所になります。
これは OnInitializedAsync
のなかで await
が呼ばれるところまでの状態を使って一度レンダリングが行われて、その後、一連の処理が終わった後に再レンダリングが必要かどうかが ShouldRender
で確認されて true
を返した場合に再レンダリングが行われるという仕組みになっています。
- SetParametersAsync
- OnInitialized
- OnInitializedAsync ★ここの
await
で待機をした時点の状態で一度レンダリングされる★ - OnParameterSet
- OnParameterSetAsync
- ShouldRender
- Dispose/DisposeAsync
因みに ShouldRender
のデフォルトの挙動は true
を返すので何も考えずに OnInitializedAsync
で DB にアクセスしてデータを取りにいくようなコードを書くと、DB アクセス前の状態で一度画面が表示されて、DB アクセスが終わってデータが入った状態で画面が再レンダリングされるという一般的なアプリケーションにとって好ましい動きをします。
一応 SetParametersAsync
, OnParameterSetAsync
でも DB アクセスのような非同期処理を書くことは出来ますが本来的な使い方ではないので動きが怪しいためやらない方が良いです。
画面表示は以下のようになりました。
最初のレンダリング時
最終的なレンダリング結果
ストリームレンダリングをオフにした時のライフサイクル
オフにしたときも、基本的にライフサイクルは同じです。ただ画面に表示されるまでのタイミングが違うだけです。
オフのときは全ての処理が終わった時に画面が表示されます。面白いのは OnInitializedAsync
で非同期処理を行い ShouldRender
で false
を返したケースです。
当然と言えば当然なのですが OnInitializedAsync
で非同期処理を await
する直前の状態でレンダリングされたものが表示されます。
個人的には、ちょっと面白かったです。
ストリームレンダリングがオンのときのフォームの POST 時のライフサイクル
次に画面に form
を置いて POST したときのライフサイクルを見てみます。この時の最初のレンダリングタイミングは、OnSubmit
の中で最初に await
をしたところになります。そのため OnInitializedAsync
で非同期処理で時間がかかっていると最初のレンダリングがとても遅くなってしまいます。
実際にこんな結果になりました。
- SetParametersAsync
- OnInitialized
- OnInitializedAsync
- OnParameterSet
- OnParameterSetAsync
- ShouldRender
- OnSubmit ★ここの
await
で待機をした時点の状態で一度レンダリングされる★ - ShouldRender
- Dispose/DisposeAsync
GET リクエストの時と同じ流れを辿りつつ、最初のレンダリングタイミングは OnSubmit
で await
をしたところになります。そのためこのプログラムでは OnInitializedAsync
で 10 秒待機をしているので画面の更新に凄く時間がかかります。
実際の画面の表示は以下のようになりました。
初回レンダリング時
この表示になるまで OnInitializedAsync
の完了をまつので 10 秒かかります。
2 回目のレンダリング時
さらに OnSubmit
で 10 秒待機をしているので、2 回目のレンダリングも 10 秒かかる。
ストリーム レンダリング時の処理の書き方
例えば検索画面をストリーム レンダリングで書く場合には OnInitializedAsync
で初回表示のタイミングで出しておきたいデータの取得を行い、検索ボタンが押されたときのような POST の処理では OnInitializedAsync
では何も行わずに OnSubmit
などのフォームのイベントでデータの取得を行うようにするとストリーム レンダリングの恩恵を受けた画面を作ることが出来ると思います。
このサンプルでは IHttpContextAccessor
を使っていますが、このコードのように HttpContext
の Request
プロパティの Method
プロパティが GET
のケースと POST
のケースで分岐が出来ます。この生のコードを各ページに書くのは怠いので1枚くらい何かラップするクラスを作ることになると思います。
例えばこんな感じですかね。
public interface IRenderingStatus
{
bool IsPostBack { get; }
}
public class RenderingStatus : IRenderingStatus
{
private readonly IHttpContextAccessor _httpContextAccessor;
public RenderingStatus(IHttpContextAccessor httpContextAccessor)
{
_httpContextAccessor = httpContextAccessor;
}
public bool IsPostBack => _httpContextAccessor.HttpContext?.Request.Method == "POST";
}
これを Program.cs
を以下のように修正して DI に登録します。
builder.Services.AddScoped<IRenderingStatus, RenderingStatus>();
そうすると、以下のような感じで初回のみ OnInitializedAsync
でデータ取得などが出来ます。
protected override async Task OnInitializedAsync()
{
if (RenderingStatus.IsPostBack is false)
{
// 初回表示のみの処理
}
}
ストリームレンダリングを使った時のコンポーネントのライフサイクルはリクエスト単位なので、POST の時には前回取得したデータはコンポーネントに残っていないので OnSubmit
などできちんと表示に必要なデータを準備する必要があるので、その点だけは注意してください。
まとめ
ということで SSR やストリーム レンダリングの時のコンポーネントのライフサイクルを見てみました。
Blazor Server や WASM とは結構違うのでびっくりですね。大きな違いとしては OnAfterRender
が呼ばれないという点になります。また GET のときと POST の時ではストリームレンダリングの初回レンダリングのタイミングが違います。GET の時は OnInitializedAsync
内で await
したタイミングで、POST の時は OnSubmit
などのイベントハンドラーの中で await
したタイミングになります。これはちょっとハマりポイントかも。
Discussion
いつも勉強させてもらっています。
作成中の Blazorアプリ で Reload すると ERROR(Could not find 'cultureInfo.get')となり困っています。
親から子へ遷移しこのときカスケードと URLパラメータで情報を渡し、この情報で HttpClient.GetAsync
を利用し json を読取り子画面の初期表示を行っています。この表示までは問題ないのですがブラウザで
Reload を行うと ERROR(Could not find 'cultureInfo.get') なり Shift+f5 も効かない状態になります。
初期表示までのライフサイクルのトレースは以下です。
ID:20 2024/07/26 18:06:22 - diploma - SetParametersAsync CascadingParameter:
ID:20 2024/07/26 18:06:22 - diploma - OnInitialized CascadingParameter:
ID:20 2024/07/26 18:06:22 - diploma - OnInitializedAsync CascadingParameter:
ID:20 2024/07/26 18:06:22 - diploma - OnParametersSet CascadingParameter:
ID:20 2024/07/26 18:06:22 - diploma - OnParametersSetAsync CascadingParameter:
ID:20 2024/07/26 18:06:22 - diploma - Dispose CascadingParameter:
ID:20 2024/07/26 18:06:22 - diploma - SetParametersAsync CascadingParameter:
ID:20 2024/07/26 18:06:22 - diploma - OnInitialized CascadingParameter:C3
ID:20 2024/07/26 18:06:22 - diploma - OnInitializedAsync CascadingParameter:C3
ID:20 2024/07/26 18:06:22 - diploma - OnParametersSet CascadingParameter:C3
ID:20 2024/07/26 18:06:22 - diploma - OnParametersSetAsync CascadingParameter:C3
ID:22 2024/07/26 18:06:22 - diploma - OnAfterRender CascadingParameter:C3
ID:22 2024/07/26 18:06:22 - diploma - OnAfterRenderAsync CascadingParameter:C3
ID:10 2024/07/26 18:06:22 - diploma - ShouldRender CascadingParameter:C3
ID:20 2024/07/26 18:06:22 - diploma - OnAfterRender CascadingParameter:C3
ID:20 2024/07/26 18:06:22 - diploma - OnAfterRenderAsync CascadingParameter:C3
ID:7 2024/07/26 18:06:22 - diploma - ShouldRender CascadingParameter:C3
CascadingParameter: の後ろの "C3" は親から渡されたカスケードパラメータです。
8件目の OnInitialized で初めて表示されます。ちなみにコレより前でデータセットを行うと
Reload 同様の ERROR(Could not find 'cultureInfo.get') が発生します。
8件目の OnInitialized 以降でデータをセットするととりあえず表示は問題ありませんが
ブラウザで Reload すると ERROR(Could not find 'cultureInfo.get') が発生します。
Reload後のトレースは以下です。
その他
・プレレンダリングを App.razor で OFF にしましたが、同じ画面が上下に表示されてしまいました(親も子も)。
・StreamRendering を ON/OFF にしても変わりませんでした。
・子の レンダーモードは InteractiveAuto です。親は InteractiveServer です。
質問
・プレレンダリングを OFF で上下に表示される時点でなんらかの間違いがあると思うのですがこの情報だけでなにかわかりますか。
・子で Reload するときの ERROR はなにが原因でしょうか。
・初期のデータをセットするのはどこで行うのが正しいのでしょうか。現在 OnInitializedAsync で行っていますが 途中まで一度
表示されその後最後までが表示される残念な状態です。
以上、よろしくお願い致します。
//// プロジェクト環境 /////
Blazor Web App
Interactive render mode: Auto (Server and WebAssembly)
Interactive location: Per page/component
//// 開発環境 /////
Microsoft Visual Studio Community 2022 (64 ビット) - Current
Version 17.10.4
エディション Windows 10 Pro
バージョン 22H2
インストール日 2021/04/28
OS ビルド 19045.4651
エクスペリエンス Windows Feature Experience Pack 1000.19060.1000.0
サポート環境ではないので無視頂いても問題ありません。今後も楽しみにしています。
すいません。
この情報では何とも言えません。