JavaScript
vue.js
nuxt.js

Vue.jsで構築したSPAをNuxt.jsで書き直して静的に出力するときにコケたところ

「これからはフロントエンドでガンガンやろうぜ」という勢いでSPAという言葉が歩き始め、やっぱり初期表示の速度とSEOのためにはサーバーサイドでレンダリングした方が良いよねというのが一般的になり、しまいにはそれを静的に出力するとなれば、もう最初からHTMLで良くないかという考えが頭をよぎりつつも、HTMLの速さとSPAの身軽さを兼ね備えた素晴らしい世界に足を踏み入れたと前向きにとらえて、Vue.jsで構築したSPAをNuxt.jsの静的出力に切り替えてみた際のメモです。

Nuxt.jsとはいったい

Nuxt.js - ユニバーサル Vue.js アプリケーション

Vue.jsでSSRを手軽にできて、環境構築も良い感じにやってくれるフレームワーク。静的なファイル生成もできるぞ。Reactを知ってる人にはNext.jsみたいやつで伝わる。いさぎよい名前。

フレームワークを使うと、面倒な制約が増えるし流行り廃りがあるからなあと思って避けていたのですが、思いのほか制約が少なかったので、こんなことなら最初からNuxt.jsを使っておけばよかった。

なぜ、わざわざNuxt.jsで書き直すのか

手段も目的もクソもないですが、Nuxt.jsの静的ファイル生成を試してみたかったので書き直しました。クソといえば、最近公開された孤狼の血という映画でたくさんクソが出てきて面食らいました。役所広司さんがかっこいい。

動的と静的

動的 静的
代表 WordPress MovableType
パフォーマンス ×
手軽さ ×
柔軟性 ×

ふだん受託でWebサイトを作っている人には、動的がWordPressで、静的がMovable Typeというのがしっくりくると思いますが、それぞれ一長一短あります。

やり方によっては、WordPressで静的にファイルを生成したり、Movable Typeを動的に使ったりもできますがそこには触れない方向で。

パフォーマンスだけをみれば、シンプルにHTMLを返す静的に軍配が上がりますが、コンテンツを更新する度にファイルを再生成する必要があるので手間がかかります。また、アクセスされる度に内容が変わるサイトなどは、そもそも静的に向かないので、どんなサイトにも対応できるわけではありません。

さらにnuxt generateで生成、CDNでホストされたEコマースのウェブアプリケーションを考えてみましょう。このアプリケーションは商品が在庫切れもしくは再入荷されるたびに再生成されます。ユーザーがアプリケーションを遷移している間に、在庫の状態が(再生成のおかげで)最新になるのです。もはや、サーバーで複数のインスタンスやキャッシュを持つ必要はないのです!

はじめに

Nuxt.jsのはじめにで、在庫の状態が変わるごとにファイルを再生成するECサイトの例が紹介されていますが、さすがにハードコア過ぎる気がするので、動的な方が扱いやすいものについては、無理して静的化しないほうが自然だと思います。

Nuxt.jsで静的生成するメリット

初期表示が速くなる

SSRをせずにSPAを作成した場合、巨大なJSが読み込まれて実行されるまで、ページに何も表示されないのがネックになることが多いです。それが、Nuxt.jsでSSRや静的生成をしたら解消できます。静的生成の場合は、SSRよりもレイテンシが短いのでより高速です。

メタ情報をページ毎に設定できる

we decided to try to understand pages by executing JavaScript. It’s hard to do that at the scale of the current web, but we decided that it’s worth it.

Understanding web pages better

GoogleのクローラーはJSを実行してくれますが、FacebookやTwitterのクローラーは、今のところそこまではやってくれないようです。Nuxt.jsでページ毎にメタを設定しておけば、その問題も解消できます。

サーバーの準備が楽

SSRをする場合は、Node.jsなどのバックエンドを用意する必要がありますが、静的にHTMLを吐き出した場合は特別な準備が必要ありません。S3などにアップするだけで動くので、改修の手間を抑えられます。

Nuxt.jsを使うデメリット

