💬

.NET 8 の Blazor で静的 SSR と InteractveServer/WASM 間の状態渡し

2024/02/08に公開

.NET 8 の ASP.NET Core Blazor で SSR/InteractiveWebAssembly/InteractiveServer を 1 アプリでコンポーネント単位で切り替えて使うことが出来るようになりました。
さらにプリレンダリングが行われているので SEO 的にも優しい感じになっています。ここらへんの詳細については以前まとめた記事を書いたのでそちらを参照してください。

https://zenn.dev/microsoft/articles/aspnetcore-blazor-dotnet8-overview

https://zenn.dev/microsoft/articles/blazor-dotnet8-all

プリレンダリングでよくある問題

プリレンダリングが行われる時には、サーバー側でレンダリングされた HTML がクライアントに返されて、後から InteractiveServerInteractiveWebAssembly の設定に応じてどちらかのモードが動き始めて、最終的にサーバーから返されたレンダリング結果を置き換えてページが対話操作可能な状態になります。

つまりレンダリングが静的 SSR で 1 回レンダリングされたあとに InteracitveServerInteractiveWebAssembly で再レンダリングされることになります。
この時ページの表示をするために外部の API や DB などからデータを取得するような場合には、2 回目のレンダリングの時にもう一度データを取得することになります。このような場合には 1 回目のデータ取得時にデータをキャッシュしておいて 2 回目のレンダリング時にはキャッシュされたデータを使うようにすることが一般的です。InteractiveServer モードの時には、静的 SSR も InteractiveServer もサーバーサイドで動作するので、サーバーサイドでのキャッシュを使うことが出来るのでなんとなく頑張れば出来そうです。

しかし、静的 SSR と InteractiveWebAssembly の場合にはサーバーサイドでキャッシュしてもブラウザ側では素直に参照できないのでちょっと困ります…。今回は、このような場合にどのようにデータをキャッシュするのかについて解説します。

今回解説する内容は公式の Microsoft Learn 上のドキュメントにも書かれています。以下のドキュメントの「プリレンダリングされた状態を保持する」のセクションに詳細が書かれています。

https://learn.microsoft.com/ja-jp/aspnet/core/blazor/components/prerender?view=aspnetcore-8.0#persist-prerendered-state

プリレンダリングされたページを作る

では早速やってみましょう。プリレンダリングされた状態を保持するには 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);

BlazorApp11BlazorApp11.Client の両方のプロジェクトの Program.cs に以下のように CounterValueProvider を DI コンテナに登録します。

Program.cs
// サーバー側と WASM 側の両方に以下のコードを追加する
builder.Services.AddSingleton<CounterValueProvider>();

そして、Counter.razor に以下のようなコードを追加します。

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 を追加してコードを追加します。めっちゃ長くなる…。

Counter.razor
@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();
    }
}

基本的に OnInitializedPersistentComponentStateRegisterOnPersisting メソッドを使って永続化が必要な時に呼ばれるコールバックを登録します。OnInitializedAsyncPersistentComponentStateTryTakeFromJson メソッドを使ってキャッシュにある場合にはそれを使い、無い場合は CounterValueProvider から値を取得します。OnPersistingAsync で永続化が必要になったタイミングでデータを保存します。そして最後に Dispose でコールバックの登録を削除します。

こうすると、InteractiveWebAssembly に切り替わった時にもキャッシュされたデータを使うことが出来るので、2 回目のレンダリング時にもう一度データを取得することが無くなります。良き!!

PersistentComponentState の注意点

ちなみに PersistentComponentState から TryTakeFromJson で一度値を取得すると、その値はキャッシュから削除されます。気を付けましょう。

もうちょっと応用的な使い方

この例では PersistentComponentState をページに埋め込みましたが、それ以外にもクラス内で使うこともできます。普通のクラス内で PersistentComponentState を使うときには RegisterOnPersisting メソッドの第二引数に RenderMode.InteractiveServer などのように保存したデータを渡したいレンダリングモードを指定しないといけません。

CounterValueProvider.cs
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);

これを使うとコンポーネント側はこうなります。

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++;
    }
}

そして、CounterValueProvider は Scoped として DI コンテナに登録しないといけない(多分 PersistentComponentState が Scoped)ので Program.cs に以下のように変更します。

Program.cs
// サーバーも WASM も両方で以下のように変更する
builder.Services.AddScoped<CounterValueProvider>();

スッキリ!といっても CounterValueProvider がデータの取得とキャッシュの役割を持ってるのが気に入らないので、このようなクラスを用意しました。

State.cs
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 かどうかのチェックで初期化済みかどうかを確認できるようにしました。

CounterValueProvider.cs
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 を書き換えると以下のようになります。今度こそスッキリ!

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 をアクセスしてるととても大変…。

Microsoft (有志)

Discussion