シングル ページ アプリケーション (SPA) で正しいサムネイル画像とタイトルを表示する

2023/06/12   HIRANO Satoshi

TwitterやFacebookなどのSNSで Webページへのリンクを共有すると、ページのタイトルとサムネイル画像が表示されます。残念なことに、ページがシングル ページ アプリケーション (SPA) の一部である場合、サーバーは実際のページの代わりに固定のindex.html を提供するため、いつも決まったタイトルと画像が表示されます。検索エンジンも各ページの正しいタイトル、コンテンツ、サムネイル画像をインデックスできません。この記事では、この問題を解決してページに正しいタイトルと画像を表示する方法について説明します。

開発 Webpack SPA SEO
フォロー
シェア

問題


リクエストランドの開発中に起きた問題のひとつに、ユーザーが Twitter や Facebook などのソーシャル メディアで Requestland 上のあるページへのリンクを共有すると、そのページのタイトルとサムネイル画像の代わりに、いつも同じデフォルトのタイトルとサムネイル画像が表示されるという問題がありました。


Twitterの読者がそのページのサムネイル画像を見ることができなければ、リンクをクリックする可能性は低くなります。


原因は、リクエストランドがシングル ページ アプリケーション (SPA) であったことです。 この問題はよくある問題ですが、ネットや ChatGPT で解決策を見つけることができませんでした。


SPA では、リンクがアクセスまたはリロードされると、ウェブサーバーは、SPA の実行に必要なJavaScript コードと CSS を含むバンドルファイルをロードする <script> タグを含むデフォルトのindex.html ファイルを提供します。index.html ファイルにはタイトルとサムネイル画像が含まれているため、ユーザーは常に固定されたサムネイル画像を見ることになります。


たとえば、ノートアプリのあるページ https://my-service.com/app/note/995 をロードすると、次のindex.htmlが返されます。<title>はアプリのタイトルで、ノートのタイトルではありません。


<html>
  <head>
    <base href="/app">
    <title>SPAのノートアプリ</title>
    <description>これはノートアプリです</description>
    <meta property="og:image" content="https://file-server.image1.png">
    <!-- バンドルファイルのscriptタグが挿入されている -->
    <script src="https://file-server.bundle.a94.js"></script>
    <link href="https://file-server.bundle.vc0.css" rel="stylesheet"></link>
  </head>
  <body>
    ...
  </body>
</html>


目標は、SPA内のどのページをシェアする場合でも、各ページの正しいタイトルとサムネイル画像を表示することです。


これで、一部の検索エンジンで、サーバーサイド・レンダリング(SSR)なしで、sitemap.xml ファイルにリストされている各ページの正しいタイトル、コンテンツ、サムネイル画像をインデックスできる可能性もあります。


解決すべき問題は、固定されたindex.htmlファイルに正しいタイトルとサムネイル画像をどのように挿入するかです。



もし、ウェブサーバーがバンドルファイルの配布と、要求されたページに対するindex.html の生成を両方行う場合には、バンドルファイルの <script> タグを挿入することは簡単です。 しかし、ウェブサーバーはリクエストの処理と、静的ファイルの配布を両方行う必要があるため、性能上の問題が発生する可能性があります。




図1: プロダクション環境でのコンポーネント



図1 は、プロダクション環境でファイルサーバーを分離して性能向上を図った構成を示しています。(まだ上記の問題は残っています)