Nuxt.jsの、というよりフレームワークのデメリットになりますが、一定のルールに従うことで手軽に書ける一方で依存が強くなってしまうので、何らかの理由でフレームワークを変えるor辞める必要が出てきたときに大変な思いをします。

この点については、クセが強いルールがルーティングの自動生成ぐらいしかなかったので、メリットの方が大きいと判断しました。最初からNuxt.jsを使っておけばよかった。

移行後の主な変更点

pagesディレクトリの構成でルーティングされる

pagesディレクトリの構成によってルーティングが自動生成されるので、vue-routerで定義していた内容を、pagesに移行する必要があります。ページ数が多かったら大変そう。

storeディレクトリがVuexとして扱われる

storeディレクトリからはVuexのインスタンスがエクスポートされる事が前提になっています。現状、storeというディレクトリ名でVuexを使用していないので、たぶん怒られる。

ビルドをNuxt.jsに任せる

いま使っているWebpackの設定はゴミ箱に捨てて、Nuxt.jsに任せます。

vue-routerをpagesに合わせて変更

Nuxt.jsではpagesディレクトリに各ページのパス名で.vueファイルを設置するだけで、自動的にvue-routerの設定を生成してくれます。ネストしたルートや動的なルートも設定できるので詳しくはドキュメントで。こんなかんじの構成にすれば良い感じにやってくれるようです。

ルーティング

pages/
  ├ ec2.vue   # /ec2 
  ├ rds.vue   # /rds
  └ index.vue # /

こんな感じのルーターとコンポーネントを

router/index.js
import Vue from 'vue'
import Router from 'vue-router'
import ServiceEC2 from '@/components/service/ServiceEC2'
import ServiceRDS from '@/components/service/ServiceRDS'

Vue.use(Router)

export default new Router({
  routes: [
    { path: '/ec2', component: ServiceEC2 },
    { path: '/rds', component: ServiceRDS }
  ]
})

components/service/ServiceEC2
<template>
  <ServiceTemplate service-name="ec2">
    <section class="section">
      <h2 class="title">概要と料金</h2>
      <p class="text">スペックや台数が柔軟に変更できる仮想サーバーです。</p>
      <p class="text">主に、インスタンスのスペックや利用する台数...</p>
    </section>
  </ServiceTemplate>
</template>

<script>
import ServiceTemplate from '@/components/service/template/ServiceTemplate'

export default {
  name: 'ServiceEC2',
  components: {
    ServiceTemplate
  }
}
</script>

pagesに移動する

ファイル名を変えて移動しただけでいけました。

pages/ec2.vue
<template>
  <ServiceTemplate service-name="ec2">
    <section class="section">
      <h2 class="title">概要と料金</h2>
      <p class="text">スペックや台数が柔軟に変更できる仮想サーバーです。</p>
      <p class="text">主に、インスタンスのスペックや利用する台数...</p>
    </section>
  </ServiceTemplate>
</template>

<script>
import ServiceTemplate from '@/components/service/template/ServiceTemplate'

export default {
  name: 'ServiceEC2',
  components: {
    ServiceTemplate
  }
}
</script>

Nuxt.jsと重複するパッケージを削除する

Nuxt.jsのpackage.jsonを見ると、vuevue-routerなどがいろいろと含まれているので、プロジェクトから不要になるパッケージを削除しておきます。ビルドもNuxt.jsに任せることになるので、webpackなども削除します。

$ yarn remove vue vue-router webpack ...

srcの中身をひとつ上の階層に

もともとはsrcをdistへ出力する構成だったのですが、Nuxt.jsではroot直下に色々と置く構成のようなので、バコッと移動しました。設定ファイルで srcDir を指定すれば変更しなくてもいけますが、流儀に従っておいたほうがわかりやすかったので合わせました。

srcDir プロパティ

Nuxt.jsのインストールして立ち上げる

インストールしてスクリプトを設定します。最初からSSRをする気はなかったので、spaモードに設定しておきます。

インストール

$ yarn add nuxt
$ yarn dev
package.json
{
  "scripts": {
    "dev": "nuxt --spa",
    "generate": "nuxt generate"
  }
}

Vuexでコケる

# Chromeのコンソールにて
Uncaught Error: [nuxt] state should be a function in store/index.js

