この連載記事では、フロントエンドに 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)の対応を実装します。
概要図
以下が実装案の概要図です。このような作戦で実装しようと思います。
- エラーページのページコンポーネントを追加。
- コンポーネントをまたいでエラー情報をあつかう
error
ストアモジュールを追加。 auth
モジュールでエラーが発生したときにerror
モジュールのステートを更新。- ルートコンポーネント
App.vue
でerror
モジュールのステートをwatch
で監視。 - 特定のエラーコードであればエラーページへ移動。
システムエラーページ
システムエラーページを表すコンポーネントを作成します。
エラーページをまとめる resources/js/pages/errors
ディレクトリを作成し、さらにその中に System.vue
を作成してください。
<template>
<p>システムエラーが発生しました。</p>
</template>
ページが出来たら router.js
にシステムエラーのルート定義に追加します。
import SystemError from './pages/errors/System.vue'
/* 中略 */
const routes = [
/* 中略 */
{
path: '/500',
component: SystemError
}
]
レスポンスコード定義
今回はレスポンスコードを元にエラーかどうか、どのようなエラーかを判別します。200
や 500
といったアプリケーション的に意味のある数字がハードコードされるのを避けるために util.js
にステータスコードの定義を追記します。
export const OK = 200
export const CREATED = 201
export const INTERNAL_SERVER_ERROR = 500
他のプログラムはこれをインポートして使うようにします。
error ストア
コンポーネントをまたいでエラー情報をあつかう error
ストアモジュールを追加します。
以下の内容で resources/js/store/error.js
を作成します。
const state = {
code: null
}
const mutations = {
setCode (state, code) {
state.code = code
}
}
export default {
namespaced: true,
state,
mutations
}
error
モジュールはエラーのステータスコードを表す code
ステートを持っています。
store/index.js
で error
モジュールを読み込みます。
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
ステートを参照して後続の処理を行うかどうかを判別します。
const state = {
user: null,
apiStatus: null
}
さらに、ステートを更新するための setApiStatus
ミューテーションを追加します。
const mutations = {
setUser (state, user) {/* 中略 */},
setApiStatus (state, status) {
state.apiStatus = status
}
}
次に先ほど定義したステータスコードをインポートします。
import { OK } from '../util'
そしてアクションを以下の通り編集してください。
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
では、通信失敗の場合、つまり apiStatus
が false
の場合にはトップページへの移動処理を行わないように制御を加えます。
まず算出プロパティで auth
モジュールの apiStatus
ステートを参照します。
computed: {
apiStatus () {
return this.$store.state.auth.apiStatus
}
},
apiStatus
が成功(true
)だった場合のみトップページに移動します。
async login () {
// authストアのloginアクションを呼び出す
await this.$store.dispatch('auth/login', this.loginForm)
if (this.apiStatus) {
// トップページに移動する
this.$router.push('/')
}
}
ルートコンポーネント
App.vue
では error
モジュールのステートを監視し、INTERNAL_SERVER_ERROR
だった場合に先ほど作成したエラーページに移動します。
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 のコードをエラーが出るように書き変えるなどして確認してみてください。
ストアや各コンポーネントなど登場人物(?)が多いので最初は分かりづらいと感じるかもしれませんが、あきらめずに概要図と見比べながらコードを追ってみましょう。
バリデーションエラー
次にバリデーションエラーの対応を実装します。
実装はシステムエラーよりはシンプルで、auth
ストアモジュールにエラーメッセージを入れるステートを追加して、ページコンポーネント側で参照して表示させます。
レスポンスコード定義
まずはレスポンスコードの定義を util.js
に追記します。
export const UNPROCESSABLE_ENTITY = 422
Laravel はバリデーションエラーでは 422 をレスポンスします。
auth ストア
上記のステータスコードをインポートします。
import { OK, UNPROCESSABLE_ENTITY } from '../util'
エラーメッセージを入れる loginErrorMessages
ステートを追加します。
const state = {
user: null,
apiStatus: null,
loginErrorMessages: null
}
loginErrorMessages
ステートのためのミューテーションを追加します。
const mutations = {
setUser (state, user) {/* 中略 */},
setApiStatus (state, status) {/* 中略 */},
setLoginErrorMessages (state, messages) {
state.loginErrorMessages = messages
}
}
login
アクションは以下の通り編集してください。
ステータスコードが UNPROCESSABLE_ENTITY
の場合の分岐を追加します。
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
を参照します。
computed: {
apiStatus () {
return this.$store.state.auth.apiStatus
},
loginErrors () {
return this.$store.state.auth.loginErrorMessages
}
},
上記の書き方でも問題ないのですが、Vuex が提供する mapState
関数を使うと別の書き方もできます。まず mapState
をインポートします。
import { mapState } from 'vuex'
computed
は以下のように書き換えることができます。
computed: {
...mapState({
apiStatus: state => state.auth.apiStatus,
loginErrors: state => state.auth.loginErrorMessages
})
}
mapState
は名前の通り、コンポーネントの算出プロパティとストアのステートをマッピングする関数と言えます。普通に computed
を定義するのとは単純に書き方の違いしかなく機能はまったく同じです。プロパティが増えてくると mapState
の方が見やすいでしょうか?個人的には正直どちらでもいいと思うので好きな書き方を使ってください。
さて、エラーメッセージを参照できるようになったのでフォームにエラーメッセージ表示欄を追加します。
<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
ライフサイクルフックでエラーをクリアしましょう。
methods: {
/* 中略 */
clearError () {
this.$store.commit('auth/setLoginErrorMessages', null)
}
},
created () {
this.clearError()
}
以上でバリデーションエラー対策は完了です。
ログイン以外の機能にも適用する
ここまでログイン機能に対してシステムエラー、そしてバリデーションエラーの対策を施しましたが、会員登録機能やログアウト機能にも同様の実装を施しましょう。ログイン機能とパターンはほとんど同じです。
bootstrap
まず 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
ストアモジュールです。
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
}
どのアクションもやっていることはほとんど同じです。ログアウトとログインユーザーチェックに関しては入力値がないのでバリデーションエラーは考慮していません。
会員登録
会員登録機能へのエラー対策はログイン機能と同じパターンです。
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)
}
}
}
スクリプトブロックを編集できたら、登録フォームにエラーメッセージ表示欄を追加します。
<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
に追加します。
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')
}
}
}
}
算出プロパティは mapState
と mapGetters
を用いて記述しています。
システムエラーとバリデーションエラー対策が完了しました。
この章はこれでおしまいです。
本章までのソースコードはリポジトリの ch-8 ブランチに置いてあります。
次の章からは写真の投稿機能を実装します。
関連記事
連載記事(全16回)
Vue + Vue Router + Vuex + Laravelで写真共有アプリを作ろう
- (1) イントロダクション
- (2) アプリケーションの設計
- (3) SPA開発環境とVue Router
- (4) 認証API
- (5) 認証ページ
- (6) 認証機能とVuex
- (7) 認証機能とVuex Part.2
- (8) エラーハンドリング
- (9) 写真投稿API
- (10) 写真投稿フォーム
- (11) 写真一覧取得API
- (12) 写真一覧ページ
- (13) 写真詳細ページ
- (14) コメント投稿機能
- (15) いいね機能
- (16) エラーハンドリング Part.2