2019.01.12

Vue + Vue Router + Vuex + Laravelで写真共有アプリを作ろう (8) エラーハンドリング


UPDATED:2020.01.05
PHP 7.4 および Laravel 6 に対応しました 🎉

この連載記事では、フロントエンドに Vue.js + Vue Router + Vuex とサーバーサイドに Laravel を使用したシングルページ Web アプリケーションの開発方法を紹介します。実際に写真共有アプリを開発する手順を通して SPA 開発のエッセンスを学ぶことができます。

今回のチュートリアルで扱うツールなどのバージョンは以下の通りです。

Node npm Vue.js Vue Router Vuex PHP Laravel
10.15 6.4 2.6 3.1 3.1 7.4 6.9

この章では通信処理のエラーハンドリングを実装します。

今回のアプリケーションでは以下の4種類のエラーに対応します。

  • システムエラー
  • バリデーションエラー
  • 認証エラー
  • Not Found エラー

本章では上記のうちシステムエラーとバリデーションエラーへの対策を実装します。
認証エラーと Not Found エラーの対策は後の章で実装することにします。

システムエラー

まずはいわゆる 500 番エラー(Internal Server Error)の対応を実装します。

概要図

以下が実装案の概要図です。このような作戦で実装しようと思います。

System Error

  • エラーページのページコンポーネントを追加。
  • コンポーネントをまたいでエラー情報をあつかう error ストアモジュールを追加。
  • auth モジュールでエラーが発生したときに error モジュールのステートを更新。
  • ルートコンポーネント App.vueerror モジュールのステートを watch で監視。
  • 特定のエラーコードであればエラーページへ移動。

システムエラーページ

システムエラーページを表すコンポーネントを作成します。
エラーページをまとめる resources/js/pages/errors ディレクトリを作成し、さらにその中に System.vue を作成してください。

System.vue
<template>
  <p>システムエラーが発生しました。</p>
</template>

ページが出来たら router.js にシステムエラーのルート定義に追加します。

router.js
import SystemError from './pages/errors/System.vue'

/* 中略 */

const routes = [
  /* 中略 */
  {
    path: '/500',
    component: SystemError
  }
]

レスポンスコード定義

今回はレスポンスコードを元にエラーかどうか、どのようなエラーかを判別します。200500 といったアプリケーション的に意味のある数字がハードコードされるのを避けるために util.js にステータスコードの定義を追記します。

util.js
export const OK = 200
export const CREATED = 201
export const INTERNAL_SERVER_ERROR = 500

他のプログラムはこれをインポートして使うようにします。

error ストア

コンポーネントをまたいでエラー情報をあつかう error ストアモジュールを追加します。

以下の内容で resources/js/store/error.js を作成します。

error.js
const state = {
  code: null
}

const mutations = {
  setCode (state, code) {
    state.code = code
  }
}

export default {
  namespaced: true,
  state,
  mutations
}

error モジュールはエラーのステータスコードを表す code ステートを持っています。

store/index.jserror モジュールを読み込みます。

index.js
import auth from './auth'
import error from './error' // ★ 追加

Vue.use(Vuex)

const store = new Vuex.Store({
  modules: {
    auth,
    error // ★ 追加
  }
})

export default store

auth ストア

auth ストアモジュールでは、まず API 呼び出しが成功したか失敗したかを表す apiStatus ステートを追加します。コンポーネント側ではこの apiStatus ステートを参照して後続の処理を行うかどうかを判別します。

auth.js
const state = {
  user: null,
  apiStatus: null
}

さらに、ステートを更新するための setApiStatus ミューテーションを追加します。

auth.js
const mutations = {
  setUser (state, user) {/* 中略 */},
  setApiStatus (state, status) {
    state.apiStatus = status
  }
}

次に先ほど定義したステータスコードをインポートします。

auth.js
import { OK } from '../util'

そしてアクションを以下の通り編集してください。

auth.js
async login (context, data) {
  context.commit('setApiStatus', null)
  const response = await axios.post('/api/login', data)
    .catch(err => err.response || err)

  if (response.status === OK) {
    context.commit('setApiStatus', true)
    context.commit('setUser', response.data)
    return false
  }

  context.commit('setApiStatus', false)
  context.commit('error/setCode', response.status, { root: true })
},

ここではログイン機能の実装のみ紹介します。
他の機能はパターンがほぼ同じなので、後ほどまとめて紹介します。

さて、上記の実装のポイントをいくつか説明します。

通信エラーの取得

