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

# はじめに
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モデルを作成します。

```bash
bundle exec rails g model Like user:references post:references
bundle exec rails db:migrate
```

## アソシエーションの定義
```ruby
# 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等、不要なものは省いておきます。

```bash
rails g controller api/likes index create destroy --skip-template-engine --skip-test-framework --skip-assets --skip-helper
```

コントローラは以下のようになります。
APIなので`ActionController::API`を継承するようにします。

```ruby: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を定義しておきます。

```diff: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のルーティングを以下の通り追加します。

```ruby: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`します。

```ruby: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コマンドでインストールします。

```bash
yarn add axios rails-ujs
```

## webpackのconfig設定

`vue`というaliasを追加します。内容については以下の記事が詳しいです。
https://qiita.com/taaatk/items/3137659e69b82d10cfca

```js: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
最終的に完成したものはこちらです。各種説明はコメントしてみました。

```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) {
        // エラー処理
      }
      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を作成します。

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

```erb: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`の以下の部分と紐付いています。

```js
new Vue({
  el: '#like'
})
```

`<like-button>` という要素が`index.js`の以下の`LikeButton`部分と紐付いており、view側の要素と置き換わります。

```js
new Vue({
  el: '#like',
  components: { LikeButton }
})
```

そして、`:user-id="<%= current_user.id %>"`と`:post-id="<%= post.id %>"`という部分でvue側にrails側の値を渡しています。
vue側では以下のように`props`でその値を受け取れます。 項目は`キャメルケース(camelCase)`で指定します。

```js
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)`での定義が必要です。

```html
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

```js
import LikeButton from '../components/Like/LikeButton.vue'

new Vue({
  components: { LikeButton }
  // ..
})
```

また、`props`などのプロパティは`キャメルケース(camelCase)`が推奨されています。

```js
props: ['userId']
```

### N+1問題について
今回rails側のviewでpostレコード数分いいねボタンを設置しています。よって、レコード数分Vueインスタンスが作成され、毎回「いいね一覧」を取得するためのAPIリクエストが発生している`N+1`状態の実装となっています。

これを解決手段としては、以下の2点かなと考えています。

- rails側から「いいね一覧」をバインドして、vue側は`props`で受け取る
- postの表示をまるっとVue.js側に寄せる

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