【Rails,JS】コメントの非同期表示を実装する方法

###この記事では、コメントを非同期で表示できる実装方法を解説します。
- Rails_6.0.0_ を使用しています。
- 商品が出品・購入できるアプリケーションにコメントをつけます。
- それぞれの商品（item）の詳細ページにコメントをする場所があります。
- コメントの保存や送信に必要なRubyのコーディングと、保存したコメントを即時に表示させるJavaScriptのコーディングを行います。
- **すでにコメント投稿機能は完成している体で、非同期機能だけ実装していきます。**

![画面収録 2020-10-06 -1.mov.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/696812/bae71a65-d4e2-3e8f-f2d5-84d671f0ef16.gif)


#実装内容
- channelを用いて実装を行う
- コメントを非同期で表示
- コメントには名前（name）、コメントされた日付（created_at）、コメント内容（text）を表示

#channelとは？
####channelとは？
チャネルとは、即時更新機能を実現するサーバー側の仕組みのことです。データの経路を設定したり、送られてきたデータをクライアントの画面上に表示させたりします。
####channelでどんなことができる？
データの経路を設定したり、送られてきたデータを表示させるJavaScriptを記述すれば、送信したデータが非同期で表示できます。
####channelのファイル作成
ターミナルで以下コマンドを実行
`% rails g channel comment`
(`comment`には自分が作成するファイルの名前を記述)
いくつかファイルができますが、今回は以下2つのファイルを使用します。
#####app/channel/comment_channel.rb
クライアントとサーバーを結びつけるためのファイルです。

#####app/javascript/channels/comment_channel.js
サーバーから送られてきたデータをクライアントの画面に表示させるためのファイルです。
#comment_channel.rbの記述
```ruby
class MessageChannel < ApplicationCable::Channel
  def subscribed
    stream_from "comment_channel"
  end

  def unsubscribed
  end
end
```
`stream_from "comment_channel"`を記述することでサーバーとクライアントを結びつけることができます。






#comments_controller.rbの記述
コントローラーの記述です。
すでに非同期でないコメント実装機能は済んでいる体なので、非同期に関する記述以外の説明は割愛します。
####データベースからJSに渡したいデータを記述する
今回JSで反映させたいデータは以下の通りです

- ユーザーのニックネーム
- コメントされた時間
- コメントのテキスト
- アイテムのid(どの商品にコメントするかを判断するために必要)

この3つの情報を、コントローラーでJSに渡してあげる必要があります。

```ruby
class CommentsController < ApplicationController
  before_action :authenticate_user!
  def create
    @comment = Comment.new(comment_params)
    @item = Item.find(params[:item_id])
    @comments = @item.comments.includes(:user).order('created_at DESC')
    if @comment.valid?
      @comment.save
            ActionCable.server.broadcast  'comment_channel', content: @comment, nickname: @comment.user.nickname, time: @comment.created_at.strftime("%Y/%m/%d %H:%M:%S"), id: @item.id
    else
      render "items/show"
    end
  end

  private
    def comment_params
    params.require(:comment).permit(:text).merge(user_id: current_user.id, item_id: params[:item_id])
    end
end

```
今回の実装で書き足したのは以下の1文だけです。
`      ActionCable.server.broadcast  'comment_channel', content: @comment, nickname: @comment.user.nickname, time: @comment.created_at.strftime("%Y/%m/%d %H:%M:%S"), id: @item.id`

