#やりたいこと
ジャズライブの口コミを投稿できるサイトをチームで制作しています。
今回は、ユーザーの投稿にいいねをできる機能を実装したいです。結構苦労したので、初心者だけど、いや初心者だからこそ日本一わかりやすくメモを残しておきたいと思います。ご指摘などありましたら、ぜひ容赦無くよろしくお願いします。
Laravel 7です。
###仕様
- ハートボタンとカウンターで構成する
- 各投稿が合計いくつのいいねを集めているかが表示される
- 下記行動ができるのはログインしているユーザーのみ
- いいねボタンが押されたらスタイルをピンクにし、いいねカウントの数字を1増やす
- もう一度押されたらスタイルと数字を元に戻す
- あるユーザーが1つの投稿に対していいねが押せるのは1回のみ
- 自分がいいねした投稿は常にピンクになっている
###実装前のプログラムの挙動に関する疑問...
- どの時点でサーバー側にリクエストを出せばいい?(非同期処理だとしたら、イベントが発生した直後?)
- 非同期処理だとしたら、何回も押されたらその都度リクエストを出してサーバーと通信するのか??
- 誰がリクエストを出す?(JavaScriptのEventListener?)
- どんなリクエストを出せばいい?(review_id, いいね押されたよっていう何らかのサイン...でもどんな??)
- 投稿が削除されたら、それに付されたいいねもDBから全て削除する(リレーションで設定可能)
こんなこともわからない状態からでもなんとか実装に至りました!
###材料
僕はお恥ずかしながら、まずどんな材料が必要なのか?そこから想像するのが難しかったです。
で、結局はこんなのが必要でした↓
材料 | |
---|---|
View | これは誰でもわかりますよね。 |
CSS | いいねされたハートマークは色を変えたいところです。 |
Frontのスクリプト | この辺は僕でも、サイトを動的にするには必要だってわかりました。非同期処理はajaxでjQueryがそれできると知っていたので、jQueryを採用。 |
DB | いいね専用のテーブルを作ります。ある投稿がいくついいねを集めているかカウントするために必要です。今回はEloquentでModelを作成しusersとreviewsと1対多でリレーションする。 |
migration | DBバージョン管理ファイルで、各開発環境でテーブルの自動生成をしてくれます。 |
Model | 今回はEloquentを利用します。ModelはDBテーブルの内容を定義したり操作するクラスです。 |
Route | ルートも要るんですね。ページが遷移しない機能は初めてで、ページが遷移しなくてもルートって要るんだ!と知りました...。 |
Controller | コントローラも要るんですね。でも確かにコントローラなきゃ誰がDBと通信するの?て話ですよね。 |
では、実際にどう料理するか見てきましょう! |
#実装!
###DB データベース設計
テーブル構造(必要なフィールドのみ抜粋)
users
id
created_at, updated_at
reviews
id
created_at, updated_at
user_id
likes
id
created_at, updated_at
user_id
review_id
usersとreviewsが既に作成済という前提で、likesテーブルとリレーションを作っていきましょう。
ターミナルで下記コマンドで、モデルを生成します。-m
もしくは--migration
オプションでマイグレーションファイルも同時に作成できます。
$php artisan make:model Like -m
###Migration マイグレーション
DBのバージョン管理機能、マイグレーションファイルで、先のテーブル構造を作っていきます。
class CreateLikesTable extends Migration
{
public function up()
{
Schema::create('likes', function (Blueprint $table) {
$table->id(); //bigIncrements('id')と同じ
$table->timestamps(); //s複数形でcreated_atとupdated_atを生成
$table->foreignId('user_id') //usersテーブルの外部キー設定
->constrained() //userテーブルのidカラムを参照するconstrainedメソッド
->onDelete('cascade'); //削除時のオプション
$table->foreignId('review_id') //同じことをreviewsテーブルとも
->constrained()
->onDelete('cascade');
});
}
マイグレーションファイルが準備できたら、マイグレートを実行します↓
$php artisan migrate
###Model モデル(Like.php)
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
class Like extends Model
{
public function user()
{ //usersテーブルとのリレーションを定義するuserメソッド
return $this->belongsTo(User::class);
}
public function review()
{ //reviewsテーブルとのリレーションを定義するreviewメソッド
return $this->belongsTo(Review::class);
}
}
- belongsToの中身User::class。
::
はスコープ定義演算子。static、定数、オーバーライドされたクラスのプロパティやメソッドにアクセスできる。::class
はclassキーワードと呼ばれ、クラスの完全修飾名を文字列で取得できる。つまり'App\Model名'
が返ってくる。つまり'App\Model名'をパラメータにするのと同じことです。(むしろそれが今は標準?)
###Model モデル(User.php) ←2020/11/12追記
<?php
public function likes()
{
return $this->hasMany('App\Like');
}
↑こちらを追記します。Likeモデルとのリレーションを定義します。
###Model モデル(Review.php) ←2020/11/12追記
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
use App\Like;
class Review extends Model
{
public function likes()
{
return $this->hasMany('App\Like');
}
//後でViewで使う、いいねされているかを判定するメソッド。
public function isLikedBy($user): bool {
return Like::where('user_id', $user->id)->where('review_id', $this->id)->first() !==null;
}
}
↑**YouTube万屋エンジニアチャンネル様**のアドバイスでisLikedByメソッドを作りました。元々Viewでやっていたのですが、なるべくこういう処理はモデルにやらせた方がいいとのこと。感謝!
###view ビュー
ビューを作る前に仕様の再確認です。
いいねをできるのはログインユーザーのみ。なので、①ログインユーザーと②ゲストでの条件分岐が必要。
さらに自分が一度いいねした投稿は、いつ来てもピンクなままでないとわからなくなってしまうので、①の中でもいいね済の投稿とそうでない投稿を条件分岐しないとなりません。
まとめると、3つの条件分岐が必要になります→(①-1 ログインユーザーでいいねの押されていない投稿、①-2 ログインユーザーでいいねの既に押された投稿、② ゲストユーザー)
<!-- head内 -->
<meta name="csrf-token" content="{{ csrf_token() }}">
<!-- body内 -->
<!-- 参考:$itemにはReviewControllerから渡した投稿のレコード$itemsをforeachで展開してます -->
@auth
<!-- Review.phpに作ったisLikedByメソッドをここで使用 -->
@if (!$review->isLikedBy(Auth::user()))
<span class="likes">
<i class="fas fa-music like-toggle" data-review-id="{{ $item->id }}"></i>
<span class="like-counter">{{$item->likes_count}}</span>
</span><!-- /.likes -->
@else
<span class="likes">
<i class="fas fa-music heart like-toggle liked" data-review-id="{{ $item->id }}"></i>
<span class="like-counter">{{$item->likes_count}}</span>
</span><!-- /.likes -->
@endif
@endauth
@guest
<span class="likes">
<i class="fas fa-music heart"></i>
<span class="like-counter">{{$item->likes_count}}</span>
</span><!-- /.likes -->
@endguest
- auth, endauth, guest, endguestは認証ディレクティブと呼ばれるbladeの機能で、アクセスしているユーザがログインしているのかゲストかを判別できます。
- iタグはfont-awesomeです。(音楽サイトなので、ハートではなく♫を使ってます)
- 巷にはなぜかaタグを使用するコーディング例が多いですが不要です。aタグを入れると余計なページ遷移が起きるので、逆にうざかったのですが、aタグを使うメリットをぜひ教えてください。
- iタグのdata-の属性はカスタムデータ属性と呼ばれるグローバル属性(HTMLのどのタグにも設定可能な属性)の一種。これを介して、HTMLとJavaScript、jQuery間などでデータをやりとりできます。ここではいいねをする対象の投稿のid(review_id)をjQueryに伝える役割を担います。
###CSS
いいねマークにlikedクラスが付いた時のスタイルを適宜設定します。
.liked {
color: pink;
}
###jQuery
$(function () {
let like = $('.like-toggle'); //like-toggleのついたiタグを取得し代入。
let likeReviewId; //変数を宣言(なんでここで?)
like.on('click', function () { //onはイベントハンドラー
let $this = $(this); //this=イベントの発火した要素=iタグを代入
likeReviewId = $this.data('review-id'); //iタグに仕込んだdata-review-idの値を取得
//ajax処理スタート
$.ajax({
headers: { //HTTPヘッダ情報をヘッダ名と値のマップで記述
'X-CSRF-TOKEN' : $('meta[name="csrf-token"]').attr('content')
}, //↑name属性がcsrf-tokenのmetaタグのcontent属性の値を取得
url: '/like', //通信先アドレスで、このURLをあとでルートで設定します
method: 'POST', //HTTPメソッドの種別を指定します。1.9.0以前の場合はtype:を使用。
data: { //サーバーに送信するデータ
'review_id': likeReviewId //いいねされた投稿のidを送る
},
})
//通信成功した時の処理
.done(function (data) {
$this.toggleClass('liked'); //likedクラスのON/OFF切り替え。
$this.next('.like-counter').html(data.review_likes_count);
})
//通信失敗した時の処理
.fail(function () {
console.log('fail');
});
});
});
-
$()
はjQueryのセレクターの書き方。$はjQueryの略です。 - thisは変数の一種で、特殊な変数。プログラムが自動的に値を代入するもの。今回はイベントが発火した変数like=iタグが代入されています。
- なぜ
$this
と$
が付くのか?jQueryにおいて、変数を宣言する際、$
はjQueryオブジェクト(=$()
でセレクトした要素)を入れるための変数宣言で、 $は必須ではないが、通常の変数と区別するのに通常使います。 - .onはイベントハンドラー。第一パラメータにイベントの種類、第二パラメータにハンドラとして無名関数を取っています。
-
.dataはjQueryのメソッド。HTML内に仕込んだカスタムdata属性の値を取得することができます。ここでは投稿のid(review_id)を受け取ります。
取得したい要素.data('カスタムdata属性の名前')
という文法になります。 -
$.ajax()は、非同期処理を行うイベントハンドラ。$.はこれまたjQueryのこと。中のパラメータはコード内の説明ご参照。ここの大きな構文は、
$.ajax().done().fail()
です。通信に成功したら、doneメソッドを、失敗したらfailメソッドを実行します。 - attr()は、attributeの略で日本語訳すると「属性」です。つまり、なんてことはない、指定された属性の値を取ってくるメソッドです。(日本人プログラマの不利なところは一回英単語の意味を知らないといけないとこだなと最近思ってます)
- .next()は、セレクター。Sibling(兄弟)の後ろの要素を全て、つまり全ての弟を返します。その中から特定の要素を指定する場合は、パラメータで指定します。
- obj.html(htmlString)は、obj(=英語でいうところの目的語)にhtmlStringをセットします。.done()のパラメータdataにはコントローラからreview_likes_countという名前の「新規いいね後の総いいね数」が(←あとでコントローラで見ていきましょう)、
{review_likes_count : 1}
というJSONの形で渡ってきます。それをdata.review_likes_countとい文法でプロパティにアクセスします。JavaScriptの記法では「変数名.プロパティ名(=key)」でそのオブジェクトの対応する値が取得できます。この.をドット演算子と呼びます。このサイトがわかりやすいです。 - .fail()には、処理が失敗したときの指示をコールバック関数で書いておきます。コールバック関数とは、他の関数(ここではfail)に引数として渡される関数です。ここでは、単純にconsoleに'fail'という文字列をログを出力するというだけの実装にしています。
###Route ルート
Route::post('/like', 'ReviewController@like')->name('reviews.like');
Ajaxで指定したurlと整合性を取ります。
###Controller コントローラ(いいねをする/撤回する)
LikeControllerを作った方が良かったのかもしれませんが、私は投稿を制御するReviewController.phpに作りました。
public function like(Request $request)
{
$user_id = Auth::user()->id; //1.ログインユーザーのid取得
$review_id = $request->review_id; //2.投稿idの取得
$already_liked = Like::where('user_id', $user_id)->where('review_id', $review_id)->first(); //3.
if (!$already_liked) { //もしこのユーザーがこの投稿にまだいいねしてなかったら
$like = new Like; //4.Likeクラスのインスタンスを作成
$like->review_id = $review_id; //Likeインスタンスにreview_id,user_idをセット
$like->user_id = $user_id;
$like->save();
} else { //もしこのユーザーがこの投稿に既にいいねしてたらdelete
Like::where('review_id', $review_id)->where('user_id', $user_id)->delete();
}
//5.この投稿の最新の総いいね数を取得
$review_likes_count = Review::withCount('likes')->findOrFail($review_id)->likes_count;
$param = [
'review_likes_count' => $review_likes_count,
];
return response()->json($param); //6.JSONデータをjQueryに返す
}
- Authクラスに対してuserメソッドでログインしているユーザーのモデルインスタンスを返す。
- 投稿のidが、ビューのカスタムdata属性→jQuery Ajax経由で$requestで渡ってきているので、それをキャッチします。
- このユーザーがこの投稿に既にいいねをしていれば、そのlikeレコードを取得します。
- モデルから新しいレコードをDBに挿入するには、まず新しいインスタンス(記入用紙)を準備して、save()メソッドで実行します。いいねの新規レコードをDBに挿入する準備として、newキーワードでLikeクラスのインスタンスを生成しておきます。参考→公式ドキュメント「クラスの基礎」
- 投稿テーブルを管理するReviewモデルに対してwithCountメソッドを使用することでリレーションされている別テーブルの数をカウントすることができます。今回はパラメータにいいねテーブルとのリレーション名likesを渡すことで、いいねの数を数えます。投稿idをキーにしています。likes_countはwithCountメソッドとセットの関係にあります。Laravelでは、
withCount('hoge')
とすると自動的にhoge_count
というフィールドが生成されるのです。参考→関連するモデルのカウント - jQueryの
.done(data)
に$paramという変数名で最新の総いいね数を返します。
###Controller コントローラ(いいね数をindexに表示する)
いいねをする、撤回するの機能は以上ですが、次回index.blade.phpに訪問したときに、いいね数を返してあげないといけません。
public function index(Request $request)
{
$reviews = Review::withCount('likes')->orderBy('id', 'desc')->paginate(10);
$param = [
'reviews' => $reviews,
];
return view('reviews.index', $param);
}
- withCount()は先ほど説明した通りです。これをitemsでビューに返して、ビューは
{{$items->likes_count}}
で投稿ごとにいいね数を取得、表示することができます。 - orderBy()は指定した順番でレコードを取得するメソッド。第一パラメータは並べ替えの基準となるフィールドを選択、第二パラメータはソートする方法(昇順asc=小さい順、降順desc=大きい順)を指定します。
- paginateはレコードが多い場合に、1ページごとの表示数を指定するメソッドです。
- @ganchan9528 さんのご指摘で、2021/1/19に一部訂正しました。コメント欄ご覧ください。
実装は以上になります。
#エラーと対処の例
私が直面したエラーを参考にメモしておきます。
###responseJSON: {message: "CSRF token mismatch.", responseText: "{↵ "message": "CSRF token mismatch.",
<meta name="csrf-token" content="{{ csrf_token() }}">
をビューのheadタグに追記して解決
###responseJSON: {message: "Unauthenticated."} status: 401 statusText: "Unauthorized"
原因:ゲストのまま一生懸命いいねボタン押していたから(汗)
###responseJSON: message:"Property [likes_count] does not exist on the Eloquent builder instance."
→なんだったっけ...すみません。忘れました。思い出したら追記します。
あとがき
自分が実装したことをきちんと理解したく、あやふやなところを全て言語化しました。もしご指摘などあれば、ぜひコメントください。
Thanks to:
【jQuery】$(this)の意味&使い方について