Blazor WebAssembly (クライアントサイド Blazor) でも、SEO 対策のためにサーバー側事前レンダリングしたい

_[2021/04/12]_ .NET 5.0 ベースに内容を改訂しました。

# Blazor WebAssembly、だけど SEO したい!

Blazor WebAssembly アプリケーション (クライアントサイド Blazor) は、要するにいわゆる "SPA" です。
SPA ですので、 初回のアクセス時は、どんな URL で HTTP GET 要求しても、返される HTML コンテンツは常に同じ。
すなわち、SPA コンテンツを読み込む "Loading..." ブートストラップコンテンツのみです。

これだと検索エンジン最適化において、ほら、検索結果が下図のようになってしまい、ちょっとマズイですよね...?

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/58450/0459e845-ec06-6d45-df86-1413112b0015.png)

もっとも、昨今の検索エンジンのクローラは、JavaScript も実行してページを解析するという話をどこかで聞いたような気もします (未確認)。
とはいえ、Blazor WebAssembly についてまでもクローラが実行してくれるかどうか、個人的にはあまり期待できないのではないか、と思ってます。

# Blazor Server だった良かったのに。

その点、Blazor Server アプリケーション (サーバーサイド Blazor) であれば問題ありません。

Blazor Server では、既定のプロジェクトテンプレートから生成される実装であれば、サーバー側で事前レンダリングされた HTML が返るからです。

