VueとVue Routerで、リダイレクトのない理想の404を目指す


この記事は、[Vue #2 Advent Calendar 2019](https://qiita.com/advent-calendar/2019/vue2) の14日目の記事です🎉

# SPAだけど、リダイレクトせずURLそのままで404ページを表示したい
VueとVue RouterでSPAを作っていると、404 Not Foundを作りたくなってきますよね！
結構試行錯誤したのでアドベントカレンダーを機に、理想を目指す気持ちで改めて整理してみます。

## 前提
今回は簡単に考えるため以下の通りとします。

- Vue （Nuxtは使わずにSPAする）
- Vue Router
- SEO等については考慮しない※

※本来であれば、ただ404ページを表示させるだけだと「ソフト404エラー」に該当してしまうのであまり良くありません。ただ、SPAを採用している時点で、検索について気にしなくていいサービスやサイトである可能性が高いので、ここではいったん考慮しないでいきます。

※SPAとSEOについては以下の記事が分かりやすかったです。
[SPAを開発するエンジニアこそ知るべき、正しく評価されるためのSEO](https://qiita.com/yshnb/items/bc02a2e4609b38f8d3dd)



# 方法1：Vue Routerで設定
公式を見ながら、普通に404のルーティングを設定してみます。
https://router.vuejs.org/ja/guide/essentials/history-mode.html


```app.js
const router = new VueRouter({
    mode: 'history',
    routes: [
        {
            name: 'mypage',
            path: '/',
            component: mypage,
            meta: { title: 'マイページ' }
        },
        {
            name: 'postDetails', 
            path: '/posts/:id(\\d+)/details', 
            component: postDetails,
            meta: { title: '記事は1つしかない！' }
        },
        {
            name: 'notFound',
            path: '*',
            component: notFound,
            meta: { title: 'お探しのページは見つかりませんでした' }},
    ],
    }
})
```

これで、
`example.com/`→マイページ
`example.com/posts/1/details`→投稿1のページ
`example.com/whoops`→404ページ
が表示できるようになります。

でも、このままでは、`example.com/posts/2/details`は、存在しない記事にもかかわらずpostDetailsのコンポーネントが表示されてしまいます。困った！

# 方法2：コンポーネントガードで、404か否かでコンポーネントを出し分ける
URLは`example.com/posts/2/details`のまま、表示させるコンポーネントだけnotFoundにしたくなってきました。

そこで、次はVue Routerのコンポーネントガードである**beforeRouteEnter**と**beforeRouteUpdate**で記事取得のAPIをチェックしておいて、postsページのコンポーネントを出し分けるようにします。

流れは

- beforeRouteEnterまたはbeforeRouteUpdateの間に、APIで記事を取得しようとする
- 失敗した場合には、notFoundのフラグを立てて、`<template>`側でそれを見てnotFoundコンポーネントを出す
- 無事取得できれば、next()を使っていつも通りcreatedのライフサイクルに移行し`<template v-else>`内にあるコンテンツを出す

となります。


```postDetails.vue
<template>
    <div>
        <not-found v-if="notFound" />
        <template v-else>
            <div>記事のコンテンツ</div>
        </template>
    </div>
</template>
<script>
import axios from 'axios'
import store from '../store/store'
export default {
    beforeRouteEnter(to, from, next) {
        axios.get('/posts/2/')
            .then(res => {
                store.commit('window/setNotFound', false)
                next()
            })
            .catch(err => {
                if (err.response.status === 404) {
                    store.commit('window/setNotFound', true)
                    next()
                } else {
                    next()
                }
            })
    },
    beforeRouteUpdate(to, from, next) {
        // いったん共通化はせずベタ書きします（後述）
        axios.get('/posts/2/')
            .then(res => {
                store.commit('window/setNotFound', true)
                next()
            })
            .catch(err => {
                if (err.response.status === 404) {
                    store.commit('window/setNotFound', true)
                    next()
                } else {
                    next()
                }
            })
    },
    computed: {
        notFound() {
            return this.$store.getters['window/isNotFound']
        }
    }
}
</script>
```
```store.js
import Vue from 'vue'
import Vuex from 'vuex'
import window.js
export default new Vuex.Store({
    modules: {
        window
    },
})
```
```window.js
const namespaced = true
const state = {
    notFound: false,
}
const getters = {
    isNotFound(state) {
        return state.notFound
    }
}
const mutations = {
    setNotFound(state, val) {
        state.notFound = val
    }
}
export default {
    namespaced: namespaced,
    getters,
    state,
    mutations
}
```
これで、`example.com/posts/2/details`にアクセスしたとき、晴れてnotFoundが表示されるようになりました。

上記の例だと、

- `example.com/posts/2/details`にアクセスしようとすると、ページとしては200OKが返ってくる
- postDetails.vueのライフサイクルをガードして、beforeRouteEnterが呼び出される
- `axios.get('/posts/2/')`を取得するときにaxiosの404が返ってくる
- ストアに404であることを変数で保存する
- コンポーネントガードをnext()で終了し、postDetails.vueの中でcreated()以降のライフサイクルに進む
- ストアの404を参照して、notFoundのコンポーネントが表示される

となります。

## beforeRouteEnter内ではthisが使えない
いきなりVuexが出てきてました。何事だ！

なんと、**beforeRouteEnter内ではthisが使えない**ので、普段のコンポーネントのように`this.****`でメソッドにアクセスしたり、data()に変数を保存することは出来ません。なぜなら、その名の通りまだルートにEnterしていないからです。

notFoundであることを変数に保存するためには別の方法を使う必要があります。そのため、ここでは**Vuex**のwindowというストアを作って保存しています。

※上記のコードでbeforeRouteEnterとbeforeRouteUpdateでメソッドを共通化して…のようなことも考えると思います。が、beforeRouteEnterでthisが使えない一方、beforeRouteUpdateではthisが使えるので、一筋縄ではいかず、ちょっとした工夫が必要になります。（挑戦する人はがんばれ）


# 方法3：axiosのinterceptorsを使う
しかし、よく考えてみると、ページ読み込み以外にも他のAPIで情報を取得している箇所（たとえば「記事を削除するボタンを押した」ときなども）、すべての場所で404が起こる可能性はあるはずです。

記事を削除するAPIは、必ずしもbeforeRouteEnter/Updateで呼び出される訳ではないことが多いと思います。困った！

それなら、もはや**axiosのレスポンスを受けるときすべてに共通処理を書いて**しまえば良さそうです。

ここでは、axiosの最中に処理を挟み込む[**interceptors**](https://github.com/axios/axios#interceptors)を利用して共通処理を書いていきます。リクエストの前処理をほどこしたり、今回のようににレスポンスを受け取る前にいろいろやったりと、かなり使い勝手がよいので覚えておくと役に立つ日がくるかも。

```axiosHelper.js
import axios from 'axios'
import store from '../store/store'
const setup = () => {
    return (() => {
        axios.interceptors.response.use(
            res => {
                // ここでストアに値を保存する
                store.commit('window/setNotFound', false)
                // いつものレスポンス処理が続く
                return res
            },
            err => {
                const res = err.response
                const status = res === undefined ? undefined : res.status
                if (status === 404) {
                    store.commit('window/setNotFound', false)
                }
                return Promise.reject(err)
            }
        )
    })()
}
export default {
    setup
}
```
あとは、axiosを使う箇所で

```js
import axiosHelper from './axiosHelper'
axiosHelper.setup()
```
と書いてからaxios.getすれば、毎回404レスポンスを受け取っているかどうかチェックできます。

今回は404ページを出しましたが、表示するコンポーネントによっては、リロードを促すOverlayを表示する、ダイアログを出して動作が失敗したことを通知するなど、用途によっていろいろ工夫ができそうです。

# まとめ

- SPAで404 not foundを出す方法はいくつかある
- 理想の404は一筋縄では行かないので、その時に必要な方法を選んだり組み合わせたりする

みなさんの理想の404もお待ちしています🙌