6
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

【Rails6】共感(いいね)機能を、JavaScriptでAPIにfetchでリクエストを送って非同期通信(Ajax)で実装してみた

Posted at

#はじめに

##なぜこの記事を書くことにしたのか?
jQueryを使用したAjaxの実装方法はあったのですが、素のJavaScriptでAPIにfetchでリクエストを送る実装方法が、ネット上であまり見受けられなかったからです。
私のようにJavaScriptで1から実装したいと考えている方の参考になればと思います。
私のポートフォリオでは、いいね機能のことを共感機能と名付けているので、これより下の説明では共感機能と呼びます。

#共感機能の仕様を考える

##画面のレイアウト・動作
投稿にある共感ボタンを押すと、色とテキストが変化します。

Image from Gyazo

##使用する言語・フレームワーク

  • Ruby 3.0.0
  • Rails 6.1.3
  • MySQL 8.2.3
  • tailwindcss 1.9.0

##データベースのテーブル設計
今回関係あるのは、usersテーブル、empathiesテーブル、postsテーブルです。
usersテーブルはユーザーを表しています。
postsテーブルは投稿を表しています。
empathiesは中間テーブルです。

テーブル名 カラム名 カラム名 カラム名 カラム名
users id nickname email password
posts id text user_id password
empathies id user_id post_id

#共感機能の流れ
##流れ
共感機能は、共感ボタンを押すことで、JavaScriptが作動し、共感ボタンの色とテキストを変化させます。そして、JavaScriptはサーバーと非同期通信を行い、データをRails側に渡しに行きます。Railsでは、データベースに必要な情報を保存・削除します。
以上が、大まかな共感機能の流れとなります。
では、非同期通信とは何なのか説明する前に、同期通信について説明します。

##同期通信とは
クライアントとサーバーが交互に処理を行い、同調して通信を行うことを同期通信と呼びます。同期通信の場合、サーバーが処理を待っている間、クライアントは待つことしかできず、HTMLファイルを受け取ってから表示の処理を行うため、全体としてページの更新に時間がかかってしまいます。また、送信するデータも多くなりがちで、サーバーに負担がかかってしまいます。

##非同期通信とは
非同期通信はAjaxをも呼ばれています。Ajaxは同期通信の欠点を補うために誕生しました。AjaxではWebブラウザ上で、クライアントサイド・スクリプトとして動くJavaScriptが直接Webサーバーと通信を行い、取得したデータを用いて、表示するHTMLを更新します。HTMLそのものをやり取りするのではなく、更新に必要なデータのみをやりとりするため、送信するデータ量は同期通信と比べて少ないため、サーバーへの負担が抑えられます。

#ルーティングの設定
共感ボタンをクリックした時に、JavaScriptにjson形式で値を渡せるように定義します。

config/routes.rb
Rails.application.routes.draw do

  root to: "posts#index"
  
  devise_for :users
  
  resources :posts

  namespace :api, format: :json do
    namespace :v1 do
      resources :empathies, only: [:create, :destroy]
    end
  end
end

#モデルの設定

app/models/user.rb
class User < ApplicationRecord
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable
  has_many :posts, dependent: :destroy
  has_many :empathies, dependent: :destroy
end
app/models/post.rb
class Post < ApplicationRecord
  belongs_to :user
  has_many :empathies, dependent: :destroy

  # postのuserに関するempathyレコードを取得する
  def empathy_by(user)
    empathies.where(empathies: { user_id: user }).last
  end

  # userが共感しているかチェックしている
  def empathy_by?(user)
    empathy_by(user).present?
  end
end
app/models/empathy.rb
class Empathy < ApplicationRecord
  belongs_to :user
  belongs_to :post
  EMPATHY_COLOR = "inline-block border border-red-500 py-1 px-2 rounded-lg text-white bg-red-500".freeze
  UNEMPATHY_COLOR = "inline-block border border-red-500 py-1 px-2 rounded-lg text-red-500 bg-white".freeze
end

「EMPATHY_COLOR」と「UNEMPATHY_COLOR」ですが、tailwindcssを使っているので、class名でcssを設定しています。これは、後ほどビューで使用するので、あらかじめモデル内で定義しています。

