Vue-ServerでVue.js SPAのSEO対応


Vue-ServerでVue.js SPAのSEO対応

今回はVue ServerというVue.js用サーバーサイドレンダリングのモジュールを実践で使ってみたので、メモです。
VueServer自体の説明は今年のAdvent Calendar初日の @kazupon さんの記事を参照してください。

VueServer.js の紹介
http://qiita.com/kazupon/items/e401e9d6d07a74e6297d

Vue Serverを使用するまでの経緯

最近、以下のような遷移パターンを持つSPA Webアプリを実装していてServer Side Renderingの導入を検討していました。

作っていたWebアプリの特徴

  • リストビューと詳細ビューがある
  • リストビューでアイテムをクリックするとHistory APIを使って詳細ビューへ遷移する。その時のURLが /detial/:id に変化する (Client Side Routing)
  • /detail/:id と直接URLが入力された場合はサーバーがSEOを考慮してClient Side Routingで生成されるHTMLと同じ(ような?)ものを出力したい

作っていたアプリの挙動としてはInstagramのweb版と近いイメージです。

Instagramでは写真クリックするとpopupが出てきて、History APIでURL書き換えて、データをAPIで取得してclient側でrenderingしています。そこでページをリロード(もしくは直接アクセス)すると今度はサーバーが情報を既につめたHTMLを返します。SPAとしてのユーザビリティを確保した上で、URLのShareとSEOをケアできる、最近よく見かけるパターンだと思います。

Instagram Web版でClient Routingしたページをリロードして、同一URLでサーバーがHTMLを返す例
instagram_routing.gif

History APIを使ったSPAの実装方針は Vue-Router 使ってもいいし、Director などを使ってもいいと思います。
そこらへんについては今回は特に詳しく話しません。

今回作ったWebアプリの実装のアプローチとしては以下の3つを検討していました。

  1. /detail/:id でアクセスが来た場合は、サーバーはトップページを返し、client側で一度routingしてあげる -> 一番手間がかからない方法だが、SEOを諦めることになる
  2. /detail/:id でアクセスが来た場合は、詳細ページをexpressのjade/ejsで生成して返す -> SEO対策はできるが、テンプレートを2重管理することになる
  3. /detail/:id でアクセスが来た場合は、サーバー側でJSを実行(Server Side Rendering)して返す -> テンプレートも再利用できるので、一番良さそうだけど、敷居が高そう(?)

ちょうどタイミング的にもVueServerなんてものも出たし、Server Side Renderingが一番良さそうな選択肢だとして採用しました。

結論としてはすごく簡単に導入できました。以下、簡単なサンプルを元に導入手順を紹介します。

使用時の注意点

vue-serverのREADMEにも記述がある通り幾つかの制約があります。

VueServer.js
https://github.com/ngsru/vue-server

VueServer.js is designed for static HTML rendering. It has no real reactivity.

また、VueServerはvue.jsの仕様に沿った独自の実装をしており、本家とは幾つかの違いがあるようです。

  • Hookが異なる
  • 特定のmethodが使えない
  • 特定のdirective (v-on, v-el) が使えない

特にこのv-on, v-elが使えないというところは注意が必要です。つまり一切ユーザーからのイベントによるインタラクティブな実装はできません。
(将来的には、Reactみたいにクライアント側でコンポーネントを復元する仕組みができれば解決できるのかもしれないですね)

事前準備

  1. expressが動作していること
  2. vue-serverをインストール

$ npm install vue-server --save

コード例

静的なVueコンポーネントを定義して、クライアント側とサーバー側で同じテンプレートを使用する非常に簡単な例を紹介します。

ディレクトリ構成

とりあえずファイルはこんな感じで配置(解説に必要な部分だけ)

- sample-vue-server
  - src
    - filters.js <-- Vueのfilter定義
    - components
      - detail.vue <-- vueify/vue-loader component
  - routes
    - index.js
  - views
    - index.ejs <-- SPAで使用されるサーバーテンプレート
    - detail.ejs <-- 詳細ページのサーバーテンプレート
    - tmpl
      - detailTmpl.js <-- 共通Vueテンプレート