async/await を用いて非同期処理を書くと、以下のパターンで非同期処理が成功した場合も失敗した場合も同じ変数に結果を代入できます。

const result = await someAsyncTask().catch(error => error.something)

上記の場合、someAsyncTask が失敗すると result には error.something が代入されます。

このパターンを利用して、API 通信が成功した場合も失敗した場合も response にレスポンスオブジェクトを代入しています。

const response = await axios.post('...', data).catch(error => error.response || error)

これによって、レスポンスコードによって後続の処理を分岐させることができます。

通信ステータスの更新

apiStatus を通信結果によって更新しています。

// 最初はnull
context.commit('setApiStatus', null)

if (response.status === OK) {
  // 成功したらtrue
  context.commit('setApiStatus', true)
  return false
}

// 失敗だったらfalse
context.commit('setApiStatus', false)

この apiStatus をどう使うかはページコンポーネントで紹介します。

別モジュールのミューテーションを呼び出す

通信に失敗した場合に error モジュールの setCode ミューテーションを commit していますが、あるストアモジュールから別のモジュールのミューテーションを commit する場合は第三引数に { root: true } を追加します。

context.commit('error/setCode', response.status, { root: true })

ページコンポーネント

Login.vue では、通信失敗の場合、つまり apiStatusfalse の場合にはトップページへの移動処理を行わないように制御を加えます。

まず算出プロパティで auth モジュールの apiStatus ステートを参照します。

Login.vue
computed: {
  apiStatus () {
    return this.$store.state.auth.apiStatus
  }
},

apiStatus が成功(true)だった場合のみトップページに移動します。

Login.vue
async login () {
  // authストアのloginアクションを呼び出す
  await this.$store.dispatch('auth/login', this.loginForm)

  if (this.apiStatus) {
    // トップページに移動する
    this.$router.push('/')
  }
}

ルートコンポーネント

App.vue では error モジュールのステートを監視し、INTERNAL_SERVER_ERROR だった場合に先ほど作成したエラーページに移動します。

App.vue
import { INTERNAL_SERVER_ERROR } from './util'

export default {
  components: {
    Navbar,
    Footer
  },
  computed: {
    errorCode () {
      return this.$store.state.error.code
    }
  },
  watch: {
    errorCode: {
      handler (val) {
        if (val === INTERNAL_SERVER_ERROR) {
          this.$router.push('/500')
        }
      },
      immediate: true
    },
    $route () {
      this.$store.commit('error/setCode', null)
    }
  }
}

ストアのステートを算出プロパティで参照した上で watch で監視するというパターンです。


ここまででシステムエラーへの対応が実装できました。
動作の確認はしづらいですが、一時的にログイン API のコードをエラーが出るように書き変えるなどして確認してみてください。

ストアや各コンポーネントなど登場人物(?)が多いので最初は分かりづらいと感じるかもしれませんが、あきらめずに概要図と見比べながらコードを追ってみましょう。

System Error

バリデーションエラー

次にバリデーションエラーの対応を実装します。

実装はシステムエラーよりはシンプルで、auth ストアモジュールにエラーメッセージを入れるステートを追加して、ページコンポーネント側で参照して表示させます。

レスポンスコード定義

まずはレスポンスコードの定義を util.js に追記します。

util.js
export const UNPROCESSABLE_ENTITY = 422

Laravel はバリデーションエラーでは 422 をレスポンスします。

auth ストア

上記のステータスコードをインポートします。

auth.js
import { OK, UNPROCESSABLE_ENTITY } from '../util'

エラーメッセージを入れる loginErrorMessages ステートを追加します。

auth.js
const state = {
  user: null,
  apiStatus: null,
  loginErrorMessages: null
}

loginErrorMessages ステートのためのミューテーションを追加します。

auth.js
const mutations = {
  setUser (state, user) {/* 中略 */},
  setApiStatus (state, status) {/* 中略 */},
  setLoginErrorMessages (state, messages) {
    state.loginErrorMessages = messages
  }
}

login アクションは以下の通り編集してください。
ステータスコードが UNPROCESSABLE_ENTITY の場合の分岐を追加します。

auth.js
async login (context, data) {
  /* 中略 */
  context.commit('setApiStatus', false)
  if (response.status === UNPROCESSABLE_ENTITY) {
    context.commit('setLoginErrorMessages', response.data.errors)
  } else {
    context.commit('error/setCode', response.status, { root: true })
  }
}