コケるもなにも、Vuexを使っていないので当たり前だけどもエラーが出た。もともとシンプルなstoreパターンで作っていてVuexは使っていなかったので、

  • Vuexで書き直す
  • ディレクトリ名をstoreから変更する

のどちらかで対応する必要がありますが、このためにVuexを導入するのはなんか違う気がしたので、ディレクトリ名をstoresに変更したらうまくいきました。その場しのぎ。

Vuexストア

SVGでコケる

Nuxt.jsをインストールする前に色々とパッケージを削除した際に、SVGのローダーも削除してしまったので入れ直します。設定ファイルのbuildプロパティで、Nuxt.jsのWebpack設定を拡張できます。コンポーネントとしてSVGを埋め込みたかったので、vue-svg-loaderを使っています。

buildプロパティ

$ yarn add vue-svg-loader
nuxt.config.js
module.exports = {
  build: {
    extend(config) {
      config.module.rules = config.module.rules.map(rule => {
        if (rule.loader === 'url-loader' && rule.test.toString().includes('svg')) {
          return {
            ...rule,
            test: /\.(png|jpe?g|gif)$/
          }
        }
        return rule
      })

      config.module.rules.push({
        test: /\.svg$/,
        loader: 'vue-svg-loader'
      })
    }
  }
}

Sassでコケる

こちらもローダーなどを追加して、設定ファイルにコンパイルするScssファイルを設定しました。

CSSプロパティ

$ yarn add node-sass sass-loader
nuxt.config.js
module.exports = {
  css: ['@/assets/scss/index.scss']
}

layoutsの設定

初期設定ではレイアウトがかなりシンプルになっているので、移行前にレイアウト用に使っていた内容に差し替えます。<nuxt/>のところにpagesのコンポーネントが入るので、router-viewのようなものと思って差し支えはなさそう。

レイアウト
nuxtコンポーネント

layouts/default.vue
<!-- デフォルトはシンプル -->
<template>
  <nuxt/>
</template>
layouts/default.vue
<!-- router-viewをnuxtに変える -->
<template>
  <div class="app">
    <div class="container">
      <LayoutMenuPC />
      <LayoutMenuSP />
      <div class="contents">
        <nuxt/> <!-- <router-view /> -->
        <LayoutFooter />
      </div>
    </div>
  </div>
</template>

<script>
 // いろいろあるけど省略
</script>

headの設定

Nuxt.jsでは、SSRやページ遷移の際にvue-metaでheadを切り替えてくれるので、その設定をしていきます。共通の設定はnuxt.config.jsで行い、各ページのhead()titledescriptionなどを設定します。メタが散らばると編集するのが面倒なので、ひとつのファイルにガッとまとめました。

静的に出力しているので当たり前ですが、TwitterやFacebookでも正しく下層ページのメタを認識できていました。

nuxt.config.js
module.exports = {
  head: {
    meta: [
      { charset: 'utf-8' },
      { name: 'viewport', content: 'width=device-width, initial-scale=1' },
      { name: 'theme-color', content: '#ffffff' },
      { name: 'twitter:card', content: 'summary_large_image' },
      ...
    ],
    link: [{
      rel: 'stylesheet',
      href: 'https://fonts.googleapis.com/css?family=Lato:400,700|Ramabhadra'
    }]
  },
}
config/meta.js
export default {
  index: {
    title: 'トップページのタイトル',
    meta: [{ hid: 'description', name: 'description', content: 'トップページのdescription' }]
  },
  ec2: {
    title: '下層ページのタイトル',
    meta: [{ hid: 'description', name: 'description', content: '下層ページのタイトル' }]
  },
  ...
}
pages/ec2.vue
<template>
  <ServiceTemplate service-name="ec2">
    ...
  </ServiceTemplate>
</template>

<script>
import meta from '@/config/meta'

export default {
  head() {
    return meta.ec2
  }
}
</script>

実際はogのtitleやdescriptionなども色々設定しているので、詳しくはソースで。
config/meta.js

<router-link><nuxt-link>に書き換える

現在のところ<nuxt-link><router-link>と同じです。将来においては、Nuxt.js アプリケーションの応答性を改善するためにバックグランドでプリフェッチするような機能を nuxt-link コンポーネントに追加する予定です。

