Blazor WebAssembly アプリを発行するときに、事前レンダリングして静的 HTML ファイルに保存する

# 先に結論

「BlazorWasmPreRendering.Build」パッケージを Blazor WebAssembly プロジェクトに追加しておくと、そのプロジェクトの発行時、運が良ければ (!?) ただそれだけで、事前レンダリングされた静的 HTML ファイルが発行先フォルダに生成されるようになる。

https://twitter.com/nthonyChu/status/1463953732731760647

Blazor WebAssembly アプリケーションを、GitHub Pages や Azure Static Web Apps などの静的 Web ホスティングに配置しているのなら、事前レンダリングされた静的 HTML ファイルがインターネット検索のクローラに拾われるようになり、検索結果の改善につながる。

# はじめに

この記事では、静的 Web ホスティングでホストされている Blazor WebAssembly スタンドアロンアプリを、その発行時に事前レンダリングし、静的 HTML ファイルに保存する方法について説明します。

さて、本題に入る前に「Awesome Blazor Browser」Web サイトを紹介させてください。

「Awesome Blazor Browser」とは、下記 URL に配置している、下図スクリーンショットで示される Web サイトになります。

https://jsakamoto.github.io/awesome-blazor-browser/

![image](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/c4lijh319ks4et3b36f3.png)
 「Awesome Blazor Browser」とは何か、なぜ作ったのか説明させてください。
これは、Blazor WebAssembly アプリの静的な事前レンダリングの背景と要件を理解するのに役立ちます。

ご存知かもしれませんが、「Awesome _何々_ 」というサイトがたくさんあります。
これらのサイトは、主にコンピュータープログラミングに関係した「素晴らしい (Awesome)」もののコレクションです。

https://github.com/sindresorhus/awesome#readme

もちろん、Blazorプログラミングに関する「素晴らしい (Awesome)」サイトもあります。
下記は「Awesome Blazor」GitHub リポジトリです。

https://github.com/AdrienTorris/awesome-blazor

これは Adrien Torris 氏による素晴らしい成果です（氏に感謝します！👍）。
私はしょっちゅう「Awesome Blazor」サイトにアクセスして、役立つソフトウェアやその方法を入手しました。

しかし、ある日、私は1つの問題に遭遇しました。

「Awesome Blazor」のアイテムは日ごとに膨大になり、サイトの閲覧が難しくなったのです。
たとえば、サイトの多くのセクションから、見たいセクションを一目で見つけることが難しくなりました。

これが私が「Awesome Blazor Browser」サイトを作成した動機です。

「Awesome Blazor Browser」は、「Awesome Blazor」サイトのコンテンツを閲覧するための専用ウェブアプリです。
「Awesome Blazor Browser」は、「Awesome Blazor」 GitHub リポジトリから README コンテンツを取得して解析し、ユーザーフレンドリーなナビゲーション UI に表示します。
「Awesome Blazor Browser」を使用すると、キーワードでコンテンツをフィルタリングしたり、見たい特定のセクションをフィルタリングしたり、セクションに直接ジャンプしたりできます。

「Awesome Blazor Browser」は、Blazor WebAssembly による、サーバー側を持たないスタンドアロンアプリであり、GitHub Pages に配置しました。
GitHub Pages への発行・配置処理は、GitHub Actions スクリプトとして自動化してあります。

以上のように、「Awesome Blazor Browser」は、私の要件・動機から作り始めたので、当然のことながら、少なくとも私自身にとっては非常に便利です。😁

しかし、ひとつ問題がありました。

その問題というのは、**「Awesome Blazor Browser」のインターネット検索結果が、その当時、それはもう本当に酷かった**のです (下図)。

![image](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/h7g6jz39m0ywt2ro6aiu.png)

この検索結果をどうにかするには、どうにかして "サーバー側レンダリング" を実装し、インターネット検索のクローラに対する HTTP GET 要求に対して、"Loading..." とだけ書かれた SPA をロードするためだけの**空っぽの HTML テキストを返すのではなく**、SPA が動き出した後にユーザーが目にするであろうコンテンツと同じ内容を**事前レンダリングした HTMLテキストを返す**ようにするほかありません。

もしも ASP.NET Coreサーバーで「Awesome Blazor Browser」をホストしていたのなら話は簡単でした。
"普通に" サーバー側の事前レンダリングを実装するだけの話です (下記公式ドキュメントに記載があります)。

