Vue+Firebase+Hosting+FunctionsでOGP対策

# SPA における OGP 対策

皆さん SPA 作ってますか！

私は 2020 年の年始から SPA の勉強を始めています。

勉強をすすめに連れて、ページ遷移するたびに更新が走る web サイトにストレスを感じるようになってきました。開発者でなくても現代のリッチな web サイトにおいてページ遷移に 1 秒程度であろうともそれぐらいの時間がかかろうものなら「おそっ」と思ってしまう人も多くなってきたのではないでしょうか。

本記事では

- SPA の勉強をしている人
- SPA でブログを運営しようと考えてる開発者

以上の人を対象に、SPA の弱点である OGP 対策のうちの解決策の１つを提供します。

また、本記事では `Vue + Firebase + Hosting + Functions` を扱っていますが、OGP 対策のための根幹となる部分は変わらないのでご心配しなくても大丈夫です。

## SPA における OGP 対策手法

SPA において OGP 対策は切っても切り離すことができない問題です。

ちなみに OGP([Open Graph protocol](https://ogp.me/)) を知らない人は以下のような画像をお見せすれば理解できるでしょうか。

![OGPの例](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/146910/fe4363f2-7048-e087-41b9-de8203ee9cd0.png)

Twitter なら Twitter のクローラー用の bot が、 Facebook なら Facebook のクローラー用の bot が html の meta タグから必要な情報を取得してくるのです。

```html
<meta property="og:title" content="Sample" />
<meta name="description" content="Sample Description Body" />
<meta property="og:type" content="website" />
<meta property="og:url" content="http://sample.hoge.sample.com/" />
<meta property="og:image" content="http://sample.hoge.sample.test.jpg" />
<meta name="twitter:card" content="summary" />
<meta property="fb:app_id" content="数字の列挙" />
```

上記は OGP の一例です。SPA において OGP 対策のこと知らずに開発している人や、とりあえず作ってみた系の時には `public/index.html` のような、ルートとなる index ファイルの中身に上記のような meta タグを仕込んで Hosting しておけばリンクを貼った時にいい感じにグラフィカルな URL を作ることができていたのではないでしょうか。

但し、SNS などがそのサイトへの流入の手段として大きい場合、各ページごとに OGP の中身を設定してリンクごとに生成されるグラフィックや文言が変わって欲しいのが普通だと思います。

それらを解決する手段として、現状以下の方法に大別されることになると思います。

1. [Server Side Rendering](#ssr)
2. [Prerendering](#prerendering)
3. その他(本記事では [Functions](#functions) を扱います)

私が OGP 対策をする際に考慮したのは以下のようなポイントです

- 難易度
- ブログ部分のページごとに個別 OGP を設定できること
- 継続的に運用のために時間が取られないこと

本記事では SSR と Prerendering に関しては実行コードないので、結論だけ知りたい人は、[Functions](#functions)へレッツゴーです。

### SSR

SSR(Server Side Rendering)のメリット

- 柔軟に設定できる
- ファーストビューまでが最速

一見メリットしか感じないような SSR ですが、小規模なサイトにおいてはオーバーテックだと言わざるを得ないです。

Vue で SPA を作り Firebase に Hosting した人は、フロントで完結することに歓喜していたはずです。私は歓喜しました！

なのに、OGP のためだけにサーバーを立ち上げるのは辛すぎます。

先ほど、OGP 対策時に考慮したポイントとして、 `難易度`、 `個別OGP設定` `継続的な運用の時間が取られない` を上げましたが、このうち難易度と継続的な運用の時間が取られないことに関しては、SSR を選択した場合は怪しいです。習熟している人なら全く問題がないかもしれないですが、私にとっては習得までの間にブログ運営が辛くなってやめたくなることが想像できたのでこの選択肢は却下です。

もし、詳しく調べてみたい人がいたら、[Vue.js サーバサイドレンダリングガイド](https://ssr.vuejs.org/ja/#%E3%82%B5%E3%83%BC%E3%83%90%E3%82%B5%E3%82%A4%E3%83%89%E3%83%AC%E3%83%B3%E3%83%80%E3%83%AA%E3%83%B3%E3%82%B0-ssr-%E3%81%A8%E3%81%AF%E4%BD%95%E3%81%8B) を公式がドキュメントを作ってくれていますので、確認してみるといいかもしれません。

### Prerendering

次に Prerendering のメリット

- 難易度低い（かも）
- 個別に設定可能

`prerender-spa-plugin`というプラグインを使うことで比較的簡単に OGP の設定が可能なように感じました。

参考：[Vue.js のプリレンダリングで手軽な OGP 対応](https://qiita.com/mio3io/items/bd2d91fc2a7785f9022c)

が、個別のページを設定するために、ページ単位で自分でタイトルや詳細メッセージを変えていくのは、`継続的に運用のために時間が取られないこと`に反してしまったので却下しました。(もしかしたら自動で設定できたのかもしれないですが、判断した時点では知識が足らずこの方法を選択することができませんでした。もし便利な方法や記事があれば教えて欲しいくらいです。)

### Functions

そして、最後に `その他` の方法を説明しようと思います。

本記事では Firebase の Functions を利用することにしています

`functions/index.js`にて、以下のように API などによってブログの内容を取得し、そのタイトルやボディ部分を使って meta タグ設定つきの HTML を作成するようにしています。

ちなみに、Vue は公式が [ButterCMS とのブログ連携](https://jp.vuejs.org/v2/cookbook/serverless-blog.html)をドキュメントとして残しているので、ブログの内容取得部分を参考にしてみると良いかもしれません。

```js
const functions = require("firebase-functions");
const fetch = require("node-fetch");

function buildHtmlWithPost(id, obj) {
  return `<!DOCTYPE html><head>
<meta name="description" content="${obj.title}" />
<meta property="og:site_name" content="Sample Blog" />
<meta property="og:title" content="${obj.title}">
<meta property="og:description" content='${truncate(
    removeHTMLTag(obj.body),
    150
  )}' />
<meta property="og:url" content="http://sample.hoge.sample.com/" />
<meta property="og:image" content="${obj.thumbnail.url}">
<meta property="og:type" content="website" />
<meta property="og:locale" content="ja_JP" />
<meta name="twitter:card" content="summary" />
<meta name="twitter:title" content="${obj.title}" />
<meta name="twitter:image" content="${obj.thumbnail.url}" />
<link rel="canonical" href="/note/${id}">
<link rel="icon" href="<%= BASE_URL %>favicon.ico" />
<title>${obj.title}</title>
</head>
<body>
  <script>
    window.location = "/_note/${id}";
  </script>
</body>
</html>`;
}

function truncate(str, len) {
  return str.length <= len ? str : str.substr(0, len) + "...";
}

function removeHTMLTag(str) {
  return str.replace(/<("[^"]*"|'[^']*'|[^'">])*>/g, "");
}

exports.note = functions.https.onRequest((req, res) => {
  const path = req.path.split("/");
  const noteId = path[path.length - 1];
  fetch(`何かしらのAPIのURL`, {
    headers: {
      "SOME-API-KEY": functions.config().vue.app.same.api.key
    }
  })
    .then(response => response.json())
    .then(result => {
      // APIのレスポンス結果からHTMLを作成して、Clientに返却する
      const htmlString = buildHtmlWithPost(noteId, result);
      res.set("Cache-Control", "public, max-age=3600, s-maxage=3600");
      res.status(200).end(htmlString);
    })
    .catch(err => {
      res.status(500).end(err);
    });
});
```

Functions へのリクエストは、Hosting にて設定することが可能です。
`rewrites`を用いて、`/note/~`に対するリクエストがきたら、Functions の note 関数へとリクエストを送ります

`/note/~`以外の URL へのリクエストの場合は index.html を返すようにしています

```json
"hosting": {
    "public": "dist",
    "ignore": ["firebase.json", "**/.*", "**/node_modules/**"],
    "rewrites": [{
        "source": "/note/**",
        "function": "note"
      },
      {
        "source": "**",
        "destination": "/index.html"
      }
    ]
  },
```

`src/router/index.js`に以下のようなパスを設定することで、functions で設定した `window.location = "/_note/${id}"`を通常の /note/\${id} へのコンポーネントへのリンクとしてリダイレクトしてくれます

また、Router の mode に`history`を設定することも忘れないでください

```js
const routes = [
  {
    path: "/",
    component: App
  },
  {
    path: "/note/:id",
    component: Note
  },
  {
    path: "/_note/:id",
    redirect: "/note/:id"
  }
];

const router = new VueRouter({
  mode: "history",
  routes
});
```

この対応によって、直接リンクが踏まれた際に Functions にて生成された ogp 用の meta タグ付きの HTML を生成して bot に渡すことができるようになりました。

## まとめ

本記事では `Vue + Firebase + Hosting + Functions` によってブログページ個別に OGP を設定することができました。

もし、今後、SSR か Prerender に置き換えるような時が来れば、それも記事にして参考になるようにしたいと思います。

正直ベストプラクティスには思えていないので、`難易度`、`個別OGP設定可能`、`運用が楽`の 3 つが揃うものがあればぜひコメントいただけると嬉しいです。