2019.01.12

Vue + Vue Router + Vuex + Laravelで写真共有アプリを作ろう (12) 写真一覧ページ


この連載記事では、フロントエンドに 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

この章では写真一覧ページのフロントエンドを実装します。

Photo コンポーネント

まず、写真一つ分に当たる <Photo> コンポーネントを作成します。

トップページ

<PhotoList> が写真データを取得して、得た個数分 <Photo> を表示するというコンポーネント構成を実装していきましょう。

写真の表示

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

Photo.vue
<template>
  <div class="photo">
    <figure class="photo__wrapper">
      <img
        class="photo__image photo__image--portrait"
        :src="item.url"
        :alt="`Photo by ${item.owner.name}`"
      >
    </figure>
  </div>
</template>

<script>
export default {
  props: {
    item: {
      type: Object,
      required: true
    }
  }
}
</script>

まずここまでで写真は表示されます。

このコンポーネントは一つ分の写真データとして item という props を受け取ります。

マウスオーバー時のオーバーレイ

<figure> の下に <RouterLink> を追加します。

Photo.vue
<template>
  <div class="photo">
    <figure class="photo__wrapper">
      <!-- 中略 -->
    </figure>
    <RouterLink
      class="photo__overlay"
      :to="`/photos/${item.id}`"
      :title="`View the photo by ${item.owner.name}`"
    >
    </RouterLink>
  </div>
</template>

これはマウスオーバーすると表示される、黒半透明な背景にボタンや投稿者名が乗る要素です。
写真詳細ページへのリンクになっています。

いいねボタン

<RouterLink> の中にいいねボタンを追加します。

Photo.vue
<template>
  <div class="photo">
    <figure class="photo__wrapper">
      <!-- 中略 -->
    </figure>
    <RouterLink
      class="photo__overlay"
      :to="`/photos/${item.id}`"
      :title="`View the photo by ${item.owner.name}`"
    >
      <div class="photo__controls">
        <button
          class="photo__action photo__action--like"
          title="Like photo"
        >
          <i class="icon ion-md-heart"></i>12
        </button>
      </div>
    </RouterLink>
  </div>
</template>

いいね機能は後の章で実装しますので、今はダミーの数字を置いてあります。

ダウンロードボタン

いいねボタンの下にダウンロードボタン(<a> 要素)を追加します。

Photo.vue
<button
  class="photo__action photo__action--like"
  title="Like photo"
>
  <i class="icon ion-md-heart"></i>12
</button>
<a
  class="photo__action"
  title="Download photo"
  @click.stop
  :href="`/photos/${item.id}/download`"
>
  <i class="icon ion-md-arrow-round-down"></i>
</a>
  • href 属性には前章で作成したダウンロードリンクを指定します。
  • このリンクは <RouterLink> ではなく <a> でなければいけません。Vue Router にハンドリングさせるのではなく、直接サーバーに GET リクエストを送信する必要があるからです。
  • マークアップ的に、写真詳細ページへのリンクの上にダウンロードリンクが乗っています。ダウンロードリンクをクリックしたときにイベントがバブリングして写真詳細ページへのリンクが作動することを防ぐために @click.stop を記述します。これはクリックイベントで event.stopPropagation() が実行されるのと同じ効果を持ちます。

投稿者名

いいねボタンとダウンロードボタンを内包する要素の下に投稿者名を追加します。

Photo.vue
<div class="photo__controls">
  <!-- いいねとダウンロードボタン -->
</div>
<div class="photo__username">
  {{ item.owner.name }}
</div>

PhotoList コンポーネント

次に <PhotoList> コンポーネントを実装します。

まずはテンプレートブロックを以下の内容で編集してください。

PhotoList.vue
<template>
  <div class="photo-list">
    <div class="grid">
      <Photo
        class="grid__item"
        v-for="photo in photos"
        :key="photo.id"
        :item="photo"
      />
    </div>
  </div>
</template>

photos に写真の一覧データが入っていると考えてください。データ数分 <Photo> コンポーネントを描画しています。

次にスクリプトブロックを追加します。

PhotoList.vue
<script>
import Photo from '../components/Photo.vue'

export default {
  components: {
    Photo
  },
  data () {
    return {
      photos: []
    }
  }
}
</script>

テンプレートブロックの説明の通り、<Photo> コンポーネントをインポートし、components に登録しました。さらに data に写真一覧データを入れる photos を追加しました。

あとはページを表示するタイミングで前章で作った写真一覧取得 API を呼び出して、結果を photos に代入してやればいいわけです。

PhotoList.vue
import { OK } from '../util'
import Photo from '../components/Photo.vue'

export default {
  components: {/* 中略 */},
  data: {/* 中略 */},
  methods: {
    async fetchPhotos () {
      const response = await axios.get('/api/photos')

      if (response.status !== OK) {
        this.$store.commit('error/setCode', response.status)
        return false
      }

      this.photos = response.data.data
    }
  },
  watch: {
    $route: {
      async handler () {
        await this.fetchPhotos()
      },
      immediate: true
    }
  }
}