https://docs.microsoft.com/ja-jp/aspnet/core/blazor/components/prerendering-and-integration?pivots=webassembly

これはトリックでも何でもありません。
上記リンク先のように、公式ドキュメントサイトでも言及されている程度に、ASP.NET Core でホストされた Blazor WebAssembly プログラムであればこれは一般的なタスクです。

しかし、「Awesome Blazor Browser」は、GitHub Pages のような静的 Web ホスティングでも Blazor WebAssembly アプリをホストできることを示す良いショーケースとなります。
そのため、私は「Awesome Blazor Browser」を**どうしても GitHub Pages でホストしたい**と強く考えました。

そうとなれば、Blazor WebAssembly アプリを発行する時点で事前レンダリングするを仕組みを実現し、事前レンダリング結果は静的 HTML ファイルで公開フォルダーに保存する必要があります。

もちろん、ルートインデックスコンテンツだけでなく、他のすべてのルーティングされた URL も、静的 HTML ファイルに事前レンダリングする必要があります。

そして以上のような事前レンダリング処理を、GitHub Actions スクリプト内で起動・実行できる必要があります。

# 解決策を探す

この問題を解決するため、まずはじめに私は、Blazor WebAssembly アプリケーションの公開時事前レンダリングを行うためのツールや記事をインターネット上で検索しました。

幸いなことに、インターネット上にすでに存在するいくつかの優れたリソースを見つけることができました。
私が見つけた2つのリソースを以下に紹介します。

## react-snap

まず一つ目に紹介するのは Swimberg 氏による技術ブログ記事です (下記リンク先)。

https://swimburger.net/blog/dotnet/pre-render-blazor-webassembly-at-build-time-to-optimize-for-search-engines

彼は上記ブログ記事で、「react-snap」を使用して事前レンダリングする方法について説明しています。

「react-snap」とは、NodeJS 上のツールです。

このツールは内蔵しているローカル Web サーバーを起動し、発行先フォルダ内の静的コンテンツを HTTP 経由でアクセス可能にします。

そして続けてこのツールは、ヘッドレス Chromium ブラウザを起動します。
「react-snap」はその起動したヘッドレス Chromium ブラウザを操作して前述の内蔵 Web サーバーへのアクセスを開始します。
そうして「react-snap」はヘッドレス Chromium ブラウザがレンダリングした DOM コンテンツを取得し、そのコンテンツを静的 HTML ファイルへと保存します。

Blazor WebAssembly の発行結果に対して「react-snap」を使うと、このような仕組みで、事前レンダリングとその静的 HTML ファイルへの保存が実現できる次第です。

このアプローチは簡潔且つ実直でわかりやすく、加えて「react-snap」は JavaScript による SPA フレームワークの界隈では広く使われており、安定していると思います。
さらに Blazor WebAssembly プロジェクト側では、何かコードを変更したりする必要もありません。

## 事前レンダリングするホストプログラムを C# で実装する

二つ目に紹介するのは、Andrew Lock 氏の技術ブログ記事です (下記リンク先)。

https://andrewlock.net/prerending-a-blazor-webassembly-app-without-an-asp-net-core-host-app/

彼は上記ブログ記事で、ASP.NET ホストプロジェクトを追加して事前レンダリングする方法を説明しています。

彼のアプローチは、先に公式ドキュメントのリンク先があることで示した、Blazor WebAssembly アプリケーションでサーバー側の事前レンダリングを行うための、よく知られた手法に基づいています。

彼は新しい ASP.NET Core ホストプロジェクトを追加し、Blazor WebAssembly アプリケーションを事前レンダリングするようにホストを構成しました。

最後に、発行時にホストプログラムが起動されると、ホストプログラムは HttpClient を介して自身をクロールし、取得されたコンテンツを静的HTMLファイルに保存します。

この手法は ASP.NET Core プログラミングでよく知られている方法に基づいているため、このアプローチも優れていると思います。

# これらの解決方法に対する不満

しかし、私はそれらの解決策に満足することができませんでした。

「react-snap」を使用した Swimberge 氏のアプローチは、多くの場合、良好に機能します。
しかし、Blazor WebAssembly 側で「OnInitializedAsync」メソッドでやや長い時間かかる非同期処理が行なわれているときに、「react-snap」はそれを待ちきれません。
その結果、中途半端なレンダリング結果が保存されることがありました。