#ビューの設定
共感ボタンを様々なページで使いまわしたので、パーシャルテンプレートを使います。

app/views/empathies/_empathies.html.erb
<% if user_signed_in? %>
  <% empathy_button_color = post.empathy_by?(current_user) ? Empathy::EMPATHY_COLOR : Empathy::UNEMPATHY_COLOR %>
  <% if post.empathy_by?(current_user) %>
    <button class="js-empathy-button <%= empathy_button_color %>" id="<%= post.id %>" value="<%= post.empathy_by(current_user).id %>">共感済み</button>
  <% else %>
    <button class="js-empathy-button <%= empathy_button_color %>" id="<%= post.id %>" value=" ">共感する</button>
  <% end %>
<% end %>

empathy_button_color = post.empathy_by?(current_user) ? Empathy::EMPATHY_COLOR : Empathy::UNEMPATHY_COLOR
この部分ですが、「AAA ? BBB : CCC」を使用しています。
これは、AAAという条件に該当する場合はBBBを実行、該当しない場合はCCCを実行するという意味です。
つまり、postにすでに共感していればEMPATHY_COLORを代入して、まだ共感していなければUNEMPATHY_COLORを代入するという意味です。

#jsファイルの設定

app/javascript/js/empathies.js
document.addEventListener('turbolinks:load', () => {
  const empathyColor = "js-empathy-button inline-block border border-red-500 py-1 px-2 rounded-lg text-white bg-red-500";
  const unempathyColor = "js-empathy-button inline-block border border-red-500 py-1 px-2 rounded-lg text-red-500 bg-white";
  const empathyEndpoint = '/api/v1/empathies';
  const getCsrfToken = () => {
    const metas = document.getElementsByTagName('meta');
    for (let meta of metas) {
      if (meta.getAttribute('name') === 'csrf-token') {
        return meta.getAttribute('content');
      }
    }
    return '';
  }
  
  const sendRequest = async (endpoint, method, json) => {
    const response = await fetch(endpoint, {
      headers: {
        'Content-Type': 'application/json',
        'Accept': 'application/json',
        'X-CSRF-Token': getCsrfToken()
      },
      method: method,
      credentials: 'same-origin',
      body: JSON.stringify(json)
    });
    
    if (!response.ok) {
      throw Error(response.statusText);
    } else {
      return response.json();
    }
  }
  
  
  const empathyButtons = document.getElementsByClassName('js-empathy-button');
  
  // postの一覧ページで複数要素がある時に対応できるようにfor文を使っている
  for (let i = 0; i < empathyButtons.length; i++) {
    // 共感ボタンをクリックしたときの処理
    empathyButtons[i].addEventListener('click', event => {
      const button = event.target;

      const createEmpathy = (postId, button) => {
        sendRequest(empathyEndpoint, 'POST', { post_id: postId })
          .then((data) => {
            button.value = data.empathy_id
            console.log(button.value);
          });
        }
        
        const deleteEmpathy = (empathyId, button) => {
          const deleteEmpathyEndpoint = empathyEndpoint + '/' + `${empathyId}`;
          sendRequest(deleteEmpathyEndpoint, 'DELETE', { id: empathyId })
          .then(() => {
            button.value = '';
            console.log(button.value);
          });
      }

      if (!!button) {
        const currentColor = button.className;
        const postId = button.id;
        const empathyId = button.value;
        // 共感する場合
        if (currentColor === unempathyColor) {
          button.className = empathyColor;
          button.innerText = '共感済み';
          createEmpathy(postId, button);
        }
        // 共感済みの場合
        else {
          button.className = unempathyColor;
          button.innerText = '共感する';
          deleteEmpathy(empathyId, button);
        }
      }
    });
  }
});

記述がとても長いので、分割して説明します。

  const empathyColor = "js-empathy-button inline-block border border-red-500 py-1 px-2 rounded-lg text-white bg-red-500";
  const unempathyColor = "js-empathy-button inline-block border border-red-500 py-1 px-2 rounded-lg text-red-500 bg-white";
  const empathyEndpoint = '/api/v1/empathies';
  const getCsrfToken = () => {
    const metas = document.getElementsByTagName('meta');
    for (let meta of metas) {
      if (meta.getAttribute('name') === 'csrf-token') {
        return meta.getAttribute('content');
      }
    }
    return '';
  }

