この連載記事では、フロントエンドに 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 |
この章では投稿機能のフロントエンドを実装して、実際に写真が投稿できるようにします。
以下の通りボタンをクリックすると表示されるフォームを作成します。
ファイルコンポーネント作成
まずはナビゲーションバーの「Submit a photo」ボタンをクリックすると表示されるフォームのコンポーネントを作成します。
以下の内容で resources/js/components/PhotoForm.vue
を作成してください。
<template>
<div class="photo-form">
<h2 class="title">Submit a photo</h2>
<form class="form">
<input class="form__item" type="file">
<div class="form__button">
<button type="submit" class="button button--inverse">submit</button>
</div>
</form>
</div>
</template>
表示・非表示の切り替え
冒頭に載せた GIF 画像の通り、投稿フォームは「Submit a photo」ボタンをクリックすると表示され、もう一度ボタンをクリックすると隠れます。
今回は v-model
を用いてこの動作を実装します。v-model
は基本的には入力要素と一緒に用いられますが、他のカスタムコンポーネントでも使用できます。value
を props
にとって input
イベントを $emit
するコンポーネントには v-model
を指定できます。
つまり以下の記述は、
<!-- $eventはSampleコンポーネントがinputイベントと共に発行した値 -->
<Sample :value="foo" @input="foo = $event" />
v-model
を使うと以下のように書き換えられます。
<Sample v-model="foo" />
ここではフォームを表示するかどうかを示す変数を v-model
で管理します。
フォームコンポーネント
PhotoForm.vue
を以下の通り編集します。
<template>
<div v-show="value" class="photo-form">
<!-- 中略 -->
</div>
</template>
<script>
export default {
props: {
value: {
type: Boolean,
required: true
}
}
}
</script>
value
を受け取れるようにスクリプトブロックにprops
を追加します。value
は表示 / 非表示を真偽値で表現するためBoolean
型と定義します。- テンプレートの一番外側の要素に
v-show
の記述を追加します。
これでこのコンポーネントの表示 / 非表示を(value
を渡す)親コンポーネント側で制御できるようになりました。
ナビゲーションバーコンポーネント
投稿フォームコンポーネントはナビゲーションバーコンポーネントから使用します。
Navbar.vue
を以下の通り編集してください。
まず PhotoForm.vue
をインポートし components
に登録します。
さらにフォームの表示 / 非表示を表す showForm
データ変数を用意します。最初は非表示状態なので初期値は false
です。
import PhotoForm from './PhotoForm.vue'
export default {
components: {
PhotoForm
},
data () {
return {
showForm: false
}
},
/* 中略 */
}
次に「Submit a photo」ボタンのクリックイベントで showForm
の値を切り替えます。
<button class="button" @click="showForm = ! showForm">
<i class="icon ion-md-add"></i>
Submit a photo
</button>
最後に <PhotoForm>
コンポーネントを配置します。
<nav class="navbar">
<!-- 中略 -->
<PhotoForm v-model="showForm" />
</nav>
v-model
で showForm
を渡しています。
ここまでできたらブラウザで動作を確認してみましょう。
ビルドコマンドを実行していなければ実行してください。
$ npm run watch
ファイルプレビュー
次にファイルプレビュー機能を実装します。
HTML5 の FileReader API を用いてファイルの読み込みを行います。
写真を表示する
投稿フォームのテンプレートを以下の通り編集します。
<input class="form__item" type="file" @change="onFileChange">
<output class="form__output" v-if="preview">
<img :src="preview" alt="">
</output>
<input>
要素に@change
を追加する(onFileChange
メソッドはすぐ後で実装します)。- プレビュー表示領域の
<output>
要素を追加する(preview
はすぐ後で定義します)。
それからスクリプトブロックを以下の通り編集してください。
export default {
props: {/* 中略 */},
data () {
return {
preview: null
}
},
methods: {
// フォームでファイルが選択されたら実行される
onFileChange (event) {
// 何も選択されていなかったら処理中断
if (event.target.files.length === 0) {
return false
}
// ファイルが画像ではなかったら処理中断
if (! event.target.files[0].type.match('image.*')) {
return false
}
// FileReaderクラスのインスタンスを取得
const reader = new FileReader()
// ファイルを読み込み終わったタイミングで実行する処理
reader.onload = e => {
// previewに読み込み結果(データURL)を代入する
// previewに値が入ると<output>につけたv-ifがtrueと判定される
// また<output>内部の<img>のsrc属性はpreviewの値を参照しているので
// 結果として画像が表示される
this.preview = e.target.result
}
// ファイルを読み込む
// 読み込まれたファイルはデータURL形式で受け取れる(上記onload参照)
reader.readAsDataURL(event.target.files[0])
}
}
}
data
にプレビューのデータ URL を格納するpreview
を追加する。methods
にonFileChange
を追加する。
ポイントはコメントを参照してください。
読み込んだデータ URL の DOM への反映部分に Vue を使っているとはいえ、入力ファイルのプレビュー機能の実装方法としては HTML5 の慣用的な書き方なので覚えておくと便利です。
表示をリセットする
上記までの実装でプレビューは表示できるのですが、一つ課題があります。
プレビューを表示させてからもう一度ファイル選択を開いて、今度は何も選ばずに「キャンセル」をクリックすると、入力欄の値はクリアされますがプレビューが残ってしまいます。
入力欄の値とプレビュー表示をクリアする汎用的なメソッドを作っておいて、onFileChange
でも利用するようにしましょう。
methods: {
onFileChange (event) {
if (event.target.files.length === 0) {
this.reset() // ★ 追加
return false
}
if (! event.target.files[0].type.match('image.*')) {
this.reset() // ★ 追加
return false
}
/* 中略 */
},
// 入力欄の値とプレビュー表示をクリアするメソッド
reset () {
this.preview = ''
this.$el.querySelector('input[type="file"]').value = null
}
}
this.$el
はコンポーネントそのものの DOM 要素を指します。
ここまでできたらブラウザで動作を確認してみましょう。
ファイルを指定すると入力欄の下にプレビューが表示されるでしょうか。
ファイル送信
やっとメイン処理であるファイル送信を実装します。
ここでは Vuex は使わずに実装します。設計判断次第ですが、Vuex を導入すると複雑性が増すデメリットもあります。今回はコンポーネントをまたいで利用するデータを管理する目的でのみ Vuex を使うことにします。
API 呼び出し
まずフォームの submit イベントで submit
メソッドを実行する記述を加えます。
<form class="form" @submit.prevent="submit">
ログイン・会員登録フォームと同様、デフォルトのフォーム送信処理を抑えるためにイベントに .prevent
を付けています。
続いてスクリプトを以下の通り編集してください。
data () {
return {
preview: null,
photo: null // ★ 追加
}
},
/* 中略 */
methods: {
onFileChange (event) {
/* 中略 */
this.photo = event.target.files[0] // ★ 追加
},
reset () {
this.preview = ''
this.photo = null // ★ 追加
this.$el.querySelector('input[type="file"]').value = null
},
async submit () {
const formData = new FormData()
formData.append('photo', this.photo)
const response = await axios.post('/api/photos', formData)
this.reset()
this.$emit('input', false)
}
}
data
に選択中のファイルを格納するphoto
を追加します。onFileChange
メソッドの最終行に、photo
にファイルを代入する記述を追加します。reset
メソッドにphoto
もクリアする記述を追加します。submit
メソッドを追加します。
Ajax でファイルを送るためには、HTML5 の FormData API を使用します。
これも慣用的な FormData のユースケースなので覚えておきましょう。
送信が完了したら reset
を呼んで入力値をクリアしています。
また、input
イベントを発行して自動的にフォームが閉じるようにしています。イベントとともに発行される値が false
なので、<Navbar>
の showForm
が false
になります。すると <PhotoForm>
に渡ってくる value
も false
になるので <PhotoForm>
の v-show
が偽と判定されて表示されなくなるわけです。
図式化すると以下のようになるでしょう。
カスタムコンポーネントにおける v-model
のデータの流れは循環している感じで最初は追いづらいかもしれませんが、慣れてしまえば表現の幅は広がります。
投稿完了後のページ遷移
投稿が完了したらその写真の詳細ページに移動する仕様にしたいと思います。
遷移先ページ作成
写真詳細ページのコンポーネントを作成します。
以下の内容で resources/js/pages/PhotoDetail.vue
を作成してください。
<template>
<h1>Photo Detail</h1>
</template>
いまのところは遷移できることだけが確認できれば OK なので中身はありません。
router.js
で写真詳細ページのルート定義を追加してください。
import PhotoDetail from './pages/PhotoDetail.vue'
/* 中略 */
const routes = [
{
path: '/',
component: PhotoList
},
{
path: '/photos/:id',
component: PhotoDetail,
props: true
},
/* 中略 */
]
:id
は URL の変化する部分(ここでは写真ID)を表し、props: true
はその変数部分(写真IDの値)を props
として受け取ることを意味します。
いまは投稿機能の実装が本筋なので、ここは後でまた説明します。
フォームコンポーネント
<PhotoForm>
の submit
メソッドの最終行に以下の記述を追加してください。
this.$router.push(`/photos/${response.data.id}`)
これで投稿完了後に写真詳細ページに移動する処理が実装できました。
エラー処理
エラー処理を実装していきます。パターンはログインフォームなどと似ています。アクションでやっていたことをコンポーネントのメソッドで行うと考えてください。
まずスクリプトブロックの先頭でレスポンスコードの定義をインポートします。
import { CREATED, UNPROCESSABLE_ENTITY } from '../util'
data
にエラーメッセージを格納する errors
を追加します。
data () {
return {
preview: null,
photo: null,
errors: null
}
},
submit
メソッドは以下のように編集してください。
async submit () {
const formData = new FormData()
formData.append('photo', this.photo)
const response = await axios.post('/api/photos', formData)
if (response.status === UNPROCESSABLE_ENTITY) {
this.errors = response.data.errors
return false
}
this.reset()
this.$emit('input', false)
if (response.status !== CREATED) {
this.$store.commit('error/setCode', response.status)
return false
}
this.$router.push(`/photos/${response.data.id}`)
}
バリデーションエラー(UNPROCESSABLE_ENTITY
)対応の位置に注意してください。バリデーションエラーの場合はエラーメッセージを表示する関係から、値をクリアしたりフォームを閉じたりしません。その前に return fasle
で処理を中断します。
テンプレートにエラーメッセージの表示欄を追加します。
<form class="form" @submit.prevent="submit">
<div class="errors" v-if="errors">
<ul v-if="errors.photo">
<li v-for="msg in errors.photo" :key="msg">{{ msg }}</li>
</ul>
</div>
<!-- 中略 -->
</form>
以上で写真投稿機能のエラー処理は完了です。
写真の投稿自体はできるようになりました。
ここからはもう一手間加えた以下の2つの UI を実装します。
- 投稿 API の通信中にローディングを表示する。
- 投稿が完了したら「投稿されました」のようなサクセスメッセージを表示する。
ローディング
ローダーコンポーネント作成
まずローディング表示のコンポーネントを追加します。
以下の内容で resources/js/components/Loader.vue
を作成してください。
<template>
<div class="loader">
<p class="loading__text">
<slot>Loading...</slot>
</p>
<div class="loader__item loader__item--heart"><div></div></div>
</div>
</template>
フォームコンポーネント
<PhotoForm>
コンポーネントでは <Loader>
コンポーネントをインポートしてから components
に登録します。
import Loader from './Loader.vue'
export default {
components: {
Loader
},
/* 以下略 */
そして data
にローディングを表示させるかどうかを表す loading
を追加します。
data () {
return {
loading: false,
preview: null,
photo: null,
errors: null
}
},
テンプレートにローディング表示を追加します。ローディングが表示されている間は逆に入力欄は隠すので <form>
にも v-show
を追加しています。これで loading
が true
の間はローディングが出て入力欄が隠れます(逆もまた然りです)。
<div v-show="loading" class="panel">
<Loader>Sending your photo...</Loader>
</div>
<form v-show="! loading" class="form" @submit.prevent="submit">
最後に submit
メソッドの API 通信箇所の前後にローディングの表示状態を制御する記述を追加します。まずメソッドの冒頭で loading
を true
にします。これでローディングは表示されます。通信が終わったら loading
を false
にしてローディングを非表示にします。
async submit () {
this.loading = true
const formData = new FormData()
formData.append('photo', this.photo)
const response = await axios.post('/api/photos', formData)
this.loading = false
/* 中略 */
}
v-show
(または v-if
)と data
(または算出プロパティ)を組み合わせてメソッドで要素の表示を制御するパターンは Vue らしい(というか React なども含めて近年の JS フレームワークらしい)典型的なパターンと言えるでしょう。
サクセスメッセージ
この章の最後に、投稿完了後に以下のメッセージを表示する機能を実装します。
<PhotoForm>
コンポーネント内にとどまらない処理ですので、ストアを活用します。
メッセージストア作成
グローバルなメッセージ管理用にストアモジュールを追加します。
以下の内容で resources/js/store/message.js
を追加してください。
const state = {
content: ''
}
const mutations = {
setContent (state, { content, timeout }) {
state.content = content
if (typeof timeout === 'undefined') {
timeout = 3000
}
setTimeout(() => (state.content = ''), timeout)
}
}
export default {
namespaced: true,
state,
mutations
}
メッセージが一定時間経過後に自動的にクリアされるように書いています。
resources/js/store/index.js
で message
モジュールを読み込みます。
import message from './message'
/* 中略 */
const store = new Vuex.Store({
modules: {
auth,
error,
message
}
})
コンポーネント構成
さて message
モジュールが完成したのでここからコンポーネントの実装をしていくわけですが、その前にコンポーネント構成を説明しておきます。図にすると以下の通りです。
- メッセージ部分を表現する
<Message>
コンポーネントを追加作成します。 <PhotoForm>
で投稿が完了したらmessage
モジュールのcontent
を更新します。<Message>
コンポーネントではcontent
を参照して、値があればメッセージを表示します。
メッセージコンポーネント
メッセージコンポーネントを追加します。
以下の内容で resources/js/components/Message.vue
を作成してください。
<template>
<div class="message" v-show="message">
{{ message }}
</div>
</template>
<script>
import { mapState } from 'vuex'
export default {
computed: {
...mapState({
message: state => state.message.content
})
}
}
</script>
フォームコンポーネント
次に <PhotoForm>
では submit
メソッドにメッセージ登録の記述を追加します。
async submit () {
/* 中略 */
if (response.status !== CREATED) {
this.$store.commit('error/setCode', response.status)
return false
}
// メッセージ登録
this.$store.commit('message/setContent', {
content: '写真が投稿されました!',
timeout: 6000
})
this.$router.push(`/photos/${response.data.id}`)
}
表示時間は6秒にしてみました。
ルートコンポーネント
<App>
に <Message>
を配置します。
import Message from './components/Message.vue' // ★ 追加
import Navbar from './components/Navbar.vue'
import Footer from './components/Footer.vue'
import { INTERNAL_SERVER_ERROR } from './util'
export default {
components: {
Message, // ★ 追加
Navbar,
Footer
},
<div class="container">
<Message /> <!-- ★ 追加 -->
<RouterView />
</div>
以上でサクセスメッセージ表示機能が実装できました。
ブラウザで一連の動作を確認してみましょう。
投稿結果を表示する機能をまだ作っていないので、直接 S3 バケットやデータベースを覗いてうまく投稿できているかを確認してください。
この章はこれでおしまいです。
本章までのソースコードはリポジトリの ch-10 ブランチに置いてあります。
次の章では写真の一覧を取得する Web API を実装します。
関連記事
連載記事(全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