`content`と`user`と`time`と`id`をJSで使用するので、そちらの定義をしてあげました。
`content`
@commentで定義している:text、Commentテーブルのtextカラム、すなわち入力したコメントのことです。
`user`
@commentに紐づいているuserのnicknameをとってきています。（commentとuserにアソシエーションを組んでいます。）
`time`
Commentテーブルのcreated_atカラムです。`strftime("%Y/%m/%d %H:%M:%S")`と記述することで任意の日付、時刻設定を表示できます。以下の記事を参考にさせていただきました。
[strftime を憶えられない (rubyの)](https://qiita.com/annyamonnya/items/97c5cb0cfa414b3592d7)
`item`
自分が今表示しているアイテムのページだけでJSが発火する必要があるので、それを判断するために使用します。

#comment_channel.rbの記述(JavaScript)

今回は`app/javascript/channels/comment_channel.js`の`received()`部分に記述していきます。

```javascript:app/javascript/channels/comment_channel.js
received(data) {
}
```
()の中に`data`と記述してあげることで、コントローラーで定義した値がとってこれるようになります。`received`は、受け取るという意味なので、データを受け取ったら、この中に記述しているJSを実行してね！ということになります。
さあ、これからこの中にJSの記述をしていきます！

#####今開いているアイテムページだけでコメント表示できるように、条件分岐分を書きます。
```javascript:app/javascript/channels/comment_channel.js
//現在開いているページのURLをゲット
let url = window.location.href
//スラッシュ(/)ごとに要素を取り出す
let param = url.split('/');
//このアプリの場合、URLの一番最後にアイテムidがきており、それをparamItemとして定義
let paramItem = param[param.length-1]
// パラメータid(URLの中に含まれているid)が、コントローラーから送った`data.id`かどうかを判断する
if (paramItem == data.id) {}
```
if文で、条件分岐をしてあげます。次は、処理の内容を条件分岐分の中に書いてあげます。
記述する内容は、

- div要素を生成する
- 生成した要素をブラウザに表示させる
- 表示させるテキストを生成する

といった流れです。

#####表示させるためのdivを作る
```javascript:app/javascript/channels/comment_channel.js
    //表示させる場所のdivのIDをとってくる
    const comments = document.getElementById('comments');
    //　すでにあるビューファイルと同じになるようにdivを作成
    const textElement = document.createElement('div');
    textElement.setAttribute('class', "comment-display");
    
    const topElement = document.createElement('div');
    topElement.setAttribute('class', "comment-top");

    const nameElement = document.createElement('div');
    const timeElement = document.createElement('div');

    const bottomElement = document.createElement('div');
    bottomElement.setAttribute('class', "comment-bottom");
```
createElementメソッドを使用し、div要素を作成、必要なものにはそれぞれsetAttributeメソッドでclass名を与えてあげます。

ちなみに、コメントを表示するビューは以下の通り

>```html
      <div id='comments'>
      </div>
      <% @comments.each do |comment| %>
        <div class='comment-display'>
          <div class='comment-top'>
            <div><%= comment.user.nickname %></div>
            <div><%= l comment.created_at %></div>
          </div>
          <div class='comment-bottom'>
            <p><%= comment.text %></p>
          </div>
        </div>
      <% end %>
>```

div要素を生成しましたが、まだブラウザに表示されていません。ブラウザに表示させ、かつ親子関係を作りましょう。

```javascript:app/javascript/channels/comment_channel.js
      // 生成したHTMLの要素をブラウザに表示させる
      comments.insertBefore(textElement, comments.firstElementChild);
      textElement.appendChild(topElement);
      textElement.appendChild(bottomElement);
      topElement.appendChild(nameElement);
      topElement.appendChild(timeElement);
```
insertBefireメソッドと、appendChildメソッドを使用します。
親要素.insertBefore(追加する要素, どこに追加するのか)
親要素.appendChild(追加する要素)
で、insertBefireは任意の場所に、appendChildは親クラスの中の最後のに要素を入れることができます。

もう少し詳しく見たい方は以下をご覧ください
[【JavaScript】appendChildとinsertBeforeの違い](https://qiita.com/zuu_aa/items/d961303d2cf6fa9d6d86)

div要素が作成できたら、次は表示させる情報をとってきましょう。

```javascript:app/javascript/channels/comment_channel.js
      const name = `${data.nickname}`;
      nameElement.innerHTML = name;
      const time = `${data.time}`;
      timeElement.innerHTML = time;
      const text = `<p>${data.content.text}</p>`;
      bottomElement.innerHTML = text;
```
表示させる情報をそれぞれ変数に入れています。dataは、`received(data) {}`のdataです。コントローラーで定義した値のことです。それぞれ`content`,`nickname`,`time`,を定義しましたね。
innerHTMLで、既存の要素にHTMLを上書きをします。

ここまでで表示は完了しました！
ですがこのままだと、２つ問題があります。
1. データは表示されたが、コメント入力欄にコメントが残ったままであること
2. HTMLはデフォルトでボタンが１回しか押せない仕様になっていること
これを解決しましょう！
#####データ送信した後にコメント入力欄のコメントを消す
```javascript:app/javascript/channels/comment_channel.js
    const newComment = document.getElementById('comment_text');
    newComment.value='';
```
コメント入力欄のIDをとってきて、そこの値を空にする、という記述です。
#####何度もコメントボタンを押せるようにする
```javascript:app/javascript/channels/comment_channel.js
    const inputElement = document.querySelector('input[name="commit"]');
    inputElement.disabled = false;
```
"コメントする"ボタンの、name属性をとってきて、そこを`disabled = false`としてあげることで何度もクリック可能になります。

#####記述をまとめると以下の通りです。

```javascript:app/javascript/channels/comment_channel.js
import consumer from "./consumer"

consumer.subscriptions.create("CommentChannel", {
  connected() {
  },

  disconnected() {
  },
  // ↓データを受け取ったら実行してね
  received(data) {
    let url = window.location.href
    let param = url.split('/');
    let paramItem = param[param.length-1]
    if (paramItem == data.id) {
      const comments = document.getElementById('comments');
      const comment = document.getElementsByClassName('comment-display');
      //使用する要素の作成
      const textElement = document.createElement('div');
      textElement.setAttribute('class', "comment-display");
      const topElement = document.createElement('div');
      topElement.setAttribute('class', "comment-top");
      const nameElement = document.createElement('div');
      const timeElement = document.createElement('div');
      const bottomElement = document.createElement('div');
      bottomElement.setAttribute('class', "comment-bottom");
      // 生成したHTMLの要素をブラウザに表示させる
      comments.insertBefore(textElement, comments.firstElementChild);
      textElement.appendChild(topElement);
      textElement.appendChild(bottomElement);
      topElement.appendChild(nameElement);
      topElement.appendChild(timeElement);
      // 表示するテキストを生成
      const name = `${data.nickname}`;
      nameElement.innerHTML = name;
      const time = `${data.time}`;
      timeElement.innerHTML = time;
      const text = `<p>${data.content.text}</p>`;
      bottomElement.innerHTML = text;
      //コメントを送った後、コメント欄をからにする
      const newComment = document.getElementById('comment_text');
      newComment.value='';
      //何度もボタンを押せるようにする
      const inputElement = document.querySelector('input[name="commit"]');
      inputElement.disabled = false;
    }
  }
});
```

##おわりに
完成したと思っていましたが、これを書いている時点でいくつもミスや、ちょっとよくわからない記述を発見しました。
リファクタリングがいかに大切かよく分かりました。
正しく無い記述があるかもしれませんが、誰かのお役に立てれば幸いです。