Help us understand the problem. What is going on with this article?

Rails + Vue.jsでいいね機能を実装する

More than 1 year has passed since last update.

はじめに

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を継承するようにします。

controllers/api/likes_controller.rb
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を定義しておきます。

models/like.rb
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のルーティングを以下の通り追加します。

config/routes.rb
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します。

Gemfile
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

config/webpack/development.js
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

最終的に完成したものはこちらです。各種説明はコメントしてみました。

javascript/components/Like/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) { process.exit() }
      return res.data
    },
    // rails側のcreateアクションにリクエストするメソッド
    registerLike: async function() {
      const res = await axios.post('/api/likes', { post_id: this.postId })
      if (res.status !== 201) { process.exit() }
      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) { process.exit() }
      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を作成します。

javascript/packs/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を呼び出します。

views/posts/index.html.erb
<!-- ここでエントリポイントとなるファイルを指定
     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側に寄せる

以上です。
不備やこうした方が良いなどありましたら、コメントお願いします。

Why do not you register as a user and use Qiita more conveniently?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away