.NET 8 の Blazor で静的 SSR と InteractveServer/WASM 間の状態渡し
.NET 8 の ASP.NET Core Blazor で SSR/InteractiveWebAssembly/InteractiveServer を 1 アプリでコンポーネント単位で切り替えて使うことが出来るようになりました。
さらにプリレンダリングが行われているので SEO 的にも優しい感じになっています。ここらへんの詳細については以前まとめた記事を書いたのでそちらを参照してください。
プリレンダリングでよくある問題
プリレンダリングが行われる時には、サーバー側でレンダリングされた HTML がクライアントに返されて、後から InteractiveServer
か InteractiveWebAssembly
の設定に応じてどちらかのモードが動き始めて、最終的にサーバーから返されたレンダリング結果を置き換えてページが対話操作可能な状態になります。
つまりレンダリングが静的 SSR で 1 回レンダリングされたあとに InteracitveServer
か InteractiveWebAssembly
で再レンダリングされることになります。
この時ページの表示をするために外部の API や DB などからデータを取得するような場合には、2 回目のレンダリングの時にもう一度データを取得することになります。このような場合には 1 回目のデータ取得時にデータをキャッシュしておいて 2 回目のレンダリング時にはキャッシュされたデータを使うようにすることが一般的です。InteractiveServer
モードの時には、静的 SSR も InteractiveServer
もサーバーサイドで動作するので、サーバーサイドでのキャッシュを使うことが出来るのでなんとなく頑張れば出来そうです。
しかし、静的 SSR と InteractiveWebAssembly
の場合にはサーバーサイドでキャッシュしてもブラウザ側では素直に参照できないのでちょっと困ります…。今回は、このような場合にどのようにデータをキャッシュするのかについて解説します。
今回解説する内容は公式の Microsoft Learn 上のドキュメントにも書かれています。以下のドキュメントの「プリレンダリングされた状態を保持する」のセクションに詳細が書かれています。
プリレンダリングされたページを作る
では早速やってみましょう。プリレンダリングされた状態を保持するには PersistentComponentState
を使います。このクラスは DI で受け取ることが出来るので @inject PersistentComponentState State
のようにページに組み込んで使います。Blazor Web App のプロジェクトテンプレートでプロジェクトを作成します。Interactive mode を Auto にして、Interactivity location を Per page/component にします。
今回は BlazorApp11
という名前でプロジェクトを作りました。BlazorApp11.Client
プロジェクトに以下のようなクラスを追加します。Pages/Counter.razor
でカウンターの初期値を生成するためのメソッドを追加します。
5 秒待ってからランダムな値を返すメソッドです。値だけではなく DateTimeOffset
で値が生成された時点の時間も返すようにしています。
namespace BlazorApp11.Client;
public class CounterValueProvider
{
public async ValueTask<CounterState> GetValueAsync()
{
await Task.Delay(5000);
return new (Random.Shared.Next(100), TimeProvider.System.GetUtcNow());
}
}
public record struct CounterState(int Value, DateTimeOffset CreatedTimestamp);
BlazorApp11
と BlazorApp11.Client
の両方のプロジェクトの Program.cs
に以下のように CounterValueProvider
を DI コンテナに登録します。
// サーバー側と WASM 側の両方に以下のコードを追加する
builder.Services.AddSingleton<CounterValueProvider>();
そして、Counter.razor
に以下のようなコードを追加します。
@page "/counter"
@rendermode InteractiveAuto
@inject CounterValueProvider CounterValueProvider
<PageTitle>Counter</PageTitle>
<h1>Counter</h1>
<p role="status">Current count: @currentCount</p>
<button class="btn btn-primary" @onclick="IncrementCount">Click me</button>
@code {
private CounterState currentCount;
protected override async Task OnInitializedAsync()
{
currentCount = await CounterValueProvider.GetValueAsync();
}
private void IncrementCount()
{
currentCount.Value++;
}
}
実行して Counter ページに遷移すると、5 秒程度待つと以下のように表示されます。
さらに、5 秒待つと以下のようにカウンターの値が変わってタイムスタンプも 5 秒ずれていることが確認できます。厳密には途中で InteractiveServer
に切り替わったときに OnInitialized
が呼ばれて 0
にリセットされた値が表示されます。そこから OnInitializedAsync
が呼ばれて 5 秒待ってからランダムな値が表示されます。
状態を保存されるようにしよう
Counter.razor
に @inject PersistentComponentState State
を追加してコードを追加します。めっちゃ長くなる…。
@page "/counter"
@rendermode InteractiveAuto
@implements IDisposable
@inject PersistentComponentState State
@inject CounterValueProvider CounterValueProvider
<PageTitle>Counter</PageTitle>
<h1>Counter</h1>
<p role="status">Current count: @currentCount</p>
<button class="btn btn-primary" @onclick="IncrementCount">Click me</button>
@code {
private CounterState currentCount;
private PersistingComponentStateSubscription subscription;
protected override void OnInitialized()
{
// 永続化が必要な時に呼ばれるコールバックを登録する
subscription = State.RegisterOnPersisting(OnPersistingAsync);
}
protected override async Task OnInitializedAsync()
{
if (State.TryTakeFromJson<CounterState>("counter", out var value))
{
// キャッシュにある場合にはそれを使う
currentCount = value;
}
else
{
// キャッシュに無い場合は CounterValueProvider から値を取得する
currentCount = await CounterValueProvider.GetValueAsync();
}
}
private Task OnPersistingAsync()
{
// 保存が必要になったタイミングで現在の状態を取得する。
State.PersistAsJson("counter", currentCount);
return Task.CompletedTask;
}
private void IncrementCount()
{
currentCount.Value++;
}
public void Dispose()
{
// コールバックの登録を削除
subscription.Dispose();
}
}
基本的に OnInitialized
で PersistentComponentState
の RegisterOnPersisting
メソッドを使って永続化が必要な時に呼ばれるコールバックを登録します。OnInitializedAsync
で PersistentComponentState
の TryTakeFromJson
メソッドを使ってキャッシュにある場合にはそれを使い、無い場合は CounterValueProvider
から値を取得します。OnPersistingAsync
で永続化が必要になったタイミングでデータを保存します。そして最後に Dispose
でコールバックの登録を削除します。
こうすると、InteractiveWebAssembly
に切り替わった時にもキャッシュされたデータを使うことが出来るので、2 回目のレンダリング時にもう一度データを取得することが無くなります。良き!!
PersistentComponentState の注意点
ちなみに PersistentComponentState
から TryTakeFromJson
で一度値を取得すると、その値はキャッシュから削除されます。気を付けましょう。
もうちょっと応用的な使い方
この例では PersistentComponentState
をページに埋め込みましたが、それ以外にもクラス内で使うこともできます。普通のクラス内で PersistentComponentState
を使うときには RegisterOnPersisting
メソッドの第二引数に RenderMode.InteractiveServer
などのように保存したデータを渡したいレンダリングモードを指定しないといけません。
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
namespace BlazorApp11.Client;
public class CounterValueProvider : IDisposable
{
private readonly PersistentComponentState _state;
private readonly PersistingComponentStateSubscription[] _subscriptions;
private CounterState _counterState;
public CounterValueProvider(PersistentComponentState state)
{
_state = state;
// InteractiveWebAssembly 用と InteractiveServer 用の 2 つを登録する
_subscriptions = [
_state.RegisterOnPersisting(OnPersistingAsync, RenderMode.InteractiveWebAssembly),
_state.RegisterOnPersisting(OnPersistingAsync, RenderMode.InteractiveServer),
];
}
public void Dispose()
{
foreach (var d in _subscriptions)
{
d.Dispose();
}
}
public async ValueTask<CounterState> GetValueAsync()
{
if (_state.TryTakeFromJson<CounterState>(nameof(CounterValueProvider), out var value))
{
_counterState = value;
return value;
}
await Task.Delay(5000);
_counterState = new (Random.Shared.Next(100), TimeProvider.System.GetUtcNow());
return _counterState;
}
private Task OnPersistingAsync()
{
_state.PersistAsJson(nameof(CounterValueProvider), _counterState);
return Task.CompletedTask;
}
}
public record struct CounterState(int Value, DateTimeOffset CreatedTimestamp);
これを使うとコンポーネント側はこうなります。
@page "/counter"
@rendermode InteractiveAuto
@inject CounterValueProvider CounterValueProvider
<PageTitle>Counter</PageTitle>
<h1>Counter</h1>
<p role="status">Current count: @currentCount</p>
<button class="btn btn-primary" @onclick="IncrementCount">Click me</button>
@code {
private CounterState currentCount;
protected override async Task OnInitializedAsync()
{
currentCount = await CounterValueProvider.GetValueAsync();
}
private void IncrementCount()
{
currentCount.Value++;
}
}
そして、CounterValueProvider
は Scoped として DI コンテナに登録しないといけない(多分 PersistentComponentState
が Scoped)ので Program.cs
に以下のように変更します。
// サーバーも WASM も両方で以下のように変更する
builder.Services.AddScoped<CounterValueProvider>();
スッキリ!といっても CounterValueProvider
がデータの取得とキャッシュの役割を持ってるのが気に入らないので、このようなクラスを用意しました。
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
using System.Diagnostics.CodeAnalysis;
namespace BlazorApp11.Client;
public class State<T> : IDisposable
{
private readonly PersistentComponentState _state;
private readonly PersistingComponentStateSubscription[] _subscriptions;
private T? _value;
public State(PersistentComponentState state)
{
_state = state;
if (_state.TryTakeFromJson<T>(nameof(State<T>), out var value))
{
_value = value;
}
_subscriptions = [
_state.RegisterOnPersisting(OnPersistingAsync, RenderMode.InteractiveWebAssembly),
_state.RegisterOnPersisting(OnPersistingAsync, RenderMode.InteractiveServer),
];
}
public void Dispose()
{
foreach (var d in _subscriptions)
{
d.Dispose();
}
}
public T? Value
{
get => _value;
set => _value = value;
}
[MemberNotNull(nameof(Value))]
public void Verify()
{
_ = _value ?? throw new InvalidOperationException();
}
private Task OnPersistingAsync()
{
_state.PersistAsJson(nameof(State<T>), _value);
return Task.CompletedTask;
}
}
CounterValueProvider
は初期バージョンに戻します。CounterState
は参照型に変更しました。State<T>
で null
かどうかのチェックで初期化済みかどうかを確認できるようにしました。
namespace BlazorApp11.Client;
public class CounterValueProvider
{
public async ValueTask<CounterState> GetValueAsync()
{
await Task.Delay(5000);
return new(Random.Shared.Next(100), TimeProvider.System.GetUtcNow());
}
}
public record CounterState(int Value, DateTimeOffset CreatedTimestamp);
この 2 つを使って Counter.razor
を書き換えると以下のようになります。今度こそスッキリ!
@page "/counter"
@rendermode InteractiveAuto
@inject State<CounterState> State
@inject CounterValueProvider CounterValueProvider
<PageTitle>Counter</PageTitle>
<h1>Counter</h1>
<p role="status">Current count: @State.Value</p>
<button class="btn btn-primary" @onclick="IncrementCount">Click me</button>
@code {
protected override async Task OnInitializedAsync()
{
if (State.Value is null)
{
State.Value = await CounterValueProvider.GetValueAsync();
}
}
private void IncrementCount()
{
// 値が入っているはずなので Verify してからインクリメントする
State.Verify();
// インクリメントがだるい…
State.Value = State.Value with { Value = State.Value.Value + 1 };
}
}
まとめ
ということで PersistentComponentState
を使ってプリレンダリングされたページの状態を保持する方法について解説しました。そのまま使うのがメンドクサイので今回作ったような State<T>
型みたいなものを使うとスッキリ書けるのでお勧めです。
ただ、ここまでやっても CounterValueProvider
クラスはサーバーとクライアントの両方で動くようにしないといけないので、その辺りの対応は必要です。Web API を呼んだりするものはまだなんとかなるけど、DB をアクセスしてるととても大変…。
Discussion