nuxt-link

同じなんかーい。
将来的に拡張があるかもしれないとのことなので、置換しておきます。ついでに、ルーターの設定を追加しておきました。

nuxt.config.js
module.exports = {
  router: {
    linkActiveClass: 'is-active',
    linkExactActiveClass: 'is-active-exact'
  }
}

routerプロパティ

静的ファイルを出力

$ nuxt generate

コマンド

nuxt generateコマンドを実行すると静的ファイルをdistディレクトリに出力してくれます。12ページで30秒ぐらいでした。

中身が気になったので、出力されたファイルを確認しました。下に記載しているのは主なファイルなので、他にも色々と出力されています。各ページのHTMLがindex.htmlとして出力され、JSもページ毎に分割されているのがわかります。

app.jsに共通の処理が、vendor.jsに共通のライブラリなどが出力されているようです。

# 実際はJSファイルにハッシュ値がついていますが見づらいので省略しています
dist/
  ├ _nuxt/
  │  ├ pages
  │  ├  ├ ec2.js
  │  ├  ├ rds.js
  │  ├  └ index.js
  │  ├ app.js
  │  └ vendor.js
  ├ ec2/
  │ └ index.html
  ├ rds/
  │ └ index.html
  └ index.html

ファイルの最適化

JSファイルを良い感じに分割してくれるのはありがたいのですが、各ページのJSのファイルサイズが大きい気がしたので、ビルドされたJSの中身を確認しました。ビルドのコマンドに解析用のオプションを付けると、視覚的な解析結果をブラウザで確認できます。

Webpack Bundle Analyzer

$ nuxt build --analyze

Lodashの存在感よ。
各ページにLodashが含まれてしまっているので、ビルド時にvendor.jsへまとめます。

nuxt.config.js
module.exports = {
  build: {
    vendor: ['lodash']
  }
}

buildプロパティ#vendor

これでLodashをvendor.jsにまとめられましたが、身に覚えがないmomentが鎮座している。調べてみると、chart.jsにバンドルされていたのですが、解決策がすぐには浮かばなかったので、悔しいですがスルーしました。

JSファイルはトータルでgzip圧縮後に300KBぐらいでした。もう少し依存パッケージを減らしたい...

デプロイ

あとは出力したファイルをS3へデプロイしたら移行完了です。htmljsにそれぞれキャッシュ期間を設定してsyncして、CloudFrontのキャッシュをクリアしています。

このサイトは、AWSの簡易見積もりをできるサイトなのでAWSを使っていますが、GCP/Github Pages/Heroku/netlifyなどへのデプロイ方法も調べればたくさん出てきます。

deploy.sh
npm run generate

aws s3 sync ./dist/ s3://aws.noplan.cc --exact-timestamps --delete --exclude "*" --include "*.html" --cache-control no-store
aws s3 sync ./dist/ s3://aws.noplan.cc --exact-timestamps --delete --exclude "*" --include "*.js" --cache-control max-age=31536000
aws cloudfront create-invalidation --distribution-id HOGEHOGE--paths "/*"

何回かコケたけどスムーズに移行できた

ざっくりAWS

いくつかコケたポイントはありましたが、すべて身に覚えがあるものだったので、スムーズに移行できました。細かい調整も含めると1.5日ぐらいかかってしまいましたが、そのぶん表示がかなり速くなったので満足です。

Nuxt.jsはVue.jsのベストプラクティスを集めてルーティングやSSRを良い感じにやってくれるフレームワークなので、Vue.jsを使う場合はNuxt.jsの上で開発したほうが短時間でパフォーマンスの高いサイトが作れそうだな、というのが今のところの感想です。Vue.jsと共通しているところですが、とにかくドキュメントが読みやすい。

使いやすいフレームワークの裏側には、使いやすさのために苦心しているすごいプログラマーがいるわけで、そういう人の頭の中はどうなっているんだろうかと純粋に気になるけれど、作る側と使う側には大きな隔たりがあるなあと他人事のように思ってしまう。いつかは携わってみたいものです。

ソース
https://github.com/noplan1989/aws-rough