そうはいっても、以前の投稿「[Blazor をお勧めできる人は誰か?](https://qiita.com/jsakamoto/items/52c5ef52fc5ccebaae1f)」に書いたとおり、オフライン対応が必要で Blazor Server を選択できない、などなど、どうしても Blazor WebAssembly でなければならないケースも充分あり得ます。

どうしたらよいのでしょうか!?

# Blazor WebAssembly でもサーバー側事前レンダリングできるよ!

答えは**「Blazor WebAssembly でも、サーバー側事前レンダリングしてしまう」**です!

Blazor WebAssembly と Blazor Server の違いは "ホスティングモデル" の違い、と言われています。

つまり、基本的に Blazor コンポーネントは、クライアントサイドでも、サーバーサイドでも、どちらでも動作するものなのです。

なので、Blazor WebAssembly であっても、Blazor Server と同じ実装でサーバー側事前レンダリングできるのです。

もっとも、このような仕組みでサーバー側事前レンダリングさせるため、Blazor WebAssembly であっても、ASP.NET Core サーバー実装が必要となります。

_[2021/04/12]_ 要件によっては、"ビルド時事前レンダリング" という作戦も可能で、その場合は GitHub Pages のような静的ホスティングサイトに配置しつつ、検索エンジン対応も可能ですが、本稿では割愛。

# 具体的にはどうやって?

話を簡単にするために、すでに ASP.NET Core ホスティングとなっている Blazor WebAssembly アプリのプロジェクトを対象としましょう。
 (dotnet CLI であれば、`dotent new blazorwasm --hosted` で作成されたプロジェクト。)

基本的な部分は、以下の 3ステップで完了します。


1. サーバー側実装で、Razor Pages 機能を有効にする
2. ブートストラップページとして、静的 HTML である index.html から Razor Page (.cshtml) に移行する
3. ブートストラップページである Razor Page (.cshtml) 中でサーバーサイドレンダリングを実行!

順番に見ていきましょう。


## 1. サーバー側実装で、Razor Pages 機能を有効にする

サーバー側の `Startup` クラスの `ConfigureServices()` メソッド内にて、Razor Pages 機能を DI 機構に登録します。


```csharp
public class Startup
{
  public void ConfigureServices(IServiceCollection services)
  {
    services.AddRazorPages(); // 👈 この行を追加
    ...
  }
  ...
```

続けて同 `Startup` クラスの `Configure()` メソッド内にて行なわれているルーティング構成を変更します。

(API 用などのいずれのルーティングにもマッチしなかった URL への要求を) "index.html" へフォールバックしていたところを、(このあと "_Host" の名前で作成する) Razor Page へフォールバックするように書き換えます。 

```csharp
  public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
  {
    ...
    app.UseEndpoints(endpoints =>
    {
      ...
      // endpoints.MapFallbackToFile("index.html"); 👈 これは削除し...
      endpoints.MapFallbackToPage("/_Host"); // 👈 この行を追加。
    });
    ...
```

## 2. ブートストラップページとして、静的 HTML である index.html から Razor Page (.cshtml) に移行する


上記ステップの 1 により、どこにもルーティングされなかった URL は、"_Host" の名前の Razor Page にフォールバックするようになりました。

ということで、これまでのブートストラップページであった (クライアント側プロジェクトの `wwwroot` にある) `index.html` ファイルを、ASP.NET Core サーバー側プロジェクトの `Pages` フォルダ内に、`_Host.cshtml` というファイル名にリネームして移します。


## 3. ブートストラップページである Razor Page (.cshtml) 中でサーバーサイドレンダリングを実行!


仕上げとして、上記ステップ 2 で作成した `_Host.cshtml` を編集します。

この `_Host.cshtml` のルーティング URL を冒頭に追記します。

```
@page "/"
...
```

続けて、Blazor コンポーネントをサーバー側でレンダリングするためのタグヘルパーの登録を `_Host.cshtml` に追記します。

```
...
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
```

そして、この時点ではまだ "Loading..." コンテンツを表示するだけである `<div id="app">` 要素内に、Blazor コンポーネント `App` をレンダリングするタグヘルパー `component` の記述を追加します。

```html
...
<div id="app">

  <!-- 👇 これを追加! -->
  <component type="typeof(MyBlazorApp.Client.App)" render-mode="Static" />

  <div class="loading">Loading...</div>
</div>
...
```

この `component` タグヘルパーは、Blazor Server アプリを実装するときに使ってるのと同じタグヘルパーです。

しかしながら、Blazor Server と違って、このあと SignalR 通信動作などはしませんので、`render-mode` には単純にレンダリングするだけの意図で `Static` モードを指定します。

これで、Blazor WebAssembly アプリではあるのですが、どのような URL であっても初回ロード時は、サーバー側事前レンダリングされた HTML が返るようになります!

## 最後にもう一工夫

ただし、Blazor WebAssembly は、このサーバー側事前レンダリングされた初期 HTML コンテンツがブラウザに読み込み・描画されたあとも、引き続き、dotnet.wasm や諸々の .dll ファイルの読み込みにやや時間がかかります。

つまり、**ユーザーの目にはそれっぽいコンテンツが表示されているのに、Blazor WebAssembly の実行に必要なファイル群が読み込み終わるまでの、やや暫くの間、満足に動作しない状態になってしまう**のです。

これはマズイ!

さてさてよくよく考えますに、結局のところ、このサーバー側事前レンダリング結果は、検索エンジンのクローラに読んでもらえたらそれでいいわけです。

人間の目に触れられなければいいのであれば、と考えまして、そこで、CSS で透明度を 0 にした `div` 要素で、サーバー側事前レンダリング結果を囲うようにしました。

```html
...
<div id="app">

  <!-- 👇 サーバー側事前レンダリング結果をブラウザ表示上は透明にしてしまう! -->
  <div style="opacity:0;">
    <component type="typeof(MyBlazorApp.Client.App)" render-mode="Static" />
  </div>

  <div class="loading">Loading...</div>
</div>
...
```

これで検索エンジンのクローラにも優しく、ユーザーにも迷惑をかけない、サーバー側事前レンダリングが実装できました!

めでたしめでたし!


# ...実は罠がありまして。

...と、まぁ、至極単純な Blazor コンポーネントであれば、以上でサーバー側事前レンダリングできるのですが、現実はなかなか甘くありません。


多くの Blazor コンポーネントでは、`HttpClient` を使って、サーバー側と JSON 等のフォーマットでデータのやりとりなどを行なうことでしょう。

そのような **`HttpClient` を DI 機構から注入してもらう**ような Blazor コンポーネントをサーバー側事前レンダリングする何が起きるでしょうか。

なんと、**例外で落ちてしまう**のです!

それもそのはず。
サーバー側でのレンダリング中は、その Blazor コンポーネントは、サーバーのプロセス内で実行されています。
Web サーバーが自身のプロセス内で自分に `HttpClient` でアクセスするとはこれ如何に、というわけなのです。

そういった仕組みですから、ASP.NET Core サーバー実装では `HttpClient` は DI 機構に登録されておらず、それで前述のように注入失敗の例外で落ちてしまうのです。

これを回避するには、難しくはないものの、少々手を動かして、クライアントサイド/サーバーサイドのいずれでもうまくデータアクセスできるような仕掛けを施す必要があります。


この "仕掛け" について詳しくは、少々記事量が大きくなりますので、いったん割愛とさせていただきます。

代わりにと言っては何ですが、ASP.NET Core ホステッドな Blazor WebAssembly アプリを、サーバー側事前レンダリングするよう改造する例を示したサンプルコードを下記 GitHub リポジトリに公開しています。

- _**"Sample Code: How to enable Server-Side Rendering on Client-Side Blazor App?"**_  
https://github.com/sample-by-jsakamoto/Blazor-ClientSideBlazorSSR

とくに、`HttpClient` を使用する Blazor コンポーネントでサーバー側事前レンダリングを可能とするための改造は、[コミット 18298a37](https://github.com/sample-by-jsakamoto/Blazor-ClientSideBlazorSSR/commit/18298a37fe85c274431fba83d0b2127f7265ff6a) でその差分をご覧いただけます。

解説がないと差分だけでは何が何だかという感じかも知れませんが、また続きを書く機会があれば、そのときに解説していきたいと思います。


Happy Coding! :)