2019.01.12

Vue + Vue Router + Vuex + Laravelで写真共有アプリを作ろう (10) 写真投稿フォーム


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

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

Node npm Vue.js Vue Router Vuex PHP Laravel
10.7.0 6.4.1 2.5.21 3.0.2 3.0.1 7.2.10 5.7.19

この章では投稿機能のフロントエンドを実装して、実際に写真が投稿できるようにします。
以下の通りボタンをクリックすると表示されるフォームを作成します。

写真投稿フォーム

ファイルコンポーネント作成

まずはナビゲーションバーの「Submit a photo」ボタンをクリックすると表示されるフォームのコンポーネントを作成します。

以下の内容で resources/js/components/PhotoForm.vue を作成してください。

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 は基本的には入力要素と一緒に用いられますが、他のカスタムコンポーネントでも使用できます。valueprops にとって input イベントを $emit するコンポーネントには v-model を指定できます。

v-model

つまり以下の記述は、

<!-- $eventはSampleコンポーネントがinputイベントと共に発行した値 -->
<Sample :value="foo" @input="foo = $event" />

v-model を使うと以下のように書き換えられます。

<Sample v-model="foo" />

ここではフォームを表示するかどうかを示す変数を v-model で管理します。

フォームコンポーネント

PhotoForm.vue を以下の通り編集します。

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 です。

Navbar.vue
import PhotoForm from './PhotoForm.vue'

export default {
  components: {
    PhotoForm
  },
  data () {
    return {
      showForm: false
    }
  },
  /* 中略 */
}

次に「Submit a photo」ボタンのクリックイベントで showForm の値を切り替えます。

Navbar.vue
<button class="button" @click="showForm = ! showForm">
  <i class="icon ion-md-add"></i>
  Submit a photo
</button>

最後に <PhotoForm> コンポーネントを配置します。

Navbar.vue
<nav class="navbar">
  <!-- 中略 -->
  <PhotoForm v-model="showForm" />
</nav>

v-modelshowForm を渡しています。

ここまでできたらブラウザで動作を確認してみましょう。
ビルドコマンドを実行していなければ実行してください。

$ npm run watch

ファイルプレビュー

次にファイルプレビュー機能を実装します。
HTML5 の FileReader API を用いてファイルの読み込みを行います。

写真を表示する

投稿フォームのテンプレートを以下の通り編集します。

PhotoForm.vue
<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 はすぐ後で定義します)。

それからスクリプトブロックを以下の通り編集してください。

PhotoForm.vue
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 を追加する。
  • methodsonFileChange を追加する。

ポイントはコメントを参照してください。
読み込んだデータ URL の DOM への反映部分に Vue を使っているとはいえ、入力ファイルのプレビュー機能の実装方法としては HTML5 の慣用的な書き方なので覚えておくと便利でしょう。

表示をリセットする

上記までの実装でプレビューは表示できるのですが、一つ課題があります。

プレビューを表示させてからもう一度ファイル選択を開いて、今度は何も選ばずに「キャンセル」をクリックすると、入力欄の値はクリアされますがプレビューが残ってしまいます。

入力欄の値とプレビュー表示をクリアする汎用的なメソッドを作っておいて、onFileChange でも利用するようにしましょう。

PhotoForm.vue
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 メソッドを実行する記述を加えます。

PhotoForm.vue
<form class="form" @submit.prevent="submit">

ログイン・会員登録フォームと同様、デフォルトのフォーム送信処理を抑えるためにイベントに .prevent を付けています。

続いてスクリプトを以下の通り編集してください。

PhotoForm.vue
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>showFormfalse になります。すると <PhotoForm> に渡ってくる valuefalse になるので <PhotoForm>v-show が偽と判定されて表示されなくなるわけです。

図式化すると以下のようになるでしょう。

v-model

カスタムコンポーネントにおける v-model のデータの流れは循環している感じで最初は追いづらいかもしれませんが、慣れてしまえば表現の幅は広がります。

投稿完了後のページ遷移

投稿が完了したらその写真の詳細ページに移動する仕様にしたいと思います。

遷移先ページ作成

写真詳細ページのコンポーネントを作成します。
以下の内容で resources/js/pages/PhotoDetail.vue を作成してください。

PhotoDetail.vue
<template>
  <h1>Photo Detail</h1>
</template>

いまのところは遷移できることだけが確認できれば OK なので中身はありません。

router.js で写真詳細ページのルート定義を追加してください。

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 メソッドの最終行に以下の記述を追加してください。

PhotoForm.vue
this.$router.push(`/photos/${response.data.id}`)

これで投稿完了後に写真詳細ページに移動する処理が実装できました。

エラー処理

エラー処理を実装していきます。パターンはログインフォームなどと似ています。アクションでやっていたことをコンポーネントのメソッドで行うと考えてください。

まずスクリプトブロックの先頭でレスポンスコードの定義をインポートします。

PhotoForm.vue
import { CREATED, UNPROCESSABLE_ENTITY } from '../util'

data にエラーメッセージを格納する errors を追加します。

PhotoForm.vue
data () {
  return {
    preview: null,
    photo: null,
    errors: null
  }
},

submit メソッドは以下のように編集してください。

PhotoForm.vue
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 で処理を中断します。

テンプレートにエラーメッセージの表示欄を追加します。

PhotoForm.vue
<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 を作成してください。

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 に登録します。

PhotoForm.vue
import Loader from './Loader.vue'

export default {
  components: {
    Loader
  },
  /* 以下略 */

そして data にローディングを表示させるかどうかを表す loading を追加します。

PhotoForm.vue
data () {
  return {
    loading: false,
    preview: null,
    photo: null,
    errors: null
  }
},

テンプレートにローディング表示を追加します。ローディングが表示されている間は逆に入力欄は隠すので <form> にも v-show を追加しています。これで loadingtrue の間はローディングが出て入力欄が隠れます(逆もまた然りです)。

PhotoForm.vue
<div v-show="loading" class="panel">
  <Loader>Sending your photo...</Loader>
</div>
<form v-show="! loading" class="form" @submit.prevent="submit">

最後に submit メソッドの API 通信箇所の前後にローディングの表示状態を制御する記述を追加します。まずメソッドの冒頭で loadingtrue にします。これでローディングは表示されます。通信が終わったら loadingfalse にしてローディングを非表示にします。

PhotoForm.vue
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 を追加してください。

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.jsmessage モジュールを読み込みます。

index.js
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 を作成してください。

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 メソッドにメッセージ登録の記述を追加します。

PhotoForm.vue
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> を配置します。

App.vue
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
  },
App.vue
<div class="container">
  <Message /> <!-- ★ 追加 -->
  <RouterView />
</div>

以上でサクセスメッセージ表示機能が実装できました。


ブラウザで一連の動作を確認してみましょう。
投稿結果を表示する機能をまだ作っていないので、直接 S3 バケットやデータベースを覗いてうまく投稿できているかを確認してください。

👾 👾 👾

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

次の章では写真の一覧を取得する Web API を実装します。

関連記事

連載記事(全16回)

その他


<!-- Font Awesome Free 5.0.13 by @fontawesome - https://fontawesome.com --><!-- License - https://fontawesome.com/license (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) -->