empathyColorとunempathyColorで、共感ボタンのスタイルを定義しています。tailwindを使っているので、後でclass名を上書きするのに使います。

empathyEndpointはRails側でどのコントローラーを使うのかを設定しています。こちらは、後でfetchメソッドというのが登場してくるのですが、その時にどのurlにデータを送信するのかを設定する時に使います。なので、あらかじめ設定しておきます。

getCsrfTokenですが、こちらを設定しておかないとエラーになってしまいます。なぜかというと、Railsの仕様で、app/views/layouts/application.html.erbに最初から書かれている<%= csrf_meta_tags %> などによって
GET以外のあらゆる非同期通信Requestでは正しいX-CSRF-TokenをRequest Headerに含めないと
サーバー側はRequestを弾くようにしているためです。クロスサイトリクエストフォージェリ(CSRF)というサイバー攻撃対策用のTokenを用いた仕組みです。以下に参考にしたサイトを張っていおきます。

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

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

こちらでは、どのurl(endpoint)に、どのHTTPメソッド(method)で、どんなデータ(json)を送信するのかというの設定しています。
このコードの理解を深めるためには、fetch,async,awaitの使い方をしる必要があります。以下に参考になるサイトを貼るっていおきます。

  // 画面上のボタン要素を全て取得する
  const empathyButtons = document.getElementsByClassName('js-empathy-button');

  // postの一覧ページで複数要素がある時に対応できるようにfor文を使っている
  for (let i = 0; i < empathyButtons.length; i++) {
    // 共感ボタンをクリックしたときの処理
    empathyButtons[i].addEventListener('click', event => {
      const button = event.target;

      const createEmpathy = (postId, button) => {
        sendRequest(empathyEndpoint, 'POST', { post_id: postId })
          .then((data) => {
            button.value = data.empathy_id
          });
        }

        const deleteEmpathy = (empathyId, button) => {
          const deleteEmpathyEndpoint = empathyEndpoint + '/' + `${empathyId}`;
          sendRequest(deleteEmpathyEndpoint, 'DELETE', { id: empathyId })
          .then(() => {
            button.value = '';
          });
      }

      if (!!button) {
        const currentColor = button.className;
        const postId = button.id;
        const empathyId = button.value;
        // 共感する場合
        if (currentColor === unempathyColor) {
          button.className = empathyColor;
          button.innerText = '共感済み';
          createEmpathy(postId, button);
        }
        // 共感済みの場合
        else {
          button.className = unempathyColor;
          button.innerText = '共感する';
          deleteEmpathy(empathyId, button);
        }
      }
    });
  }

まず、画面上のボタンの要素をempathyButtonsに代入します。

画面上に投稿が一つだけの場合は、いきなりaddEventListenerのclickを使用しても問題ないのですが、画面上に複数投稿ある場合は、一旦全ての要素を取得してからでないと、うまく作動してくれません。なので、for文を使用しています。
そして、for文で一つ一つの要素にクリックした時にイベントが発火するようにしていきます。

「const button = event.target;」でクリックしたボタンをbuttonに代入しています。

createEmpathyでは、JavaScriptからRailsのempathiesコントローラーのcreateアクションにリクエストを送信し、送信されたデータを元に新たにempathyレコードが作成され、その作成されたレコードのidを受け取り、buttonタグのbalueに挿入します。

deleteEmpathyでは、JavaScriptからRailsのempathiesコントローラーのdestroyアクションにリクエストを送信し、されたデータを元にemapathyレコードを削除し、無事に削除が完了したら、buttonタグのvalueを空にします。

「!!button」は二重否定を使うことで、trueを返すようにしています。
それより以下は、ボタンのスタイルの上書きを行い、テキストの上書きを行い、それぞれの関数を実行するという感じです。

#作成したjsファイルの読み込み方
以下のように記述してあげることで読み込みが完了します。

app/javascript/packs/application.js
import "../js/empathies"

#まとめ
以上でJavaScriptでAPIにfetchでリクエストを送る実装は終了となります。
お疲れさまでした。

#参考にしたサイト

6
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?