【Rails・JavaScript・Ajax】いいね機能を2通りの方法で作ってみた

## 開始

この記事は [駆け出しエンジニアの第一歩！ Advent Calendar 2020](https://qiita.com/advent-calendar/2020/programming-beginner) の 16日目 の記事です。


AdventCalendar初参加記事です。エンジニア2年目です。

いいね機能の記事自体は何番煎じ感はありますが、やりたかったことは、JSで書くのはコードも増えて大変という主張です。

いいね機能を作ります。

まず、モデルは両パターン共通で大体こんな感じ。

```app/models/like.rb
class Like < ApplicationRecord
  belongs_to :movie
  belongs_to :user

  LIKED_COLOR = '#ff3366'.freeze
  UNLIKED_COLOR = '#A0A0A0'.freeze
end
```

```app/models/movie.rb
class Movie < ApplicationRecord
  has_many :likes, dependent: :destroy

  def like_by(user)
    likes.where(likes: { user_id: user }).last
  end

  def liked_by?(user)
    like_by(user).present?
  end
end
```

## 【パターン1】 Rails Way版

ルーティングを定義。

```config/routes.rb
resources :movies, only: :show
resources :likes, only: %i[create destroy]
```

コントローラのポイントとしては、**非同期で** `create` , `destroy` アクションが発生する場合、
それぞれ `create.js.slim` `destroy.js.slim` を自動的に `render` するので、それぞれに渡す `@movie` を `before_action` で定義しておいたことくらい。

```app/controllers/likes_controller.rb
class LikesController < ApplicationController
  before_action :set_movie

  def create
    Like.create!(user_id: current_user, movie_id: params[:movie_id])
  end

  def destroy
    Like.find(params[:id]).destroy!
  end

  private

  def set_movie
    @movie = Movie.find(params[:movie_id])
  end
end
```

viewは今回はslimで書いてしまいます。どうでもいいですが、ハートマークはfontawesome使ってる想定。

`link_to` は、
* `params[:movie_id]` をコントローラに渡せるように引数に `movie_id: movie.id` を定義しており、
* `remote: true` を使って非同期でアクションを発生させるようにしています。

```app/views/movies/show.html.slim
#js-like-button
  = render 'like', movie: @movie
```

```app/views/movies/_like.html.slim
- if movie.liked_by?(current_user)
  = link_to like_path(movie.like_by(current_user).id, movie_id: movie.id), method: :delete, remote: :true do
    i.fas.fa-heart style="color: #{Like::LIKED_COLOR}"
- else
  = link_to likes_path(movie_id: movie.id), method: :post, remote: :true do
    i.fas.fa-heart style="color: #{Like::UNLIKED_COLOR}"
```

js.slimの記法がちょっと独特かも。 `create` も `destroy` もrenderする内容は同じ。

```app/views/likes/create.js.slim
| document.getElementById('js-like-button').innerHTML = "#{j(render 'movies/like', movie: @movie)}";
```

```app/views/likes/destroy.js.slim
| document.getElementById('js-like-button').innerHTML = "#{j(render 'movie/like', movie: @user)}";
```

パターン1はこれで完成。Railsのおかげで、さくっとできてしまいます。

## 【パターン2】 JavaScript版

ルーティングを定義。 いいね機能用の `create` と `destroy` アクションは、JavaScriptにJSONを渡すAPIとして定義します。

```config/routes.rb
resources :movies, only: :show

namespace :api, format: :json do
  namespace :v1 do
    resources :likes, only: %i[create destroy]
  end
end
```

API用のコントローラーを定義しています。
`skip_forgery_protection` を書いておかないと、CSRFに引っかかってJavaScript側からAPIを叩けません。※あまり詳しくわかってないので、有識者の方はご教授いただけますと幸いです。
※ 2021年4月19日追記   
別の記事でRailsがAjaxリクエストを受け取る際のCSRF対策について書きました。
> CSRF対策をちゃんとやるなら、fetch のパラメータに headers: { 'X-CSRF-Token': csrfToken } を付与する

https://qiita.com/yokoto/items/a78c7b6a8c1a2f2f9ca0#%E8%BF%BD%E8%A8%98

```app/controllers/api/v1/likes_controller.rb
module Api
  module V1
    class LikesController < ApplicationController
      skip_forgery_protection

      def create
        like = Like.create!(user: current_user, movie_id: params[:movie_id])
        render json: { like_id: like.id }
      end

      def destroy
        Like.find(params[:id]).destroy!
        render json: { } # ちょっとブサイクだが、jsonを返さないとjs側で処理ができなかった。
      end
    end
  end
end
```

viewは一旦、現在いいねされているかどうかを表示しておく。
`input type='hidden'` で、JSで受け取るためのパラメーター `like_id` と `movie_id` を定義しています。

```app/views/movies/show.html.slim
#js-like-button
  - like_button_color = @movie.liked_by?(current_user) ? Like::LIKED_COLOR : Like::UNLIKED_COLOR
  input type='hidden' id='like_id' value="#{@movie.like_by(current_user).id}"
  input type='hidden' id='movie_id' value="#{@movie.id}"
  i.fas.fa-heart style="color: #{like_button_color}"
```

いいねボタンがクリックされたときに、 `create` と `destroy` のAPIを叩くJSを書きます。

* JSのリクエストはfetch APIで書いています。
    * しかもECMAScript2015以降の記法でちゃんと書いたつもりです。
* `rgbTo16()` はRGBカラーを16進数に変換して、比較できるようにしています。
* 色によって叩くAPIが `POST(create)` なのか `DELETE(destroy)` なのかを判別しているが、微妙なやり方だったかもしれない。

```app/javascript/likes.js
document.addEventListener('turbolinks:load', () => {
  const LIKED_COLOR = '#ff3366';
  const UNLIKED_COLOR = '#a0a0a0';
  const LIKE_ENDPOINT = '/api/v1/likes';

  const rgbTo16 = rgb => {
    return '#' + rgb.match(/\d+/g).map((value) => {
      return ('0' + parseInt(value).toString(16)).slice(-2)
    }).join('');
  }

  const sendRequest = async (endpoint, method, json) => {
    const response = await fetch(endpoint, {
      headers: {
        'Content-Type': 'application/json',
        'Accept': 'application/json'
      },
      method: method,
      credentials: 'same-origin',
      body: JSON.stringify(json)
    });

    if (!response.ok) {
      throw Error(response.statusText);
    } else {
      return response.json();
    }
  }

  const createLike = movieId => {
    sendRequest(LIKE_ENDPOINT, 'POST', { movie_id: movieId })
      .then(data => { 
        document.getElementById('like_id').value = data.like_id
      });
  }

  const deleteLike = likeId => {
    const DELETE_LIKE_ENDPOINT = LIKE_ENDPOINT + '/' + `${likeId}`;
    sendRequest(DELETE_LIKE_ENDPOINT, 'DELETE', { id: likeId })
      .then(() => {
        document.getElementById('like_id').value = '';
      });
  }

  const likeButton = document.getElementById('js-like-button');

  if (!!likeButton) {
    likeButton.addEventListener('click', () => {
      const currentColor = rgbTo16(likeButton.style.color);
      const likeId = document.getElementById('like_id').value;
      const movieId = document.getElementById('movie_id').value;

      if (currentColor === UNLIKED_COLOR) {
        likeButton.style.color = LIKED_COLOR;
        createLike(movieId);
      }
      else {
        likeButton.style.color = UNLIKED_COLOR;
        deleteLike(likeId);
      }
    });  
  }
});
```

忘れずにapplication.jsで、書いたファイルを読み込んでおく。
いつも思うんだが、application.jsって `import ~ from ~` で書いちゃってもいいんだろうか。

```app/javascript/packs/application.js
require('../likes')
```

パターン2は以上になります。
渾身のJSコードですが、長いですね汗

## まとめ

簡単なことはRailsが提供してくれるものを使うと早い。

参考リンクは思い出したら貼ります。

感想やレビューいただけると嬉しいです。