fetchPhotos メソッドを追加します。内容に関しては今までと似たようなパターンなので理解できるかと思います。response.data.data は少しややこしいですが、response.data でレスポンスの JSON が取得できます。前章で設計した通り、その中にさらに data という項目があってその中に写真一覧が入っているのでこのような記述になっています。

また、$route を監視してページが切り替わったときに fetchPhotos が実行されるよう記述しています。さらに immediate オプションを true に設定しているので、コンポーネントが生成されたタイミングでも実行されます。

これはコンポーネントは同じだがページが異なる場合を考慮しています。例えば一覧ページの2ページ目にも <PhotoList> が用いられます。Vue は最適化のために使いまわせるコンポーネントは使いまわそうとします。そのため createdfetchPhotos を呼ぶと、2ページ目に移動したときにコンポーネントが使い回され、created が呼ばれない → fetchPhotos も呼ばれない → データが変わらないという結果になってしまいます。

対応として、$route の監視ハンドラ内で fetchPhotos を実行します。ただしこれだけだと初めて <PhotoList> をレンダリングするときに実行されないので、immediate オプションを true に設定します。


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

$ npm run watch

ページネーション

一覧は表示されるようになったので、ページネーション(ページ送り)機能を実装します。

ページネーション

上記の通り、前後のページに移動するリンクをページ下部に配置します。

各ページの URL は以下のようにクエリパラメータ page が付与される仕様とします。

/
/?page=2
/?page=3

さらに前章でも触れましたが、API も 2ページ目以降の写真一覧を取得したい場合は URL にクエリパラメータ page を付与するのでした。

/api/photos/?page=2

これらの前提を元に実装を進めましょう。

ルート定義

URL のクエリパラメータ page をページコンポーネントで取得して、API のパラメータにも使います。つまり2ページ目(/?page=2)を構成するときに API /api/photos/?page=2 が呼ばれるように実装します。

まずルート定義 router.jsPhotoList のルートを以下の通りに編集してください。

router.js
{
  path: '/',
  component: PhotoList,
  props: route => {
    const page = route.query.page
    return { page: /^[1-9][0-9]*$/.test(page) ? page * 1 : 1 }
  }
}

props を追加しています。この記述で <PhotoList> コンポーネントにクエリパラメータ page の値が、page という props として渡されるようになります。

props に関数を指定する場合は、その返却値が props としてページコンポーネントに渡されます。そしてその関数の引数はルート情報を表す route です。

今回は route からクエリパラメータ page を取り出した上で、正規表現を使って整数と解釈されない値は「1」と見なして返却しています。

Pagination コンポーネント

次に、こちらのボタン部分を一つのコンポーネントとして作成します。

ページネーション

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

Pagination.vue
<template>
  <div class="pagination">
    <RouterLink
      v-if="! isFirstPage"
      :to="`/?page=${currentPage - 1}`"
      class="button"
    >&laquo; prev</RouterLink>
    <RouterLink
      v-if="! isLastPage"
      :to="`/?page=${currentPage + 1}`"
      class="button"
    >next &raquo;</RouterLink>
  </div>
</template>

<script>
export default {
  props: {
    currentPage: {
      type: Number,
      required: true
    },
    lastPage: {
      type: Number,
      required: true
    }
  },
  computed: {
    isFirstPage () {
      return this.currentPage === 1
    },
    isLastPage () {
      return this.currentPage === this.lastPage
    }
  }
}
</script>
  • 現在ページ(currentPage)と総ページ数(lastPage)を props として受け取ります。
  • 「prev(前へ)」ボタンは1ページ目以外の場合に表示します。
  • 「next(次へ)」ボタンは最後のページ以外の場合に表示します。
  • 「1ページ目かどうか」を算出プロパティ isFirstPage で表現しています。
  • 「最後のページかどうか」を算出プロパティ isLastPage で表現しています。

コンポーネントは、独立した表示ロジックを持つ意味のある UI のまとまりで分割するのがよいでしょう。例えば <Pagination> は他の場所で使い回す予定はなく、<PhotoList> にページネーションを記述しても動作は同じですが、コンポーネントを分けたほうがプログラムの見通しがよくなると思いました。コンポーネント設計については、VuetifySemantic UI Vue などのライブラリで遊んでみるのも参考になるでしょう。

PhotoList コンポーネント

最後に <PhotoList> コンポーネントを編集します。

まずは <Pagination> コンポーネントをインポートして components に登録しましょう。

PhotoList.vue
import { OK } from '../util'
import Photo from '../components/Photo.vue'
import Pagination from '../components/Pagination.vue' // ★ 追加

