2019.01.12

Vue + Vue Router + Vuex + Laravelで写真共有アプリを作ろう (15) いいね機能


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

この章ではいいね機能を実装します。

Web API は、いいね付与 API といいね解除 API を新規追加します。また、写真一覧取得 API と写真詳細取得 API を編集し、写真に付いたいいねについての情報をレスポンスに追加します。

いいね機能 API

レスポンス JSON

いいねの付与および解除のレスポンス JSON は以下の通り写真 ID を返却するものとします。

{
  "photo_id": "abcd1234EFGH"
}

テストコード

テストコードを追加します。

$ php artisan make:test LikeApiTest

tests/Feature/LikeApiTest.php を以下の内容で編集してください。

LikeApiTest.php
<?php

namespace Tests\Feature;

use App\Photo;
use App\User;
use Tests\TestCase;
use Illuminate\Foundation\Testing\WithFaker;
use Illuminate\Foundation\Testing\RefreshDatabase;

class LikeApiTest extends TestCase
{
    use RefreshDatabase;

    public function setUp()
    {
        parent::setUp();

        $this->user = factory(User::class)->create();

        factory(Photo::class)->create();
        $this->photo = Photo::first();
    }

    /**
     * @test
     */
    public function should_いいねを追加できる()
    {
        $response = $this->actingAs($this->user)
            ->json('PUT', route('photo.like', [
                'photo' => $this->photo->id,
            ]));

        $response->assertStatus(200)
            ->assertJsonFragment([
                'photo_id' => $this->photo->id,
            ]);

        $this->assertEquals(1, $this->photo->likes()->count());
    }

    /**
     * @test
     */
    public function should_2回同じ写真にいいねしても1個しかいいねがつかない()
    {
        $param = ['id' => $this->photo->id];
        $this->actingAs($this->user)->json('PUT', route('photo.like', $param));
        $this->actingAs($this->user)->json('PUT', route('photo.like', $param));

        $this->assertEquals(1, $this->photo->likes()->count());
    }

    /**
     * @test
     */
    public function should_いいねを解除できる()
    {
        $this->photo->likes()->attach($this->user->id);

        $response = $this->actingAs($this->user)
            ->json('DELETE', route('photo.like', [
                'photo' => $this->photo->id,
            ]));

        $response->assertStatus(200)
            ->assertJsonFragment([
                'photo_id' => $this->photo->id,
            ]);

        $this->assertEquals(0, $this->photo->likes()->count());
    }
}

いいね付与 API の HTTP メソッドは PUT(リソースの置き換え)で実装します。あるユーザーは特定の写真に対して1個しかいいねが付けられない方が自然な仕様だろうという判断でした。

そのため、2回同じ写真に対していいね付与 API を実行しても結果が変わらない、1個しかいいねが付かないことも確認しています。

ルート定義

ここから API の実装に入ります。

ルート定義は以下の通りです。

api.php
// いいね
Route::put('/photos/{id}/like', 'PhotoController@like')->name('photo.like');

// いいね解除
Route::delete('/photos/{id}/like', 'PhotoController@unlike');

モデルクラス

app/Photo.phplikes リレーションを定義します。

Photo.php
/**
 * リレーションシップ - usersテーブル
 * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
 */
public function likes()
{
    return $this->belongsToMany('App\User', 'likes')->withTimestamps();
}

これは likes テーブルを中間テーブルとした、photos テーブルと users テーブルの多対多の関連性を表しています。

今回は likes テーブルに当たるモデルクラスは作成しません。特に外部キーしか中身のない中間テーブルの場合はモデルクラスは作成する必要のない場合が多いでしょう。Laravel のリレーションの機能を使えば関連するモデルから間接的に中間テーブルを操作することができます。