テンプレートファイルの生成

タイトルと住所と電話番号をただ表示するだけのめちゃくちゃ単純な静的コンポーネントを作ります。

クライアント側が vueify / vue-loader を使った.vueファイルで書いていて、そこで読み込むテンプレートとexpressサーバー側で読み込むテンプレートを共通化するために、今回はES6 Template Stringを使用してexportするだけのファイルを別で用意しました。(.vue loaderをnode側に組み込んで使う方法もあると思います。)

views/tmpl/detailTmpl.js
'use strinct'

module.exports = `
  <div class="component__detail">
    <h1>{{item.title}}</h1>
    <div>
      <label>住所</label>
      <span>{{item.address}}</span>
    </div>
    <div>
      <label>電話番号</label>
      <span>{{item.phone}}</span>
    </div>
  </div>
`

クライアント側

今回はクライアント側のVueファイルはVueServer.jsのDemo動作に必要ありませんが、Server/Client共通のテンプレートを使用したい場合は以下のように読み込みます。(特にクライアント側コンポーネントの使用例は紹介しませんが、SPA実装のroutingなどをトリガーにclient side renderingされる想定です。)

src/components/detail.vue
<script lang="babel">
import tmpl from '../../views/tmpl/detailTmpl'

export default {

  // use server side template
  template: tmpl,

  // <component-detail :item="item"></component-detail> で外から指定できるようにclient側はdataではなくpropで受け取  props: {
    'item': {
      type: Object,
      default: null
    }
  }
}
</script>

サーバー側

expressでVue-Serverを読み込んでServer Side Renderingして返します。ここがまさにServer Side Renderingしているところ。

routes/index.js
'use strict'
const express = require('express')
const router = express.Router()
const vueServer = require('vue-server')
const detailTmpl = require('../views/tmpl/detailTmpl')
const filters = require('../src/filters')
const Vue = new vueServer.renderer()

router.get('/detail/:id', (req, res, next) => {
  const id = req.params['id']
  if(!id) { return next() }

  const fetch = () => {
    return new Promise((resolve, reject) => {
      // DBなどからfetchする想定
      setTimeout(() => {
        resolve({
          id: id,
          title: 'ゔえサーバーサンプル',
          address: '東京都渋谷区渋谷x-x-x',
          phone: '0123-xxx-xxx'
        })
      }, 25)
    })
  }

  // server side rendering
  const renderer = (item) => {
    return new Promise((resolve, reject) => {
      const vm = new Vue({
        template: detailTmpl,
        data() {
          return {
            item: item
          }
        },
        // filterがもし必要であれば再利用できる
        filters: filters
      })
      vm.$on('vueServer.htmlReady', (html) => {
        resolve(html)
      })
    })
  }
  fetch().then(renderer).then((html) => {
    res.render('detail', {
      html: html
    })
  }).catch((err) => {
    return next(err)
  })
})

あとはserverでrenderしたHTMLをejsに埋め込んで返す。

detail.ejs
<!DOCTYPE html>
<html lang="en">
<head></head>
<body>
  <main>
    <%- html %>
  </main>
</body>
</html>

結果表示

ソースを見るとHTMLが以下のような感じになってます。

出力結果
<!DOCTYPE html>
<html lang="en">
<head></head>
<body>
  <main>
       <div class="component__detail">     <h1>ゔえサーバーサンプル</h1>     <div>       <label>住所</label>       <span>東京都渋谷区渋谷x-x-x</span>     </div>     <div>       <label>電話番号</label>       <span>0123-xxx-xxx</span>     </div>   </div>
  </main>
</body>
</html>

以上、上記アプローチで静的コンテンツのみを持つコンポーネントに関してはOKです。
返すページの中で動的なコンポーネントが必要であれば、Server Side Renderingをしない状態のVueコンポーネント(宣言)を返してあげて、SPAの実装と同様にクライアント側でrenderingする方針で対応できます。

まとめ

  • Vue Serverを使うと、Vue.js SPAで思ったより簡単にSEO対策できた
  • テンプレートの2重管理も避けられた
  • 本当はGoogleさんがSPAの各ページを認識してくれたらいいんだけどなぁ