図1には、バンドルファイル及びindex.html を配布するファイルサーバーと、SPA の /api/* ルートを処理する API サーバーがあります。 ロードバランサーは、HTTP リクエストをルート(URL)によって API サーバーまたはファイル サーバーに転送します。 単純な Nginx プロキシーであるかもしれません。 ファイル サーバーは、/api/* を除くすべてのルートに対してindex.html ファイルを返します。


ここで、たとえば、https://my-service.com/app/note/995 をロードまたはリロードすると、以下のようにnote 995 のタイトルとサムネイル画像、<script> タグを含むHTTPレスポンスが返されるようにしたいわけです。


<html>
  <head>
    <base href="/app">
    <title>Note 995 title</title>
    <description>Note 995 description</description>
    <meta property="og:image" content="https://file-server.note995-image.png">
    <!-- injected script tags for bundle files -->
    <script src="https://file-server.bundle.a94.js"></script>
    <link href="https://file-server.bundle.vc0.css" rel="stylesheet"></link>
  </head>
  <body>
    (content here)
  </body>
</html>



目指すべきプロダクション環境の構成を図2に示します。 API サーバーは、HTTPレスポンスを生成する際に、指定されたページのタイトルとサムネイル URLと<script> タグを挿入します。 HTTP レスポンスは、図ではindex responseとして示されています。




図2: プロダクション環境で目指す構成



ローカル開発環境


まず、Webpackを使って典型的なローカルな開発環境を作りましょう。


SPA を開発する場合、図3 に示すように、Webpack dev serverを使用してWebアプリをローカルで開発します。 バンドルファイルを含むすべての静的ファイルは、ここから配布されます。 Webpack バンドラーは<script> タグを挿入します。



図3: 典型的なローカル開発環境のコンポーネント



webpack.config.js ファイルは、設定とオプションを含む Webpack の構成ファイルです。 webpack.config.js 内にプロキシーを設定して、SPA からのリクエストを localhost:8080 にある API サーバーに転送します。


  devServer: {
    historyApiFallback: true,
    proxy: [
        { context: [ '/api' ],
          target: 'http://localhost:8080,
          secure: false,
          changeOrigin: true,
        }]


historyApiFallback: trueは/api以外の全てのルートでindex.htmlを返すSPA固有の設定です。



ローカル開発環境での解決方法



図4: 改善したローカル開発環境でのコンポーネント



図4に、問題を改善したコンポーネントの構造を示します。


webpack.config.js ファイルに/appルートをAPIサーバーに転送するプロキシー設定を追加します。


たとえば、https://localhost/app/note/995 は https://localhost:8080/app/note/995 に転送し、note 995 のタイトルとサムネイル画像を返します。


    proxy: [
        { context: [ '/api', '/app' ],
          target: 'http://localhost:8080,
          changeOrigin: true,
        }]


API サーバーはバンドルファイルに関する情報を知りませんので、それを教えてやる必要があります。 バンドルファイルのマニフェスト ファイルを生成するWebpackManifestPluginをwebpack.config.jsに追加します。


  % yarn add WebpackManifestPlugin




  const { WebpackManifestPlugin } = require('webpack-manifest-plugin');
  ...
  plugins: [
      new WebpackManifestPlugin({
          fileName: 'app-bundle-manifest.json',
          writeToFileEmit: true,
          filter: (file) => {
              return file.isChunk;
          },
          sort: (fileA, fileB) => {
              if (fileA.path.endsWith('.js') && fileB.path.endsWith('.css')) return -1;
              if (fileA.path.endsWith('.css') && fileB.path.endsWith('.js')) return 1;
              if (fileA.path > fileB.path) return -1;
              return 1;
          },
          //seed: {
          //    version: 'local-dev-server-version'
          //}
      }),


このプラグインは必要なバンドルファイルを選択して、以下のようなapp-bundle-manifest.jsonファイルを生成します。


      {
        "app-a1d170d4.css": "/app-a1d170d4.d1dd426331d40f5930fe.bundle.css",
        "app-a1d170d4.js": "/app-a1d170d4.d1dd426331d40f5930fe.bundle.js",
        "runtime~app.js": "/runtime~app.d1dd426331d40f5930fe.bundle.js",
        "vendors-91c40cd8.css": "/vendors-91c40cd8.d1dd426331d40f5930fe.bundle.css",
        "vendors-91c40cd8.js": "/vendors-91c40cd8.d1dd426331d40f5930fe.bundle.js",
      ...


このJSON ファイルは HTTP リクエスト経由でアクセスできますが、ローカル開発時にはファイル システム上には存在しません。プロダクション用に SPA アプリをビルドすると、/dist ディレクトリに出力されます。


いずれの場合も、API サーバーは HTTP リクエストを通じてこのファイルを取得できます。



APIサーバー


以下は、HTTPレスポンスの生成に使用するindex.html ファイルのテンプレートです。 {{variables}} の表記は変数でJinja2 テンプレート・エンジンによって置き換えられます。なお、このindex.htmlをメインにして、ここからSPA側のindex.ejsを生成すれようにすれば一元管理ができます。


<html>
  <head>
    <base href="/app">
    <title>{{title}}</title>
    <description>{{desc}}</description>
    <meta property="og:image" content="{{image_url}}">     {{bundles}}   </head>  <body>
    ...
  </body>
</html>



以下にPythonでFalconとJinja2を用いたAPIサーバーの例を示します。なにかエラーがあるかもしれません。インポートしているライブラリをpipでインストールすることと、requests.get()のエラー処理が必要です。


import falcon
import requests
from jinja2
from markupsafe import Markup
is_local = True
file_server = 'https://localhost' if is_local else 'https://file-server.com'
jinja_env = jinja2.Environment(loader=jinja2.FileSystemLoader('.'))
class APIServer():
    ''' Generates the HTTP response for reloading. '''
    def on_get_api(self, req, resp):
        resp.body = 'API result'
        resp.content_type = 'text/html; charset=UTF-8'
    def on_get_note(self, req, resp, note_id):
        note = get_note_from_db(note_id)
        resp.body = jinja_env.get_template("index.html").render({
            'title':       note.title,
            'desc':        note.content,
            'image_url':   note.image_url,
            'bundles':     Markup(self.get_script_tags())
        })
        resp.content_type = 'text/html; charset=UTF-8'
    def get_script_tags(self) -> str:  # need caching
        response = requests.get(file_server + '/app-bundle-manifest.json', timeout=5, verify=is_local)
        js = css = ''
        for filename in json.loads(response.text).values():
            if filename.endswith('.js'):
                js += '    <script defer src="' + filename + '"></script>\n'
            if filename.endswith('.css'):
                css += '    <link href="' + filename + '" rel="stylesheet"></link>\n'
        return js + css
api_server = APIServer()
app = falcon.App()
app.add_route('/api, api_server, suffix='api')
app.add_route('/app/note/{note_id}', api_server, suffix='note')
def sink(req, resp):
    resp.body = jinja_env.get_template("index.html").render({
        'title':       'My SPA notes app',
        'desc':        'This is a notes app.,
        'image_url':   'https://file-server.image1.png',
        'bundles':     Markup(api_server.get_script_tags())
    })
    resp.content_type = 'text/html; charset=UTF-8'
app.add_sink(sink, prefix='/app')
if __name__ == '__main__':
    from wsgiref.simple_server import make_server
    with make_server('', 8080, app) as httpd:
        httpd.serve_forever()



APIServer.on_get_api() は/apiのAPIリクエストを処理します。上記では適当な文字を返しています。


APIServer.on_get_note() /app/note/995を扱います。指定のノートをDBから取得して、HTTPレスポンスを生成する際にタイトルとサムネイル画像のURLを挿入します。バンドルファイルをロードする<script>タグはファイルサーバーから得たapp-bundle-manifest.jsonから作ります。他のページのためにon_get()ハンドラーを複数追加することも可能です。


APIServer.get_script_tags()はファイルサーバーからapp-bundle-manifest.jsonを取得して、バンドルファイルをロードする<script>タグを作成します。


sink() は他の全てのルートを処理して、デフォルトのタイトルでHTTPレスポンスを生成します。


テストをするには、/app/note/995ページをリロードして、Chrome dev toolsのNetworkタブでHTTPのレスポンスを確認します。


プロダクション環境


ローカル版が動作したら、プロダクション環境に移行するのは容易です。上記の API サーバーで、実行環境に合わせていくつかの定数を調整すれば動作します。 /app/note/* のトラフィックを API サーバーに転送する図2のロードバランサーの設定例を以下に示します。これはGoogle Cloud Platform用です。


 pathMatchers:
    - name: Note API server
      routeRules:
        - description: Note API server
          matchRules:
            - pathTemplateMatch: '/app/note/{rest=**}'
          service: note-server
          priority: 1
          routeAction:
            urlRewrite:
              pathTemplateRewrite: '/app/note/{rest}'


さらなる改善


生成するindexレスポンスに、ノートのコンテンツを含めることができます。このコンテンツは SPAを使っているユーザーには表示されませんが、検索エンジンには見える可能性があります。確認はしていませんが、一部の検索エンジンではタイトルとコンテンツがインデックスされることが期待できます。検索エンジン最適化 (SEO) として、検索エンジンを通じてコンテンツを見つけやすくなります。


改善されたindex.htmlとAPIサーバーの一部を示します。



    <body>
      <div>
        {{content}}
      </div>
    </body>


    def on_get_note(self, req, resp, note_id):
        note = get_note_from_db(note_id)
        resp.body = jinja_env.get_template("index.html").render({
            'title':       note.title,
            'desc':        note.content,
            'image_url':   note.image_url,
            'bundles':     Markup(self.get_script_tags()),
            'content':     note.content
        })
        resp.content_type = 'text/html; charset=UTF-8'



プロダクション環境で使用するには、app-bundle-manifest.json をキャッシュした方がベターです。


ファイル サーバーへのバンドルファイルのデプロイと API サーバーのデプロイは別々に行われる可能性があります。SPAアプリの新しいバージョンがデプロイされると、バンドルファイルは変更されます。サーバーは新しいバンドルファイルを認識せず、有効ではなくなったバンドルファイル名を含む古い app-bundle-manifest.json を使用し続けてしまいます。


したがって、app-bundle-manifest.json のキャッシュを設計するときは、デプロイの時間のずれを正しく処理する必要があります。これは難しいかもしれませんが、可能です。


おわりに


プロキシーの設定、API サーバー、テンプレートエンジンを組み合わせて使用することで、SPAの各ページに正しいタイトルとサムネイル画像を挿入できます。


運用には、app-bundle-manifest.json のキャッシュやデプロイ間の時間のずれの処理など、いくつかの課題があるかもしれませんが、優れたユーザーエクスペリエンスのために努力する価値はあります。