バリデーションエラーの場合はルートコンポーネントに制御を渡さず、ページコンポーネント内でエラーの表示を行う必要があるので、ステータスコードが UNPROCESSABLE_ENTITY の場合は error/setCode ミューテーションを呼びません。代わりに loginErrorMessages にエラーメッセージをセットします。

ページコンポーネント

ページコンポーネントでは算出プロパティで loginErrorMessages を参照します。

Login.vue
computed: {
  apiStatus () {
    return this.$store.state.auth.apiStatus
  },
  loginErrors () {
    return this.$store.state.auth.loginErrorMessages
  }
},

上記の書き方でも問題ないのですが、Vuex が提供する mapState 関数を使うと別の書き方もできます。まず mapState をインポートします。

Login.vue
import { mapState } from 'vuex'

computed は以下のように書き換えることができます。

Login.vue
computed: {
  ...mapState({
    apiStatus: state => state.auth.apiStatus,
    loginErrors: state => state.auth.loginErrorMessages
  })
}

mapState は名前の通り、コンポーネントの算出プロパティとストアのステートをマッピングする関数と言えます。普通に computed を定義するのとは単純に書き方の違いしかなく機能はまったく同じです。プロパティが増えてくると mapState の方が見やすいでしょうか?個人的には正直どちらでもいいと思うので好きな書き方を使ってください。

さて、エラーメッセージを参照できるようになったのでフォームにエラーメッセージ表示欄を追加します。

Login.vue
<form class="form" @submit.prevent="login">
  <div v-if="loginErrors" class="errors">
    <ul v-if="loginErrors.email">
      <li v-for="msg in loginErrors.email" :key="msg">{{ msg }}</li>
    </ul>
    <ul v-if="loginErrors.password">
      <li v-for="msg in loginErrors.password" :key="msg">{{ msg }}</li>
    </ul>
  </div>
  <!-- 中略 -->
</form>

ここまでできたらブラウザで動作を確認しましょう。
ログインフォームに何も入力せずに送信するなどしてバリデーションエラーを起こしてみてください。エラーが表示されたでしょうか?

ログインページ単体で見たときはこれで完了に見えるのですが、実は課題がまだあります。エラーが表示された状態でナビバーの左上のリンクから別のページに移動して、またログインページに戻ってくると以前のエラーが表示されたままになっています。

ログインページを表示するタイミング、つまり created ライフサイクルフックでエラーをクリアしましょう。

Login.vue
methods: {
  /* 中略 */
  clearError () {
    this.$store.commit('auth/setLoginErrorMessages', null)
  }
},
created () {
  this.clearError()
}

以上でバリデーションエラー対策は完了です。

ログイン以外の機能にも適用する

ここまでログイン機能に対してシステムエラー、そしてバリデーションエラーの対策を施しましたが、会員登録機能やログアウト機能にも同様の実装を施しましょう。ログイン機能とパターンはほとんど同じです。

bootstrap

まず bootstrap.js に以下の記述を追加してください。

bootstrap.js
window.axios.interceptors.response.use(
  response => response,
  error => error.response || error
)

axios の response インターセプターはレスポンスを受けた後の処理を上書きします。第一引数が成功時の処理ですが、こちらは変更しないのでそのまま response を返しています。第二引数は失敗時の処理で、こちらを変更しています。

通信エラーを取得するには await/catch パターンを用いましたが、API 呼び出しが増えると以下のように .catch(error => error.response || error) が重複してきます。

const response = await axios.post('/api/register', data).catch(error => error.response || error)
const response = await axios.post('/api/login', data).catch(error => error.response || error)
const response = await axios.post('/api/logout', data).catch(error => error.response || error)
const response = await axios.post('/api/user', data).catch(error => error.response || error)

エラーレスポンスが返ってきた場合はエラーそのものではなくレスポンスオブジェクトを返す、という処理はどの API 呼び出しにも共通しているのでインターセプターにまとめました。

auth ストア

以下が最終的な auth ストアモジュールです。

auth.js
import { OK, CREATED, UNPROCESSABLE_ENTITY } from '../util'

const state = {
  user: null,
  apiStatus: null,
  loginErrorMessages: null,
  registerErrorMessages: null
}

const getters = {
  check: state => !! state.user,
  username: state => state.user ? state.user.name : ''
}

const mutations = {
  setUser (state, user) {
    state.user = user
  },
  setApiStatus (state, status) {
    state.apiStatus = status
  },
  setLoginErrorMessages (state, messages) {
    state.loginErrorMessages = messages
  },
  setRegisterErrorMessages (state, messages) {
    state.registerErrorMessages = messages
  }
}

