この連載記事では、フロントエンドに 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 |
この章では写真一覧ページのフロントエンドを実装します。
Photo コンポーネント
まず、写真一つ分に当たる <Photo>
コンポーネントを作成します。
<PhotoList>
が写真データを取得して、得た個数分 <Photo>
を表示するというコンポーネント構成を実装していきましょう。
写真の表示
以下の内容で resources/js/components/Photo.vue
を作成してください。
<template>
<div class="photo">
<figure class="photo__wrapper">
<img
class="photo__image"
: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>
を追加します。
<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>
の中にいいねボタンを追加します。
<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>
要素)を追加します。
<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()
が実行されるのと同じ効果を持ちます。
投稿者名
いいねボタンとダウンロードボタンを内包する要素の下に投稿者名を追加します。
<div class="photo__controls">
<!-- いいねとダウンロードボタン -->
</div>
<div class="photo__username">
{{ item.owner.name }}
</div>
PhotoList コンポーネント
次に <PhotoList>
コンポーネントを実装します。
まずはテンプレートブロックを以下の内容で編集してください。
<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>
コンポーネントを描画しています。
次にスクリプトブロックを追加します。
<script>
import Photo from '../components/Photo.vue'
export default {
components: {
Photo
},
data () {
return {
photos: []
}
}
}
</script>
テンプレートブロックの説明の通り、<Photo>
コンポーネントをインポートし、components
に登録しました。さらに data
に写真一覧データを入れる photos
を追加しました。
あとはページを表示するタイミングで前章で作った写真一覧取得 API を呼び出して、結果を photos
に代入してやればいいわけです。
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 は最適化のために使いまわせるコンポーネントは使いまわそうとします。そのため created
で fetchPhotos
を呼ぶと、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.js
の PhotoList
のルートを以下の通りに編集してください。
{
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
を作成してください。
<template>
<div class="pagination">
<RouterLink
v-if="! isFirstPage"
:to="`/?page=${currentPage - 1}`"
class="button"
>« prev</RouterLink>
<RouterLink
v-if="! isLastPage"
:to="`/?page=${currentPage + 1}`"
class="button"
>next »</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>
にページネーションを記述しても動作は同じですが、コンポーネントを分けたほうがプログラムの見通しがよくなると思いました。コンポーネント設計については、Vuetify や Semantic UI Vue などのライブラリで遊んでみるのも参考になるでしょう。
PhotoList コンポーネント
最後に <PhotoList>
コンポーネントを編集します。
まずは前述の通り、ルーターから渡される page
プロパティを受け取るため、props
を追加します。
props: {
page: {
type: Number,
required: false,
default: 1
}
},
次に <Pagination>
コンポーネントをインポートして components
に登録しましょう。
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
に追加します。
data () {
return {
photos: [],
currentPage: 0,
lastPage: 0
}
},
総ページ数と現在ページは API のレスポンスに含まれています。そこで fetchPhotos
メソッドの最後に、追加した data
変数にレスポンスの該当する値を代入する記述を追加します。
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>
を配置します。
<div class="photo-list">
<div class="grid">
<!-- 中略 -->
</div>
<Pagination :current-page="currentPage" :last-page="lastPage" />
</div>
ここまでできたらブラウザから動作を確認しましょう。
いくつか写真を投稿してページネーションが動作していることを確認します。
1ページあたりの項目数を制御する
Laravel のページネーションのデフォルトでは1ページに15アイテムが取得されます。動作確認の際にたくさん写真を投稿するのが大変であれば、Photo
モデルに $perPage
プロパティを追加してください。この値が1ページあたりのアイテム数になります。
protected $perPage = 15; // この値を少なくすれば動作確認しやすいですね
ページ遷移時にページ先頭を表示
ページ送りボタンでページを前後に移動してみて、ページが変わってもブラウザのスクロール位置が変化しないことに気がついたでしょうか?
ページ遷移したときのスクロール位置も Vue Router の機能で制御することができます。VueRouter
のインスタンスを生成するオプションに以下の通り scrollBehavior
を追加してください。
const router = new VueRouter({
mode: 'history',
scrollBehavior () {
return { x: 0, y: 0 }
},
routes
})
ここでは x 軸方向も y 軸方向も値をゼロに設定しているので、ページが変わるごとにスクロール位置が先頭に戻ります。そのほかの活用例に関してはマニュアルを参照してください。
この章はこれでおしまいです。
本章までのソースコードはリポジトリの ch-12 ブランチに置いてあります。
次の章では写真の詳細ページを実装します。
実装や説明の分量が多くないので 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