export default {
  components: {
    Photo,
    Pagination // ★ 追加
  },
  /* 以下略 */

<Pagination> コンポーネントに渡すため、現在ページ(currentPage)と総ページ数(total)を data に追加します。

PhotoList.vue
data () {
  return {
    photos: [],
    currentPage: 0,
    lastPage: 0
  }
},

総ページ数と現在ページは API のレスポンスに含まれています。そこで fetchPhotos メソッドの最後に、追加した data 変数にレスポンスの該当する値を代入する記述を追加します。

PhotoList.vue
async fetchPhotos () {
  const response = await axios.get(`/api/photos/?page=${this.page}`)

  /* 中略 */

  this.photos = response.data.data
  this.currentPage = response.data.current_page
  this.lastPage = response.data.last_page
}

最後にコンポーネントの下の方に <Pagination> を配置します。

PhotoList.vue
<div class="photo-list">
  <div class="grid">
    <!-- 中略 -->
  </div>
  <Pagination :current-page="currentPage" :last-page="lastPage" />
</div>

ここまでできたらブラウザから動作を確認しましょう。
いくつか写真を投稿してページネーションが動作していることを確認します。

1ページあたりの項目数を制御する

Laravel のページネーションのデフォルトでは1ページに15アイテムが取得されます。動作確認の際にたくさん写真を投稿するのが大変であれば、Photo モデルに $perPage プロパティを追加してください。この値が1ページあたりのアイテム数になります。

Photo.php
protected $perPage = 15; // この値を少なくすれば動作確認しやすいですね

ページ遷移時にページ先頭を表示

ページ送りボタンでページを前後に移動してみて、ページが変わってもブラウザのスクロール位置が変化しないことに気がついたでしょうか?

ページ遷移したときのスクロール位置も Vue Router の機能で制御することができます。VueRouter のインスタンスを生成するオプションに以下の通り scrollBehavior を追加してください。

router.js
const router = new VueRouter({
  mode: 'history',
  scrollBehavior () {
    return { x: 0, y: 0 }
  },
  routes
})

ここでは x 軸方向も y 軸方向も値をゼロに設定しているので、ページが変わるごとにスクロール位置が先頭に戻ります。そのほかの活用例に関してはマニュアルを参照してください。

おまけ

画像を縦横ぴったり合わせる

この機能はこだわらなくても先には進めるという意味でおまけにしました。とりあえず飛ばしてもアプリケーションは動作します。あとで戻ってきてもよいでしょう。

いま、写真一覧の写真の表示枠は4:3にしています。それより横長の写真だと、以下のように余白ができてしまいます(背景の薄黄色が見えている状態です)。

以下の表示ルールを適用できたらこの問題は解決します。

  • 縦長の画像は、①横幅ぴったりに拡大表示させて縦に中央寄せ(余白は切り捨て)
  • 横長の画像は、②縦幅ぴったりに拡大表示させて横に中央寄せ(余白は切り捨て)

①と②のルールはそれぞれ CSS で表現できます。実際、①のルールが photo__image--portrait クラス、②のルールが photo__image--landscape クラスに適用されています。

しかし、縦長か横長かは画像がロードされるまで分かりません。ということで、CSS だけでなく JavaScript の力も借りてスタイルを適用します。

画像がロードされたイベントハンドラで縦横比を計算して、4:3よりも縦長であれば photo__image--portrait クラス、横長であれば photo__image--landscape クラスを <img> 要素に付与します。

<Photo> コンポーネントを以下のように編集します。

Photo.vue
<img
  class="photo__image"
  :class="imageClass"
  :src="item.filepath"
  :alt="`Photo by ${item.owner.name}`"
  @load="setAspectRatio"
  ref="image"
>
Photo.vue
export default {
  props: {/* 中略 */},
  data () {
    return {
      landscape: false,
      portrait: false
    }
  },
  computed: {
    imageClass () {
      return {
        // 横長クラス
        'photo__image--landscape': this.landscape,
        // 縦長クラス
        'photo__image--portrait': this.portrait
      }
    }
  },
  methods: {
    setAspectRatio () {
      if (! this.$refs.image) {
        return false
      }
      const height = this.$refs.image.clientHeight
      const width = this.$refs.image.clientWidth
      // 縦横比率 3:4 よりも横長の画像
      this.landscape = height / width <= 0.75
      // 横長でなければ縦長
      this.portrait = ! this.landscape
    }
  },
  watch: {
    $route () {
      // ページが切り替わってから画像が読み込まれるまでの間に
      // 前のページの同じ位置にあった画像の表示が残ってしまうことを防ぐ
      this.landscape = false
      this.portrait = false
    }
  }
}

できたらブラウザで確認してみましょう。
どの写真も縦横ぴったりに収まって表示されているはずです。

👾 👾 👾

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

次の章では写真の詳細ページを実装します。
実装や説明の分量が多くないので 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) -->