また「react-snap」が入手するコンテンツは、SPA が起動しレンダリングしたあとの結果であり、有効な SPA 起動前のコンテンツにはなっていません。
そのため、「react-snap」が保存した静的 HTML ファイルに対し、後処理としてさらに内容を書き換えるシェルスクリプト等を実装する必要があります。

いっぽう、ASP.NET Core ホスティングを使用した Andrew 氏のアプローチも正常に機能します。
しかしそのためには、まぁまぁの規模の C# プログラムを作成する必要があります。
おまけに、そうして作成した C# コードは、個々の Blazor WebAssembly プロジェクトに強く依存しており、汎用に使えず、他の Blazor WebAssembly プロジェクトにそのままでは転用できません。

それで、ついに、ある日、私は**自分の不満を解決するために新しいソフトウェアプロジェクトを始める**ことにしました。

# 独自の解決策の方針と目標

Blazor WebAssembly アプリケーションを静的に事前レンダリングする新たな解決策を実装するにあたっての、私の方針と目標について説明します。

まず、基礎的な仕組みとしては、ASP.NET Core サーバーでホストされている標準のサーバー側の事前レンダリングに基づくことにします。
これは、非同期初期化の問題を回避するために必要です。

次に、Blazor WebAssembly 側のコード変更をゼロ、または最小限に抑えることを目指します。
この目標は、私の成果を誰にとっても使いやすいものにするために重要です。

第三に、この仕組みを NuGet パッケージとしてパッケージ化し、「nuget.org」で配布することとします。
これは、プロジェクトにパッケージ追加するだけで、誰でも私の成果をすぐに使用できることを意味します。
 
# "BlazorWasmPreRendering.Build"

以上のように方針と目標を立て、開発を開始してから数日を経た後、ついにその成果をNuGet パッケージとして公開しました。

パッケージ ID は **「BlazorWasmPreRendering.Build」** です。

https://www.nuget.org/packages/BlazorWasmPreRendering.Build/

![image](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/79jm1simpteebu1nf3tr.png)

Blazor WebAssembly アプリケーションの発行時に、静的 HTML ファイルへの事前レンダリングを行なうためにする必要があることは、**このパッケージをその Blazor WebAssembly プロジェクトに追加することだけ**です。

このパッケージが Blazor WebAssembly プロジェクトの発行結果にどのように影響するかを示します。

次の図は、このパッケージを追加する前の、ごく普通の Blazor WebAssembly プロジェクトを発行後の発行先フォルダの様子です。

![image](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/hi6kyl8kjsueywdd3lo7.png)

ご覧のとおり、「index.html」SPA フォールバックページには「Loading...」メッセージのみが含まれています。

そして次の図は、「BlazorWasmPreRender.Build」パッケージを追加した上で、同じ Blazor WebAssembly プロジェクトを発行した結果です。

![image](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/4iv9mnl4skxit6c22gz4.png)

ご覧のとおり、「index.html」には、Blazor コンポーネントによってレンダリングされた HTML テキストが含まれています。
また、各ルート URL に対応する静的 HTML ファイルも生成されています。

繰り返しになりますが、これは「BlazorWasmPreRender.Build」パッケージを Blazor WebAssembly プロジェクトにパッケージ参照を追加しただけで得られた成果です。

したがって、GitHub Actions スクリプトを変更する必要はありません。

# 仕組み

以下では「BlazorWasmPreRendering.Build」パッケージがどのようにして事前レンダリングおよびその静的 HTML ファイルへの保存を行なっているか、その仕組みについて説明します。

## 1. 初期化処理

通常の発行処理が終了したあとで、「BlazorWasmPreRender.Build」が動作を開始します。

次の図を見るとわかるように、「BlazorWasmPreRender.Build」は、発行先フォルダーから「index.html」ファイルを読み取り、その HTML テキストを解析して、「BlazorWasmPreRender.Build」が内蔵する ASP.NET Core サーバーに SPA フォールバック Razor ページを動的に作成します。

![image](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/05sbezcgdzbos0zg0cie.png)

また、サーバー側レンダリングのコードは、その動的生成された SPA フォールバック Razor ページに挿入されます (下図参照)。

![image](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/q427o6y0kxujbc5soag2.png)
 
