サーバー側レンダリングを行なう場合、ブラウザ機能へのアクセスはタイミングに注意
Blazor、とくに Blazor Server や rendermode = InteractiveServer なコンポーネントを含む、サーバー側レンダリング (プリレンダリング) が行なわれる場合において、JavaScript コードの呼び出しをはじめとしたブラウザ機能の使用タイミングは、少々気を付ける必要があります。
例えば、プリレンダリングが有効な Blazor アプリケーションにおいて、以下のようなよくある "カウンター" コンポーネントが実装されているとします。
<p>カウント: @_count</p>
<button @onclick="Increment">カウントアップ</button>
@code {
private int _count = 0;
private void Increment() {
_count++;
}
}
そして、カウントの最終状態をブラウザの localStorage
に保存しておき、次回アクセス時に復元することを考えます。まず、カウントが加算されるたびに、ブラウザの localStorage
に最新のカウントを保存するよう、JavaScript 呼び出しを含む実装に、上記のコードを以下のように変更します。
@inject IJSRuntime JSRuntime
<p>カウント: @_count</p>
<button @onclick="Increment">カウントアップ</button>
@code {
private int _count = 0;
private async Task Increment() {
_count++;
// 👇ブラウザの localStorage に最終のカウンタ値を保存する
await this.JSRuntime.InvokeVoidAsync("localStorage.setItem", "count", _count);
}
}
ここまではいいでしょう。
次に、このコンポーネントの初期化時に、ブラウザの localStorage
から前回保存されたカウントを取得、復元するようプログラムを変更することを考えます。"コンポーネントの初期化時に..." ということなので、以下のように OnInitializedAsync
ライフサイクルメソッド内で localStorage
からの読み取りを実装したくなりますが...
...
@code {
...
protected override async Task OnInitializedAsync()
{
var countText = await this.JSRuntime.InvokeAsync<string?>("localStorage.getItem", "count");
_count = int.TryParse(countText, out var count) ? count : _count;
}
...
このコードは、このカウンターコンポーネントを含むページのプリレンダリングが行なわれる際に、以下の例外で落ちます。
InvalidOperationException: JavaScript interop calls cannot be issued at this time.
プリレンダリングということは、ブラウザからの初回ドキュメント要求の HTTP GET リクエストに対する、応答 HTML 文字列を生成するとき、ということを意味します。そのような、まだブラウザ上で何かプログラムが動作し始める前のタイミングでは、JavaScript 呼び出しをはじめ、ブラウザの各種 API に触ることはできません。それにもかかわらず、上記実装では、JavaScript 呼び出しを実行しているため、プリレンダリング時に例外に至ってしまいます。
より正しい実装は次のとおりです。
...
@code {
...
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender) {
var countText = await this.JSRuntime.InvokeAsync<string?>("localStorage.getItem", "count");
_count = int.TryParse(countText, out var count) ? count : _count;
this.StateHasChanged();
}
}
...
"コンポーネントの初期化時に、前回カウント値の復元を..." という処理なのに、OnAfterRenderAsync
、すなわち、レンダリング完了時に呼び出されるライフサイクルメソッド内でそれを実装するのは、なんだか違和感を感じなくもないです。しかしBlazor プログラミングではこれがベストプラクティスとされています。というのも、Blazor における様々なホスティングモデル、レンダーモードにおいて、ブラウザの API に触れることが保証されているタイミングというのが、OnAfterRenderAsync
ライフサイクルメソッドであるためです。実際、プリレンダリング処理 (Blazor SSR もそうですが) においては、OnAfterRenderAsync
は呼び出されません。
しかしいっぽうで、OnAfterRenderAsync
は再描画も含めて毎回呼び出されます。"最初の1回目の描画完了時" にのみカウンタ値を復元するためには、同メソッドに渡される引数 firstRender
の値を参照して if 文で条件分岐する必要があります。また、カウンタ値を復元しても、それだけでは再描画されません。明示的に StateHasChanged()
メソッドを呼び出して、Blazor に再描画を促す必要もあります。
繰り返しになりますが、自分の直感に反するような実装をしなくちゃならないのと、あと、定形コードとはいえ、少々面倒臭いなぁ、と、実は内心、常々思っていました。
しかし .NET 9 からは、この状況が改善されるかもしれません。
.NET 9 で追加された RenderInfo
プロパティ
先日 2024年11月12日にリリースされた .NET 9 から、Blazor のコンポーネントに、RenderInfo
プロパティが追加されました。この RenderInfo
プロパティには、さらに IsInteractive
という bool
型のプロパティが公開されており、この RenderInfo.IsInteractive
を参照することで、いま、そのコンポーネントが、対話モードが機能している状態にあるか、すなわち、ブラウザの API を呼び出せる状態にあるか否かを、いつでも判定できるようになりました。
この RenderInfo.IsInteractive
プロパティを活用すると、先のカウンターコンポーネントにおける、前回カウント値の復元処理は、以下のように、OnInitializedAsync
内で実装できるようになります。
...
@code {
...
protected override async Task OnInitializedAsync()
{
if (this.RendererInfo.IsInteractive) {
var countText = await this.JSRuntime.InvokeAsync<string?>("localStorage.getItem", "count");
_count = int.TryParse(countText, out var count) ? count : _count;
}
}
...
プリレンダリングの最中は RenderInfo.IsInteractive
は false
を返します。そのため、上記 if 文の判定によって、プリレンダリングの最中にブラウザ API に触ってしまうことを回避できます。
また、OnInitializedAsync
メソッド内の処理がすべて完了すると、Blazor は自動でコンポーネントの再描画を行います。つまり、明示的な StateHasChanged()
呼び出しも不要です。
何よりも、やっていることやその意図が、こちらのコードのほうが明確に伝わるように感じます。
先に紹介した .NET 8 以前の方式では、Blazor のあらゆるホスティングモデル、あらゆるレンダーモードにおいてブラウザ API へのアクセスが保証されている最大公約数的なタイミングは OnAfterRenderAsync
以降である、という予備知識がないと、「なぜ OnInitializedAsync
ではなくて、描画が終わったあとの OnAfterRenderAsync
で初期化処理をやっているの??」と理解が困難になる危険があります。
その点、.NET 9 の RenderInfo.IsInteractive
プロパティを参照する実装方式であれば、
「コンポーネントの "初期化" の時点で、前回カウント値の復元をしたい」
「ただし、プリレンダリングの最中など、ブラウザ API にアクセスできない場合は除外する」
という実装意図が素直にコードに表現できています。
また「いったん初回の描画が行なわれた後で、ブラウザ API 呼び出し」する .NET 8 以前の方式よりは、「初回描画が行なわれるより前にブラウザ API 呼び出しを開始」する .NET 9 の RenderInfo.IsInteractive
プロパティを参照する方式のほうが、処理速度的な性能面でも、もしかすると、ほんのちょっとだけ有利かもしれません。つまり、後者の .NET 9 の場合は、ブラウザ API を呼び出したあと、その結果が返ってくるまでの非同期処理の待機の間に、描画処理を遂行できているのではないか、と想像した次第です。自分はこの点、ちゃんと計測・評価したことはなく、あまりに僅差にすぎる可能性も高そうですが、気になるところです。
おわりに
.NET 9 がリリースされるまで、長らくややこしかったブラウザ機能が使用可能になるタイミングでしたが、今後は RenderInfo.IsInteractive
を参照することで、より可読性の高い・保守容易なコードで実現できるようになりそうです。
ぜひお試しください。