const actions = {
  // 会員登録
  async register (context, data) {
    context.commit('setApiStatus', null)
    const response = await axios.post('/api/register', data)

    if (response.status === CREATED) {
      context.commit('setApiStatus', true)
      context.commit('setUser', response.data)
      return false
    }

    context.commit('setApiStatus', false)
    if (response.status === UNPROCESSABLE_ENTITY) {
      context.commit('setRegisterErrorMessages', response.data.errors)
    } else {
      context.commit('error/setCode', response.status, { root: true })
    }
  },

  // ログイン
  async login (context, data) {
    context.commit('setApiStatus', null)
    const response = await axios.post('/api/login', data)

    if (response.status === OK) {
      context.commit('setApiStatus', true)
      context.commit('setUser', response.data)
      return false
    }

    context.commit('setApiStatus', false)
    if (response.status === UNPROCESSABLE_ENTITY) {
      context.commit('setLoginErrorMessages', response.data.errors)
    } else {
      context.commit('error/setCode', response.status, { root: true })
    }
  },

  // ログアウト
  async logout (context) {
    context.commit('setApiStatus', null)
    const response = await axios.post('/api/logout')

    if (response.status === OK) {
      context.commit('setApiStatus', true)
      context.commit('setUser', null)
      return false
    }

    context.commit('setApiStatus', false)
    context.commit('error/setCode', response.status, { root: true })
  },

  // ログインユーザーチェック
  async currentUser (context) {
    context.commit('setApiStatus', null)
    const response = await axios.get('/api/user')
    const user = response.data || null

    if (response.status === OK) {
      context.commit('setApiStatus', true)
      context.commit('setUser', user)
      return false
    }

    context.commit('setApiStatus', false)
    context.commit('error/setCode', response.status, { root: true })
  }
}

export default {
  namespaced: true,
  state,
  getters,
  mutations,
  actions
}

どのアクションもやっていることはほとんど同じです。ログアウトとログインユーザーチェックに関しては入力値がないのでバリデーションエラーは考慮していません。

会員登録

会員登録機能へのエラー対策はログイン機能と同じパターンです。

Login.vue
export default {
  data () {/* 中略 */},
  computed: mapState({
    apiStatus: state => state.auth.apiStatus,
    loginErrors: state => state.auth.loginErrorMessages,
    registerErrors: state => state.auth.registerErrorMessages
  }),
  methods: {
    async login () {/* 中略 */},
    async register () {
      // authストアのresigterアクションを呼び出す
      await this.$store.dispatch('auth/register', this.registerForm)

      if (this.apiStatus) {
        // トップページに移動する
        this.$router.push('/')
      }
    },
    clearError () {
      this.$store.commit('auth/setLoginErrorMessages', null)
      this.$store.commit('auth/setRegisterErrorMessages', null)
    }
  }
}

スクリプトブロックを編集できたら、登録フォームにエラーメッセージ表示欄を追加します。

Login.vue
<form class="form" @submit.prevent="register">
  <div v-if="registerErrors" class="errors">
    <ul v-if="registerErrors.name">
      <li v-for="msg in registerErrors.name" :key="msg">{{ msg }}</li>
    </ul>
    <ul v-if="registerErrors.email">
      <li v-for="msg in registerErrors.email" :key="msg">{{ msg }}</li>
    </ul>
    <ul v-if="registerErrors.password">
      <li v-for="msg in registerErrors.password" :key="msg">{{ msg }}</li>
    </ul>
  </div>
  <!-- 中略 -->
</form>

ログアウト

ログアウト機能のエラー対策は Footer.vue に追加します。

Footer.vue
import { mapState, mapGetters } from 'vuex'

export default {
  computed: {
    ...mapState({
      apiStatus: state => state.auth.apiStatus
    }),
    ...mapGetters({
      isLogin: 'auth/check'
    })
  },
  methods: {
    async logout () {
      await this.$store.dispatch('auth/logout')

      if (this.apiStatus) {
        this.$router.push('/login')
      }
    }
  }
}

算出プロパティは mapStatemapGetters を用いて記述しています。


システムエラーとバリデーションエラー対策が完了しました。

👾 👾 👾

この章はこれでおしまいです。
本章までのソースコードはリポジトリの ch-8 ブランチに置いてあります。

次の章からは写真の投稿機能を実装します。

関連記事

連載記事(全16回)

Vue + Vue Router + Vuex + Laravelで写真共有アプリを作ろう

その他