GAミント至上主義

安くて速いが好きなWEBアプリ開発者。最近はPython, Vue.js, Firebase, GKE。@株式会社ビズオーシャン

vue-router + Firebaseで404ページをちゃんとやる方法を考える

Vue.js も SPAも Firebaseも初めてなので、WEB Frameworkでやるような404をどうすればできるか考えてやってみたメモ。

Vue.jsは下記のPWAのテンプレートを利用し、vue routerも使っている
github.com

routerで指定していないパスで404を表示

これは簡単だった。
routesの最後にキャッチオール的に404、NotFound用コンポーネントを指定して表示させる
stackoverflow.com

const ROUTER_INSTANCE = new VueRouter({
    mode: "history",
    routes: [
        { path: "/", component: HomeComponent },
        // ... other routes ...
        // and finally the default route, when none of the above matches:
        { path: "*", component: PageNotFound }
    ]
})

しかし、これだけだとパスにID等を含めた動的ルートマッチングの場合はできない。

動的ルートマッチングで404を表示する

router.vuejs.org

例えば今回だと

 path: '/format/:id/edit/:cardId',

のようなpathがあり、format idと、card idと二つの変数が含まれていた。

idと
cardIdは、別のJSONファイルに含まれており、コードでは表現できない。

最初表示した先のコンポーネントのmountedでどうにかしようと思ったけど、タイミング等によって出るエラー対処が面倒くさすぎてrouter側で事前にやってしまうのがいいと実感。

そのためにはナビゲーションガードのルート単位ガード、beforeEnterを使用する。

router.vuejs.org
router.vuejs.org

今回はformatExistsOr404 という名前で関数を準備した

# コンテンツが入ったJSONファイル
import formatsData from '@/assets/formats/index'

const SITE_TITLE = 'サイト名'
const NOT_FOUND_PATH = '/404'

const formatExistsOr404 = function (to, from, next) {
  if (!to.params.id) {
    next({name:'notFound'})
    return
  }
  const id = to.params.id
  let formatResult = formatsData.filter(
    function (format) {
      if (String(format.id) === String(id)) {
        return true
      }
    }, this
  )
  if (!formatResult.length) {
    next({name: 'notFound'})
    return
  }
  if (!to.params.cardId) {
    next()
    return
  }
  const cardId = to.params.cardId
  let cardResult = formatResult[0].cards.filter(
    function (card) {
      if (String(card.id) === String(cardId)) {
        return true
      }
    }, this
  )
  if (!cardResult.length) {
    next({name: 'notFound'})
    return
  }
  next()
}

const router = new Router({
  mode: 'history',
  routes: [
  {
      path: '/format/:id/edit/:cardId',
      component: formatEdit,
      props: true,
      name: 'formatEditCard',
      beforeEnter: formatExistsOr404,
      meta: {
        title: '書式編集 - ' + SITE_TITLE
      }
    },
// 省略

まだ書き慣れてなくて汚いけど、toのparamsに含まれる変数を見て、コンテンツがあったらnext()し、無かったらnext({name: 'notFound'})で404ページに飛ばす処理を書けばいい。

今回はfilterでやったけど、Firestoreに入れている場合など状況によって書き換える必要があると思う。

これで存在しないidが指定されたときは404のURLに変わり、専用のコンポーネントが表示されるようになった。

Firebase HostingにWebpackでつくった404.htmlデプロイ

上記のPWAテンプレートではindex.html以外のHTMLファイルがなく、手で404.htmlファイルを作っただけではnpm run buildでdist/には書き出されない。
また置いたとしてもビルドのたびに消されてしまうので、設定が必要になる。

app\build\webpack.prod.conf.js

  plugins: [
    // 省略

    // generate dist index.html with correct asset hash for caching.
    // you can customize output by editing /index.html
    // see https://github.com/ampedandwired/html-webpack-plugin
    new HtmlWebpackPlugin({
      filename: process.env.NODE_ENV === 'testing' ?
        'index.html' : config.build.index,
      template: 'index.html',
      inject: true,
      minify: {
        removeComments: true,
        collapseWhitespace: true,
        removeAttributeQuotes: true
        // more options:
        // https://github.com/kangax/html-minifier#options-quick-reference
      },
      // necessary to consistently work with multiple chunks via CommonsChunkPlugin
      chunksSortMode: 'dependency',
      serviceWorkerLoader: `<script>${loadMinified(path.join(__dirname,
        './service-worker-prod.js'))}</script>`
    }),
    /* 404 ↓ここ追加 */
    new HtmlWebpackPlugin({
      filename: "404.html",
      template: "404.html",
      inject: false,
    }),

こんな感じでシンプルにappディレクトリ直下の404.htmlを追加しただけ。
404はエラー時などhtml単体で動くよう、VueやCSSを使用しないのでinject: falseとした。

他の設定はreadme.mdにある
github.com

これでbuildするとdist以下に書き出されるので、Firebase Hostingにデプロイ( firebase deploy )する。

そうすると/404.htmlで表示出来て、ステータスコードも404になる。

Vue側の404用ページコンポーネント、Firebase Hostingで404.htmlと、404用のファイルが二つあるのが若干気持ち悪いけど、FHで404.htmlは必須だし、vue-routerでそれを読み込ませるのは難しいのであきらめた。

Service Workerで404ページをprecacheするのを除外する

PWAのデフォ設定だと、すべての.htmlをキャッシュしようとするが、404.htmlは当然404を返すのでエラーになってしまう。

chromeのconsoleだと下記のようなエラーがでる

service-worker.js:1 Uncaught (in promise) Error: Request for https://{project_id}.firebaseapp.com/404.html?_sw-precache=99120a382671cea98d8e49ff29caea44 returned a response with status 404
    at service-worker.js:1
(anonymous) @ service-worker.js:1

そのため404がパスに含まれる場合、precacheの対象から外すことにした。
SWPrecacheWebpackPluginの設定を追加する

app\build\webpack.prod.conf.js

// 省略
    // service worker caching
    new SWPrecacheWebpackPlugin({
      cacheId: 'shanyang20180531-03',
      filename: 'service-worker.js',
      staticFileGlobs: ['dist/index.html', 'dist/**/*.{js,css,png,jpg,svg}'],
      minify: true,
      stripPrefix: 'dist/'
    })]
  ]
})

www.npmjs.com
最初staticFileGlobsIgnorePatternsに404.htmlを渡したが、いろいろ試しても除外されないのであきらめてstaticFileGlobsでindex.htmlだけ指定した。

実際に消えているかは書き出されたdist/service-worker.jsでファイル名検索かければわかる。

htmlが他にもある場合は正規表現の調整が必要なると思うけど今回はとりあえず除外できたのでおk。

/404へのサーバーリクエストは404.htmlにリダイレクト

今回、vue-routerではなく直接サーバーに/404をリクエストした場合、Vueで受けてしまうとindex.htmlを返し、ステータスコードは200になってしまう。
そのためHistory APIではなく、サーバーリクエストの場合、Hosting側の404.htmlにリダイレクトさせてしまうことにした。


これはfirebase.jsonで指定する

{
  "hosting": {
    "public": "dist",
    "ignore": [
      "firebase.json",
      "**/.*",
      "**/node_modules/**"
    ],
    "redirects": [{
      "source": "/404",
      "destination": "/404.html",
      "type": 301
    }],
    "rewrites": [{
      "source": "**",
      "destination": "/index.html"
    }]
  }
}

今のところ404でやってみたのはこれぐらい。

基礎から学ぶ Vue.js

基礎から学ぶ Vue.js