SPA の動作原理の基本のキ
まずはじめに、Blazor WebAssembly も含めた SPA (シングルページアプリケーション) 全般における起動の流れをおさらいしておきたいと思います。
大体のケースにおいて、SPA におけるフォールバックページの HTML コンテンツは次のような感じだと思います。
<html>
<body>
<div id="app">
Loading...
</div>
<!-- 👇 SPA アプリケーションのスクリプト(この例では app.js) を読み込む -->
<script src="app.js"></script>
</body>
</html>
Web ブラウザでこの SPA を開くと、ブラウザの表示上には、まずはいったん、以下の様に表示されることになります。
Loading...
で、そうこうしているうちに、やがて SPA 本体である app.js
のブラウザへの読み込みが完了し、app.js
内に記述されている JavaScript コードが動き始めます。
SPA 本体の JavaScript が何をやっているかの、そのエッセンスを、極めてシンプルに例示したのが下記です。
// プレースホルダとなる DOM 要素への参照を取得する
const placeHolder = document.querySelector("#app");
// SPA のコンテンツとしての DOM を組み立てる
const h1 = document.createElement("h1");
h1.innerText = "Hello, World!";
// プレースホルダの中身を、上記で組み立てた DOM 要素に差し替える
placeHolder.replaceChildren(h1);
この SPA JavaScript コードが動き始めると、index.html
が Web ブラウザに読み込まれた直後の当初の下記 DOM 構造が、
<div id="app">
Loading...
</div>
クエリセレクタ #app
で指定された DOM 要素の中身が差し替わり、下記のようになります。
<div id="app">
<h1>Hello, World!</h1>
</div>
こうして Web ブラウザ上に、SPA によるレンダリング (描画) 結果が表示されます。
Hello, World!
もちろん、Blazor や各種 SPA ライブラリ/フレームワークはこれより遙かに凝ったことをやっています。メモリ上に仮想 DOM 構造を組み立てたり、差分計算したり...。
ですので、上記例は、あくまでもその "動作原理" のエッセンスを表現したものと解釈してください。
とはいえ、プレースホルダとなる DOM 要素をクエリセレクタで特定し、そのプレースホルダ内の DOM 構造を、SPA プログラムが動的に構築・差し替えしている、というのがキモだということです。
Blazor WebAssembly でも、Program.cs
に下記のような記述があるかと思います。
builder.RootComponents.Add<App>("#app");
これがまさしく先の app.js
の例で示したように、クエリセレクタ #app
で指している DOM 要素の中に、App
クラス (すなわち、App.razor
コンポーネント) のレンダリング (描画) 結果を差し込んでね、という指定になります。
事前レンダリングの需要
さてこのような形の SPA は、ブラウザや検索エンジンクローラーからの初回ページ読み込み時 HTTP GET 要求に対して、常に、下記のとおりの「Loading...」と書かれた HTML が返されます。
<div id="app">
Loading...
</div>
しかしながら、このような状況は、以下の 2 点の困ったことがあります。
- 検索エンジンクローラーに対し、この SPA が示したい本来のコンテンツ ("Hello, World!") が拾ってもらえない
- この SPA にアクセスしたユーザーは、暫し「Loading...」を見た後でないと、この SPA が示したい本来のコンテンツ "Hello, World!" を目にすることができない
そこで、上記 2 点の課題を解決するために、「事前レンダリング」という技法が使えます。
すなわち、HTTP GET 要求に応答を返す Web サーバー側で実行時に動的に、あるいは、その SPA のビルド (発行) 時に Web サーバーに静的コンテンツとして保存しておく形で、app.js
が実行された結果の HTML をブラウザや検索エンジンクローラーに返そう、という仕組みです。
例えば、先の例で、「SPA のビルド (発行) 時に、app.js
のレンダリング結果をファイル保存しておく」という事前レンダリング方式の場合、SPA のビルド処理によって index.html
が下記のように更新される仕掛けです。
<html>
<body>
<div id="app">
<!-- 👇 ビルド時の特殊処理により、中身が "Loading..." から
app.js の実行結果に差し替わって index.html が上書き保存されている! -->
<h1>Hello, World</h1>
</div>
<!-- 👇 SPA アプリケーションのスクリプト(この例では app.js) を読み込む -->
<script src="app.js"></script>
</body>
</html>
本投稿では「どうやって、Web サーバー側あるいはビルド処理で、app.js
を実行するのか、その結果を HTML 応答で返したりファイルに保存したりするのか」の具体的な技法については割愛します。
しかしとにかく、上記のように「事前レンダリング」を用いることで、検索エンジンクローラーにはちゃんと「Hello, World!」を拾ってもらえますし、ユーザーもこの SPA にアクセスした瞬間、即座に「Hello, World!」を目にすることができます。
事前レンダリング結果が表示されているところから SPA として動き出すまで
例として「カウンター」ページを実装しておく
さてところで、ここまでの例だと「Hello, World!」を表示するだけなので「index.html
で app.js
を読み込む意味ないじゃん (事前レンダリングで既に "Hello, World!" っていう表示が達成されてるし)」と思ってしまうかもしれません。
そこでこの例をもう少し SPA らしく、ユーザー操作との対話があるように変えることにします。具体的には、「カウンター」を実装してみましょう。仕様としては、
- Web ページ上に「ここをクリック!」と書かれたボタンを配置し、
- その隣にはクリックした回数を表示しておき、
- このボタンをクリックするたびにクリック回数の表示が 1 ずつ加算されて表示更新
ということにします。早速 app.js
を以下のように変更します。
// プレースホルダとなる DOM 要素への参照を取得する
const placeHolder = document.querySelector("#app");
// SPA のコンテンツとしての DOM を組み立てる
// 1. "ここをクリック!" と書かれた button 要素を用意
const button = document.createElement("button");
button.innerText = "ここをクリック!";
// 2. クリック回数を保存する変数 count を用意
let count = 0;
// 3. クリック回数を示す変数 count の内容を表示する span 要素を用意
const span = document.createElement("span");
span.innerText = count;
// 4. ボタンがクリックされたら、変数 count を 1 加算して span 要素の表示を更新
button.onclick = () => {
count++;
span.innerText = count;
};
// プレースホルダの中身を、上記で組み立てた DOM 要素に差し替える
placeHolder.replaceChildren(button, span);
このような SPA を index.html
を上書き保存する事前レンダリングを適用した場合、その index.html
は下記のようになるでしょう。
<html>
<body>
<div id="app">
<!-- 👇 app.js の実行結果が、事前レンダリングされている -->
<button>ここをクリック!</button>
<span>0</span>
</div>
<script src="app.js"></script>
</body>
</html>
ここまでは良い感じですね。
仮に app.js
のサイズが巨大で、app.js
を読み込みおわるまでに秒単位で時間がかかる、というような場合でも (そしてそれは実際の Blazor WebAssembly アプリではよくあることです)、ユーザーはそれを待つことなく「ここをクリック!」「0」という Web ページにすぐに出会えます。
SPA コードが読み込まれる前にユーザーが操作したら、何が起きる?
しかしです。
ブラウザがせっせと app.js
を読み込んでいる間に、ユーザーが「ここをクリック!」ボタンをクリックしたら何が起きるでしょうか?
答えは、「何も起きない」 です。
当然といえば当然なんですが、事前レンダリングの結果って、あくまでも「静的コンテンツ」であり、および、このシナリオではプログラムコードである app.js
はまだブラウザに読み込み完了していないのですから、「ユーザー操作によるイベントに反応して何かする」という動きは、この時点では起きるはずもないんですね。
ということで、はい、まだこの時点ではボタンをクリックしても、クリック回数の表示は更新されません。
SPA コードが読み込み完了し動き出したら、事前レンダリングされた DOM 要素はどうなる?
さて、そうこうしている内に、ついに app.js
がブラウザに読み込み完了し、記載されていた JavaScript コードが実行されたとします。
すると、いま事前レンダリングされている「ここをクリック!」button 要素には何が起こるでしょうか? 謎のテクノロジーによって click イベントがハンドルされ app.js
内に記載のコードに接続される? いいえ、そんなことはありません。
<div id="#app">
要素の中身がいったん全て削除されて、新たに app.js
によって構築された DOM 要素にすべて置き換わるだけです。
すごく単純ですね。
事前レンダリングの結果はご破算となり、今一度ブラウザ上で app.js
が構築し、イベントハンドラも配線済みの DOM 要素が差し込まれるので、これ以降は「ここをクリック!」ボタンのクリックで、クリック回数の表示が更新されるようになります。
app.js
の読み込み完了・実行開始とともに、<div id="#app">
要素内の DOM 要素は事前レンダリングの結果から完全に刷新されてしまっているのですが、Web ブラウザ上の表示上は、すり替わったことにユーザーは気がつかない格好となります。
以上は JavaScript によるサンプルコード例における説明ですが、Blazor でもこの動作原理は同じです。
なお、実際上はユーザー目線ではこのすり替わりに気がつきませんものの、Web ブラウザの開発者ツールなどで DOM 要素の更新状況を見ていると、事前レンダリング結果による DOM 要素がすべて書き換わる様子を確認できます。
SPA が動き出すまでユーザー操作に応答しないの、ユーザーは困惑するのでは?
さてさて、少し話を巻き戻しまして、SPA として動き出すまで、先のコード例ですと「ここをクリック!」ボタンを押しても何も起きない/動作しない、ということでありました。
しかしそれだと、ユーザーは困惑してしまいます。クリックしているのに、なぜ何も起きないんだ!? と。
これに対処する方法のひとつとして、SPA として動き出すまで、ユーザー操作対象の UI 要素を無効化 (disable) しておく、という方法があります。
まず、本投稿では事前レンダリングの具体的な実装方法について省略しましたので詳しくは書けないのですが、app.js
内のコードでは、ブラウザ上で動作中なのか事前レンダリング処理中なのかの区別がついたり、ブラウザ上での初期処理完了のタイミングを捕捉できたりするものとご理解ください。
その上で、事前レンダリング結果が下記のようになるよう app.js
を実装するのです。
...
<div id="app">
<!-- 👇 事前レンダリング結果では button 要素が無効化 (disabled) されている! -->
<button disabled>ここをクリック!</button>
<span>0</span>
</div>
...
この事前レンダリング結果がブラウザ上に表示された状態・app.js
が読み込まれて動作を開始する前の状態では「ここをクリック!」ボタンが無効状態となります。
これにより、ユーザーは、ああ、まだこのボタンは押せないんだな、と理解できます。
で、app.js
が読み込まれて動作を開始すると、この事前レンダリング結果の DOM 要素は破棄され、あらたに操作可能な button 要素が配置されます。
ユーザー目線では「ここをクリック!」ボタンが無効状態から有効に切り替わるので、ああ、今から操作可能なんだな、と理解できます。
Blazor で事前レンダリング時の状態を受け渡す
もう少し凝った実際的なシナリオとして、Blazor WebAssembly における ToDo アプリのシナリオを考えてみます。
ToDo リストは Web サーバー側に保存されており、Blazor WebAssembly 側からは HttpClient
を通して JSON 形式でこの ToDo リストを取得して、レンダリングするものとします。
そしてこの ToDo アプリに事前レンダリング技法を適用し、ユーザーがこの ToDo アプリをブラウザで開いたら瞬時に、事前レンダリングされた ToDo アイテム一覧が表示されるようにしておきます。下記はブラウザ上でび表示のイメージです。
やるべきことが 3 件あります。
- Qiita に解説記事を書く
- rm コマンドを実装する
- Issue #1234 に対応する
そして暫くして Blazor WebAssembly のアプリケーションが動き始め、サーバーへ ToDo アイテムを取得しにいきます。しかしこのとき、サーバーへの fetch 処理は非同期ですから、サーバーから返信がくるまでの間に、事前レンダリング結果を破棄しての、Blazor WebAssembly アプリコードによるレンダリング結果が先に表示されてしまいます。まだサーバーから ToDo アイテム一式を取得し終わってませんから、ブラウザ上には以下の様に 「ToDo アイテムがない」と表示 されてしまいます。
やるべきことが 0 件あります。
で、ようやくサーバーから ToDo アイテムを取得し終わると、ようやく、事前レンダリング結果と一致する内容でレンダリングされます。
やるべきことが 3 件あります。
- Qiita に解説記事を書く
- rm コマンドを実装する
- Issue #1234 に対応する
このように、せっかく事前レンダリング結果が迅速に表示できたのに、いったん「0件です」と表示されてしまうのは、当然のことながら好ましくないですよね。
Blazor WebAssembly ではこのような状況を回避するために、事前レンダリング時のアプリケーション状態情報を、事前レンダリングのページ内に永続化・埋め込む、という技法が使えます。
すみませんが詳細は本投稿では割愛し、マイクロソフトの公式ドキュメントサイトを参照いただければと思いますが、
この技法を用いることで、事前レンダリング時に取得した ToDo アイテムの一覧を、その事前レンダリング結果に永続化・埋め込むことができます。
具体的には、<!--Blazor-Component-State:{ここに base64 文字列} -->
という構文の HTML コメントとして、アプリケーション状態情報が事前レンダリング結果に埋め込まれます。このコメント中の base64 文字列をデコード (復号化) すると、JSON 形式で状態情報が保存されていることが確認できます。
このように HTML ページ中に埋め込まれた情報は、 非同期で fetch するなどの必要なく、同期的に取得できます。そのため、「HTML ページ内に埋め込まれた、事前レンダリング時のアプリケーション状態情報 (この例だと ToDo アイテムの一覧) が取得できたらそれをそのまま表示」とすることで、先に述べた「せっかく事前レンダリング結果したのに、一時、0件表示になってしまう」というユーザー体験の不都合を回避することができます。
おまけ: Blazor における事前レンダリングの歴史
先月 2022 年6月下旬に、Blazor の父・Steve Sanderson 氏による下記 Tweet が話題になりました。
上記 Tweet の内容は、"QucikGrid" と命名された、氏によるグリッドコンポーネントの試験実装を紹介したもので、その QuickGrid コンポーネントのデモサイトが GitHub Pages 上で公開されているのですが、その初期表示が速いことも話題となっていました。
ここまで本投稿を読んだ諸氏なら既にお気づきと思いますが、この初期表示の速さは、事前レンダリング技法によってもたらされています。
で、この「Blazor WebAssembky なのに初期表示が爆速!」がここへ来て話題となったようですが、実のところ、Blazor における事前レンダリング機能は、本投稿から遡ること 3 年ほど前、2019年9月23日の .NET Core 3.1 リリースの時点 (Blazor Server は正式リリース、 Blazor WebAssembly はまだプレビュー) から提供されておりました。
その時点で既にマイクロソフトの公式ドキュメントサイトにて、どうやって事前レンダリングを実装するかの解説もあったはずと記憶しています (リンク先は既に事前レンダリング時の状態情報の受け渡しの項で先に掲載済み)。
また、静的コンテンツサーバーにホストするスタンドアロンな Blazor WebAssembly アプリにおいては、暫くの間、Blazor Server や ASP.NET Core ホストされた Blazor WebAssembly に比べると、事前レンダリングは少々実装が難しい技法でしたが、それでも 2021 年の 1 月には react-snap を使った技法が紹介 (下記リンク先) されたりもしています。
さらに、スタンドアロンな Blazor WebAssembly においても、もっと簡単に、パッケージ追加するだけでワンタッチで発行時の事前レンダリングが行えるようにした拙作の NuGet パッケージ (下記。先の QuickGrid のデモサイトでも使われています) も、上記記事に遅れること 4ヵ月、本投稿の 1 年前の 2021 年 5 月 9 日には最初のバージョンがリリースされています。
上記 NuGet パッケージについては別途 Qiita に記事を書き残してありますので、興味のある方はどうぞご覧下さい (下記リンク先)。
...ということで「Blazor WebAssembly でも、事前レンダリングを応用することで、爆速の初期表示を提供できるよ!」という話は、この 2022 年 6 月に、Steve Sanderson 氏の Tweet で改めてバズったものの、歴史的に見ると、実はそんなに目新しい話でもなかったりします。😁
おしまい
以上、「事前レンダリングされた Blazor アプリが動き出すまでを解説してみる」、ちゃんと解説となってましたでしょうか?
皆さんの理解の一助になれば幸いです。
Learn, Practice, Share!