参考:リレーション(🇺🇸 公式 / 🇯🇵 日本語

withTimestamps はこのリレーションメソッドを使って likes テーブルにデータを挿入したとき、created_at および updated_at カラムを更新させるための指定です。

コントローラー

PhotoController には likeunlike という2つのメソッドを追加します。

まずいいね付与のための like メソッドです。

PhotoController.php
/**
 * いいね
 * @param string $id
 * @return array
 */
public function like(string $id)
{
    $photo = Photo::where('id', $id)->with('likes')->first();

    if (! $photo) {
        abort(404);
    }

    $photo->likes()->detach(Auth::user()->id);
    $photo->likes()->attach(Auth::user()->id);

    return ["photo_id" => $id];
}

何回実行しても1個しかいいねが付かないように、まず特定の写真およびログインユーザーに紐づくいいねを削除して(detach)から、新たに追加(attach)しています。

次にいいね解除のための unlike メソッドです。

PhotoController.php
/**
 * いいね解除
 * @param string $id
 * @return array
 */
public function unlike(string $id)
{
    $photo = Photo::where('id', $id)->with('likes')->first();

    if (! $photo) {
        abort(404);
    }

    $photo->likes()->detach(Auth::user()->id);

    return ["photo_id" => $id];
}

テスト実行

これでいいね付与と解除の API は完成です。

テストを実行しましょう。

$ ./vendor/bin/phpunit --testdox

写真一覧・詳細取得 API

続いて、写真一覧 API と詳細取得 API を修正します。

レスポンス JSON

まずレスポンスの JSON フォーマットですが、2つの項目を追加します。

  • likes_count:写真に付いたいいねの総数。
  • liked_by_user:リクエストしたユーザーがその写真にいいねしているか。ログインしていない状態で呼ばれた場合は false を返す。

写真一覧(data 項目のみ)

{
  "data": [
    {
      "id": "abcd1234EFGH",
      "url": "https://s3-ap-northeast-1.amazonaws.com/backet-name/abcd1234EFGH.jpeg",
      "owner": {
        "name": "John Lennon"
      },
      "likes_count": 12,
      "liked_by_user": true
    }
  ]
}

写真詳細

{
  "id": "abcd1234EFGH",
  "url": "https://s3-ap-northeast-1.amazonaws.com/backet-name/abcd1234EFGH.jpeg",
  "owner": {
    "name": "John Lennon"
  },
  "comments": [
    {
      "content": "Nice picture!",
      "author": {
        "name": "George Harrison"
      }
    }
  ],
  "likes_count": 12,
  "liked_by_user": true
}

テストコード

レスポンスの形式が変わるので、テストコードも修正します。
PhotoListApiTest および PhotoDetailApiTest のテストメソッド内で、 assertJsonFragment の引数の期待値配列に以下の2項目を追加してください。

'liked_by_user' => false,
'likes_count' => 0,

モデルクラス

Photo モデルに likes_countliked_by_user という2つのアクセサを追加します。

まずは likes_count アクセサです。
app/Photo.phpgetLikesCountAttribute メソッドを追加してください。

Photo.php
/**
 * アクセサ - likes_count
 * @return int
 */
public function getLikesCountAttribute()
{
    return $this->likes->count();
}

次に liked_by_user アクセサです。
getLikedByUserAttribute メソッドを追加します。

Photo.php
/**
 * アクセサ - liked_by_user
 * @return boolean
 */
public function getLikedByUserAttribute()
{
    if (Auth::guest()) {
        return false;
    }

    return $this->likes->contains(function ($user) {
        return $user->id === Auth::user()->id;
    });
}

Laravel のコレクションメソッド contains を使って、ログインユーザーのIDと合致するいいねが含まれるか調べています。

参考:コレクション(🇺🇸 公式 / 🇯🇵 日本語

likes リレーションから取得できるのはユーザーモデル(のコレクション)だという点に注意しましょう。

例によって $appends$visible に上記2点のアクセサを追加します。

Photo.php
 /** JSONに含めるアクセサ */
 protected $appends = [
     'url', 'likes_count', 'liked_by_user',
 ];

/** JSONに含める属性 */
protected $visible = [
    'id', 'owner', 'url', 'comments',
    'likes_count', 'liked_by_user',
];

コントローラー

コントローラーメソッドでの対応内容は写真一覧も写真詳細も同じです。with の引数の配列に likes を追加して、likes リレーションがロードされるようにします。

写真一覧

PhotoController.php
public function index()
{
    $photos = Photo::with(['owner', 'likes'])
        ->orderBy(Photo::CREATED_AT, 'desc')->paginate();

    return $photos;
}

写真詳細

PhotoController.php
public function show(string $id)
{
    $photo = Photo::where('id', $id)
        ->with(['owner', 'comments.author', 'likes'])->first();

    return $photo ?? abort(404);
}

テスト実行

これで写真一覧 API と詳細取得 API も完成です。

テストを実行しましょう。

$ ./vendor/bin/phpunit --testdox

API が揃ったのでフロントエンドを実装していきましょう。

写真一覧ページ

まず写真一覧ページです。
以下のような仕組みでいいね機能を実現しようと思います。

  1. <Photo> にあるいいねボタンがクリックされたとき、<Photo> から <PhotoList> にいいねボタンがクリックされたことを通知するイベントを発行する。
  2. <PhotoList> はイベントを受け取って以下の処理を行う。

    1. ユーザーがログイン状態でなければ、ログインを促すアラートを表示する。
    2. ユーザーがクリックした写真にいいね済みであればいいね解除処理を行う。
    3. ユーザーがクリックした写真にいいねしていなければいいね付与処理を行う。

「ユーザーがクリックした写真にいいね済み」かどうかを、レスポンスの JSON に追加した liked_by_user で判定しようというわけです。

また、いいねボタンは以下のようにいいねする前と後で見た目が変わります。

(左:いいねする前 / 右:いいねした後)

ログインしていなければすべて左の見た目になります。

Photo コンポーネント

まず <Photo> コンポーネントのいいねボタンを以下のように編集してください。

Photo.vue
<button
  class="photo__action photo__action--like"
  :class="{ 'photo__action--liked': item.liked_by_user }"
  title="Like photo"
  @click.prevent="like"
>
  <i class="icon ion-md-heart"></i>{{ item.likes_count }}
</button>

<button> 要素に :class@click を追加します。:class は上述の通りいいね済みの場合に見た目を変えるためです。また、アイコンの横にいいね数を表示します。

次に methodslike メソッドを追加します。

Photo.vue
like () {
  this.$emit('like', {
    id: this.item.id,
    liked: this.item.liked_by_user
  })
}

クリックされた写真のIDといいね済みかどうかをデータとしてイベント発行先に渡します。

PhotoList コンポーネント

<PhotoList> コンポーネントでは、まず <Photo> から発行された like イベントを受け取る記述を追加しましょう。onLikeClick メソッドをハンドラとします。

PhotoList.vue
<Photo
  class="grid__item"
  v-for="photo in photos"
  :key="photo.id"
  :item="photo"
  @like="onLikeClick"
/>

そして methodsonLikeClick メソッドを追加します。冒頭に書いた通りのロジックです。

PhotoList.vue
onLikeClick ({ id, liked }) {
  if (! this.$store.getters['auth/check']) {
    alert('いいね機能を使うにはログインしてください。')
    return false
  }

  if (liked) {
    this.unlike(id)
  } else {
    this.like(id)
  }
},

methods にさらに like メソッドと unlike メソッドを追加します。
まず like メソッドです。

PhotoList.vue
async like (id) {
  const response = await axios.put(`/api/photos/${id}/like`)

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

  this.photos = this.photos.map(photo => {
    if (photo.id === response.data.photo_id) {
      photo.likes_count += 1
      photo.liked_by_user = true
    }
    return photo
  })
},

いいね付与 API への通信が完了したあと、ページ上の写真の見た目(いいね数とボタンの色)を変えるため、this.photo のデータを更新しています。

いいね数を一つ増やして、いいねしたかどうかを表す liked_by_usertrue に更新しています(これによってボタンの見た目が変わります)。

次に unlike メソッドです。

PhotoList.vue
async unlike (id) {
  const response = await axios.delete(`/api/photos/${id}/like`)

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

  this.photos = this.photos.map(photo => {
    if (photo.id === response.data.photo_id) {
      photo.likes_count -= 1
      photo.liked_by_user = false
    }
    return photo
  })
}

like メソッドとやっていることはほぼ同じです。unlike ではいいね数を一つ減らして、いいねしたかどうかを表す liked_by_userfalse に更新しています。

写真詳細ページ

写真詳細ページも写真一覧ページと同じパターンです。ただし <PhotoList><Photo> のような階層構造がないので、$emit は必要なく、<PhotoDetail> コンポーネントのみを編集します。

PhotoDetail コンポーネント

まずいいねボタンを編集します。

PhotoDetail.vue
<button
  class="button button--like"
  :class="{ 'button--liked': photo.liked_by_user }"
  title="Like photo"
  @click="onLikeClick"
>
  <i class="icon ion-md-heart"></i>{{ photo.likes_count }}
</button>

編集箇所は先ほどと一緒です。編集内容は異なるので注意してくださいね。

methodsonLikeClick メソッド、like メソッド、unlike メソッドを追加します。

PhotoDetail.vue
onLikeClick () {
  if (! this.isLogin) {
    alert('いいね機能を使うにはログインしてください。')
    return false
  }

  if (this.photo.liked_by_user) {
    this.unlike()
  } else {
    this.like()
  }
},

こちらも写真一覧と同じパターンですね。

PhotoDetail.vue
async like () {
  const response = await axios.put(`/api/photos/${this.id}/like`)

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

  this.$set(this.photo, 'likes_count', this.photo.likes_count + 1)
  this.$set(this.photo, 'liked_by_user', true)
},

コメント投稿のときと同様に、$set を使って this.photo の要素を更新しています。

PhotoDetail.vue
async unlike () {
  const response = await axios.delete(`/api/photos/${this.id}/like`)

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

  this.$set(this.photo, 'likes_count', this.photo.likes_count - 1)
  this.$set(this.photo, 'liked_by_user', false)
}

これでいいね機能は完成しました 🥳
ブラウザでいいねを付けたり外したりしてみましょう。

👾 👾 👾

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

次の章がこのチュートリアルの最後の章です。
最後の仕上げにエラーハンドリングを追加します。

関連記事

連載記事(全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) -->