Blazor WebAssembly アプリケーションの .DLL ファイルもロードされ、その中のルートコンポーネントクラス（通常は「App」クラス）がサーバー側の事前レンダリングコードから参照されます (下図参照)。

![image](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/bt6ifva3q4kkk78hampg.png)

以上の初期化処理を済ませたのち、「BlazorWasmPreRender.Build」は内蔵する ASP.NET Core サーバーを起動します。

## 2. クロール

次の手順として、「BlazorWasmPreRender.Build」は、**自身に内蔵の ASP.NET Core ホストサーバに対して、クロールを開始**します。

まず、内部のクローラーインスタンスは、ルート URL への HTTP GET リクエストを、内蔵 Web サーバーインスタンスに送信します。

次に、サーバーインスタンスはレンダリングを実行し、レンダリング結果をクローラーに応答します。

クローラーがレンダリング結果 (HTML 文字列) を受信すると、クローラーはその結果 HTML 文字列を、対応する「index.html」ファイルに静的に保存します (下図参照)。

![image](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/tkvfxo1t683acinny36s.png)

次に、クローラーインスタンスは、その取得したレ結果 HTML 文字列内にリンクを探します。

クローラーがリンクを検出すると、クローラーはリンクごとに HTTP GET 要求を送信し、それらの結果を再び静的HTML ファイルに保存していきます (下図参照)。

![image](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/gq2ur9nqq3u7agi9p5ie.png)
 
クローラーは以上の手続きを、すべての訪問先に達するまで、再帰的に実行します。

「BlazorWasmPreRender.Build」は、以上のとおりの仕組みで、Blazor WebAssembly アプリケーションの発行のタイミングで、すべての訪問可能な URL の事前レンダリング、およびそのレンダリング結果の静的 HTML ファイルへの保存を実現しています。

# 注意事項

しかしながら、残念ではありますが、この私の方法もまた、**完璧ではありません**。

## DI コンテナへのサービス登録処理が実行されない

最も重要なことは、アーキテクチャ上の制限から、「BlazorWasmPreRendering.Build」は Blazor WebAssembly アプリケーション側の「Main」メソッドを呼び出すことができない点です。

結果何が起こるかというと、本来であれば「Main」メソッドで登録されるであろうサービスを、ある Blazor コンポーネントが DIコンテナーから取得している場合、事前レンダリングプロセスでその Blazor コンポーネントを描画しようとすると、クラッシュしてしまうのです。

これは、事前レンダリングプロセスでは「Main」メソッドが呼び出せないことから、結果、それらサービスが DI コンテナに登録されないことが原因です。

![image](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/j0878on822tps4rx581f.png)

この問題を回避できるようにするために、「BlazorWasmPreRender.Build」にフックポイントを実装しました。

この問題を回避するには、「ConfigureServices」という名前の静的メソッドにサービス登録を抽出する必要があります。

![image](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/gb1xyx72ilqravljmknb.png)

「BlazorWasmPreRender.Build」は、「ConfigureServices」という名前の静的メソッドを見つけると、その静的メソッドを、内蔵の Web サーバーインスタンスの起動プロセスで呼び出します。

この仕組みにより、「ConfigureServices」静的メソッド内で DI コンテナに登録されるサービスは、事前レンダリングプロセス、および Web ブラウザープロセスの、いずれのプロセス内であっても、Blazor WebAssembly アプリケーションから参照・使用できるようになります。

## 現在時点では実験的プロジェクト

「BlazorWasmPreRendering.Build」はまだ実験的なプロジェクトです。

「BlazorWasmPreRendering.Build」を使用した実績はまだ多くありません。
そのため、現時点では、「BlazorWasmPreRendering.Build」が複雑な実世界の多くの Blazor WebAssembly プロジェクトでうまく機能するかどうかはわかりません。

# まとめ

まとめましょう。

「BlazorWasmPreRender.Build」パッケージは、コードの変更を最小限に抑えるか、あるいはまったく変更せずに、静的 Web ホスティングされる Blazor WebAssembly アプリケーションのインターネット検索結果を改善します。

しかし、これはまだ実験的なプロジェクトです。
私としては、このプロジェクトをフォークして改善したり、他のアプローチを実装したりする人を歓迎します。

以上、「BlazorWasmPreRendering.Build」パッケージが、一人でも多くの Blazor WebAssembly アプリケーション開発者の時間を節約することにつながれば幸いです。

Happy Coding :)