はじめに
railsで「いいね」機能を作る記事はQiita上でも上がっていますが*.js.erb
のパターンばっかりだったので、今風にwebpacker + Vue.jsで作成してみました。
※なお、内容については独学&完全趣味レベルなので、何かあればご指摘下さい。
GitHubで今回作ったものを公開しています。Docker環境がある方は数コマンドですぐに試せると思います。
https://github.com/FukushimaTakeshi/rails-vuejs-iine-sample
環境
- Ruby : 2.5.0
- Ruby on Rails : 5.2.1
- webpacker : 3.5.5
- Vue.js : 2.5.17
実装方針
ざっくりとした全体の方針は以下の通りです。
-
Rails側
- いいねの一覧を返すindexアクションを実装
- いいねボタンが押された時のcreateアクションを実装
- いいねが解除された時のdestroyアクションを実装
- これらをAPIとして提供する
- viewの実装
- post一覧をレンダリング
- Vue.js側のいいねボタンのエントリーポイントを指定、post数の分だけいいねボタンを設置
- postといいねの関連付け情報として
user_id
post_id
をVue側にバインドする
-
Vue.js側
- いいねボタンは
.vue
拡張子の単一ファイルコンポーネントで実装 - いいね一覧をrails側APIにリクエストする
- 「いいね数」および「ユーザがいいね済み」であるかを判定する
- いいねボタンが押されたor解除された場合にrails側APIにリクエストする
- いいねボタンは
モデルの作成
今回扱うモデルは以下です。
- User
- Post
- Like
※ User、Postは既に作成済みの前提で進めます。このあたりは他の記事と変わらないので適宜読み替えて下さい。(ちなみにUserはdeviseで作成)
以下のコマンドでLikeモデルを作成します。
bundle exec rails g model Like user:references post:references
bundle exec rails db:migrate
アソシエーションの定義
# models/user.rb
class User < ApplicationRecord
has_many :posts, dependent: :destroy
has_many :likes, dependent: :destroy
end
# models/post.rb
class Post < ApplicationRecord
belongs_to :user
has_many :likes, dependent: :destroy
end
# models/like.rb
class Like < ApplicationRecord
belongs_to :user
belongs_to :post
end
UserとPostの関係を中間テーブルのLikeに持ちます。このあたりも適宜読み替えて下さい。
コントローラの作成
APIとしてのlikes controllerを作成します。
ターミナル上で以下を実行します。api
という名前空間を切っています。またAPIなのでassets等、不要なものは省いておきます。
rails g controller api/likes index create destroy --skip-template-engine --skip-test-framework --skip-assets --skip-helper
コントローラは以下のようになります。
APIなのでActionController::API
を継承するようにします。
class Api::LikesController < ActionController::API
before_action :authenticate_user!
def index
render json: Like.filter_by_post(params[:post_id]).select(:id, :user_id, :post_id)
end
def create
current_user.likes.create!(likes_params)
head :created
end
def destroy
current_user.likes.find(params[:id]).destroy!
head :ok
end
private
def likes_params
params.require(:like).permit(:post_id)
end
end
post_id
でいいね一覧抽出できるようにLikeモデルにfilter_by_post
というscopeを定義しておきます。
class Like < ApplicationRecord
belongs_to :user
belongs_to :post
+ scope :filter_by_post, ->(post_id) { where(post_id: post_id) if post_id }
end
ルーティングの追加
likes controllerのルーティングを以下の通り追加します。
Rails.application.routes.draw do
namespace :api, { format: 'json' } do
resources :likes, only: [:index, :create, :destroy]
end
end
Vue.jsの実装
webpackerのインストールから説明していますが、Docker環境がある方は最初にあるGitHubリンクのリポジトリをclone後に数コマンドですべてセットアップされた環境が立ち上がるので、とりあえず試したい方はそちらをご利用下さい。
webpacker gemのインストール
railsアプリにwebpacker
をインストールします。このへんの流れは他にも記事があるので、さらっと手順のみ説明します。
Gemfile
に以下を追記してbundle install
します。
gem 'webpacker'
yarnが必要なので入ってなれけばインストールして下さい。
https://yarnpkg.com/lang/en/docs/install/
Vue.jsのインストール
bundle exec rails webpacker:install:vue
上記コマンドでVue.jsのインストールができます。app/javascript
というディレクトリができているので、以降はこのディレクトリ上にファイルを作成していきます。
axiosとrails-ujsのインストール
APIをリクエストする際に必要となるnpmモジュールをインストールします。
ここではHTTPクライアントとしてaxios
、railsのCSRFトークン認証に使用するrails-ujs
をyarn addコマンドでインストールします。
yarn add axios rails-ujs
webpackのconfig設定
vue
というaliasを追加します。内容については以下の記事が詳しいです。
https://qiita.com/taaatk/items/3137659e69b82d10cfca
process.env.NODE_ENV = process.env.NODE_ENV || 'development'
const environment = require('./environment')
module.exports = Object.assign({}, environment.toWebpackConfig(), {
resolve: {
alias: {
'vue$': 'vue/dist/vue.esm.js'
}
}
})
いいねボタンの単一ファイルコンポーネントの作成
これ以降は、本記事メインのVue.js側の実装となります。
方針に記載しているように今回いいねボタンは単一ファイルコンポーネントで実装します。
ディレクトリ構成
app/javascript/components
が単一ファイルコンポーネントを格納するためのディレクトリ。
その配下にLike
といういいねコンポーネント用のディレクトリと.vue拡張子のLikeButton.vue
を作成します。
全体のディレクトリ構成は以下のようになります。
app
├── javascript
│ ├── components
│ │ └── Like
│ │ └── LikeButton.vue
│ └── packs
│ └── index.js
あとで説明しますが、app/javascript/packs/index.js
がrailsのViewで呼び出すエントリポイントです。
LikeButton.vue
最終的に完成したものはこちらです。各種説明はコメントしてみました。
<template>
<div>
<div v-if="isLiked" @click="deleteLike()">
いいねを取り消す {{ count }}
</div>
<div v-else @click="registerLike()">
いいねする {{ count }}
</div>
</div>
</template>
<script>
// axios と rails-ujsのメソッドインポート
import axios from 'axios'
import { csrfToken } from 'rails-ujs'
// CSRFトークンの取得とリクエストヘッダへの設定をしてくれます
axios.defaults.headers.common['X-CSRF-TOKEN'] = csrfToken()
export default {
// propsでrailsのviewからデータを受け取る
props: ['userId', 'postId'],
data() {
return {
likeList: [] // いいね一覧を格納するための変数 { id: 1, user_id: 1, post_id: 1 } がArrayで入る
}
},
// 算出プロパティ ここではlikeListが変更される度に、count、isLiked が再構築される (watchで監視するようにしても良いかも)
computed: {
// いいね数を返す
count() {
return this.likeList.length
},
// ログインユーザが既にいいねしているかを判定する
isLiked() {
if (this.likeList.length === 0) { return false }
return Boolean(this.findLikeId())
}
},
// Vueインスタンスの作成・初期化直後に実行される
created: function() {
this.fetchLikeByPostId().then(result => {
this.likeList = result
})
},
methods: {
// rails側のindexアクションにリクエストするメソッド
fetchLikeByPostId: async function() {
const res = await axios.get(`/api/likes/?post_id=${this.postId}`)
if (res.status !== 200) {
// エラー処理
}
return res.data
},
// rails側のcreateアクションにリクエストするメソッド
registerLike: async function() {
const res = await axios.post('/api/likes', { post_id: this.postId })
if (res.status !== 201) {
// エラー処理
}
this.fetchLikeByPostId().then(result => {
this.likeList = result
})
},
// rails側のdestroyアクションにリクエストするメソッド
deleteLike: async function() {
const likeId = this.findLikeId()
const res = await axios.delete(`/api/likes/${likeId}`)
if (res.status !== 200) {
// エラー処理
}
this.likeList = this.likeList.filter(n => n.id !== likeId)
},
// ログインユーザがいいねしているlikeモデルのidを返す
findLikeId: function() {
const like = this.likeList.find((like) => {
return (like.user_id === this.userId)
})
if (like) { return like.id }
}
}
}
</script>
エントリポイント index.jsの作成
あとはrailsのviewから指定するエントリポイントのindex.jsを作成します。
import 'babel-polyfill'
import Vue from 'vue'
// 作成したコンポーネントファイルをimportします
import LikeButton from '../components/Like/LikeButton.vue'
document.addEventListener('DOMContentLoaded', () => {
new Vue({
el: '#like',
components: { LikeButton }
})
})
ビューの作成
最後にrails側のviewからindex.jsを呼び出します。
<!-- ここでエントリポイントとなるファイルを指定
development環境(default設定)では自動的にjsの変更を検知し、webpackでコンパイルされる -->
<%= javascript_pack_tag 'index' %>
<div id="like">
<% @posts.each do |post| %>
<li>
<%= post.content %>
<p>
<% if user_signed_in? %>
<like-button :user-id="<%= current_user.id %>" :post-id="<%= post.id %>"></like-button>
<% end %>
</p>
</li>
<% end %>
</div>
<div id="like">
がvue側のマウント要素となります。 index.js
の以下の部分と紐付いています。
new Vue({
el: '#like'
})
<like-button>
という要素がindex.js
の以下のLikeButton
部分と紐付いており、view側の要素と置き換わります。
new Vue({
el: '#like',
components: { LikeButton }
})
そして、:user-id="<%= current_user.id %>"
と:post-id="<%= post.id %>"
という部分でvue側にrails側の値を渡しています。
vue側では以下のようにprops
でその値を受け取れます。 項目はキャメルケース(camelCase)
で指定します。
props: ['userId', 'postId']
以上でいいね機能の実装は完成です。
その他
発生したエラーと対処法
axiosの非同期処理にasync/await
を使おうとしたら以下のエラーが発生しました。
Uncaught ReferenceError: regeneratorRuntime is not defined
解決策は他にもあるようですが、babel-polyfillをインポートしてしのぎました。
import 'babel-polyfill'
HTMLとVueの命名規則
HTML(railsのview)とvueのコンポーネントとの紐付けは、ルールがあります。
以下のようにHTMLは大文字と小文字を区別しないのでケバブケース(kebab-case)
での定義が必要です。
OK <like-button></like-button>
NG <LikeButton></LikeButton>
OK <like-button :user-id="hoge"></like-button>
NG <like-button :UserId="hoge"></like-button>
逆にVue側でのコンポーネント名はパスカルケース(PascalCase)
が公式でも推奨されています。
参照 : https://jp.vuejs.org/v2/style-guide/index.html
import LikeButton from '../components/Like/LikeButton.vue'
new Vue({
components: { LikeButton }
// ..
})
また、props
などのプロパティはキャメルケース(camelCase)
が推奨されています。
props: ['userId']
N+1問題について
今回rails側のviewでpostレコード数分いいねボタンを設置しています。よって、レコード数分Vueインスタンスが作成され、毎回「いいね一覧」を取得するためのAPIリクエストが発生しているN+1
状態の実装となっています。
これを解決手段としては、以下の2点かなと考えています。
- rails側から「いいね一覧」をバインドして、vue側は
props
で受け取る - postの表示をまるっとVue.js側に寄せる
以上です。
不備やこうした方が良いなどありましたら、コメントお願いします。