【Rails】未経験者が非同期処理でコメント投稿を実装〜変数の再宣言エラーを解決するまで〜

## はじめに
　初めまして、未経験からエンジニアへの転職を目指しているrkyと申します。

- 日々、学習したことを振り返ることで理解を深めたい
- 客観的に見てもらうことで間違いに気づいたり、より良い手段を知りたい
- 知識は浅いが、初学者の方と同じ目線で記事を書くことで、少しでも力になりたい

　上記の理由から、Qiitaへの記事投稿を始めることにしました。よろしくお願いします。


## 概要
【学習歴】
Ruby・Ruby on Rails・javascript
9月末から学習を始め、まだ400時間ほどの学習時間です。
学習ツールは
Porogate(HTML・CSS）
デイトラWEBアプリ開発コース(Javascript Ruby Rails)
書籍（スラスラ書けるjavascript・現場Railsなど）
を使用しました。
現在は学んだことを使いながら、ロードバイクのコース共有アプリを作成中です。

【開発環境】
Rails 6.0.6.1
ruby 2.6.5
rails/webpacker 4.3.0
エディタソフト　VScode
htmlはerbではなくhamlで書いています。
erbで書いている方は、以下のツールでerbに変換して活用してください。
↓↓↓
#### [hamlをerbに変換するツール](https://haml2erb.org/)

## 実装すること

- コメントの投稿・削除を非同期で行う。
- 投稿、削除を実行するとview側でもすぐに反映されるようにする。
- コメント作成者の情報も表示したい。
完成図は以下の通りです。

![Kapture 2023-04-02 at 10.31.19.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3301763/7d0fdeaa-2fad-056b-55a3-25ab5bc0b07a.gif)

## モデルと関連付け
３つのモデルを関連付けて実装します。

#### Userモデル
```
class User < ApplicationRecord
  # Include default devise modules. Others available are:
  # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable
  has_many :articles, dependent: :destroy
  has_many :comments, dependent: :destroy
end
```

###### User認証機能はDeviseのgemを使用して実装しています。
userから見ると記事とコメントは複数あります。
よってhas_manyで関連付けしています。
**※has_manyで関連付ける場合、モデル名も複数形（articles等）にする必要がある点に注意してください。**
dependent: :destoryにすることでuserが削除された際に、記事もコメントも削除されるようにしています。

#### Articleモデル
```
class Article < ApplicationRecord
  belongs_to :user
  has_many :comments, dependent: :destroy
end
```

articleから見ると記事の作成者であるuserは一人です。
よってbelongs_to :user　で紐付けます。

commentは一つに記事に対して複数投稿することができます。
よってhas_many :comments　で紐付けます。
単数の場合は　user　という風に単数系にする点に注意してください(初めはこういった初歩的なミスによるエラーで悩まされました）
ここでもdependent: :destoryとすることで記事が削除された際にコメントの情報も削除してくれるようになります。

#### commentモデル
```
class Comment < ApplicationRecord
    belongs_to :user
    belongs_to :article
end
```

commentから見るとuserもarticleも一つしかありません。
よってどちらもbelongs_toで単数系で紐付けを行います。

**belong_toとは？has_oneと どう違うの？**
子モデル（articleやcomment）が親モデル（userやarticle)に従属することを示しています。
子モデルとなるモデルの中で定義します。
commentから見るとarticleとuserが親モデルになります。
何故なら、コメントを作成するのはuserであり、コメントが投稿されるのがarticleであるからです。
articleから見るとcommentは子モデルになりのですが、userは親モデルになります。
何故なら、articleを作成するのはuserだからです
**※初学者なりの解釈なので間違っている可能性があります。ご了承ください。**
has_oneやhas_manyは親になるモデルで定義されます。
親から見て複数の　１対多の関係であればhas_many
親から見て単数の　１対1の関係であればhas_one
で関連付けを行います。
学習を始めた頃、全て単数の関連付けをどのモデルでもbelongs_toで定義してしまい、上手く動作しない問題が発生したため、参考までに書かせていただきました。


## コントローラーの作成
前置きが長くなりましたが、始めていきましょう！
まずはcommentsのコントローラーを作成します。
```
$ rails g controller comments
```
ターミナルで実行します。

## URLの作成
routes.rb
```
Rails.application.routes.draw do
  devise_for :users
  root to: 'articles#index'

  resources :articles do
    resources :comments, only: [:destroy, :create]
  end

end

```
今回は article/show.html.haml　にてcommentを取得して表示するため
onlyでdestroyとcreateのみ指定しています。
articles do　としてからresourcesでcommentsとすることで記事のIDを含めたURLが作成されます。


## コントローラーの編集

### create
```
class CommentsController < ApplicationController
  before_action :authenticate_user!

  def create
    @article = Article.find(params[:article_id])
    @comment = @article.comments.build(comment_params)
    @comment.user_id = current_user.id

    if @comment.save
      flash.now[:notice] = "コメントの投稿に成功しました。"
    else
      flash.now[:alert] ="コメントの投稿に失敗しました。"
    end
    @comments = @article.comments.order(created_at: :desc)
    respond_to do |format|
      format.js { render 'create' }
    end
  end
  private

  def comment_params
    params.require(:comment).permit(:content)
  end
end
```

解説していきます。
before_action :authenticate_user!とすることでログインしていないとコメントが投稿できないように制約をかけています。

まずコメントを投稿するには、記事のIDが必要になります。
どの記事にコメントを投稿するのかわからなければ投稿できないですよね。
そこで、Article.findでarticle_idを取得します。それを@articleというインスタンス変数に代入しています。
その記事のIDを使用してarticle.comments.buildでコメントを作成します。
@comment.user_id = current_user.idとすることで
コメント作成者のuser_idを現在ログインしているuserのidにすることができます。（これはdeiviseのメソッドです）
そしてif文でsaveされた場合、されなかった場合異なるアラートを出力しています。
@comments = @article.comments.order(created_at: :desc)
で保存されたコメントを再度コメント一覧に代入して新着順で出力するようにしています。
respond_to do |format|
format.js {render 'create'}
とすることでjavascript形式のリクエストが来た場合、create.js.erbを呼び出してレンダリングするようにします。

### destory
```
  def destroy
    @article = Article.find(params[:article_id])
    @comment = @article.comments.find(params[:id])
    @comment.destroy!
    flash.now[:notice] = "コメントを削除しました。"
    @comments = @article.comments
      respond_to do |format|
      format.js { render 'destroy' }
    end
  end
```
createと同様の原理です。

## 非同期処理でコメントを投稿・削除する
では、具体的にどのように非同期でコメントの投稿、削除を行うのか解説します。
- コメントフォームでコメントを投稿or削除
- コントローラーのcreateやdestoryのメソッドが実行され、DBが更新される
- 更新された後、変更があったHTMLの要素を部分的に呼び出して更新する
このような手順で行います。

まず非同期処理で更新をするにはjavascriptの形式で実行する必要があります。
そのための設定が先ほどコントローラーで設定した
format.js{render 'create'}です。
これを書くことによって、先程も言いましたが
commentsディレクトリの
create.js.erbのファイルが呼び出され実行されるようになっています。
なのでこのファイルの中に部分的にHTMLを呼び出して更新する処理をjavascriptで書くことで、コメント投稿時に実行されるようになります（多分そんな感じだと思っています・・）

#### viewの作成
**articles/show.html.haml**
```
    %h3 コメント一覧
  .article_show
    .card_content
      = render 'comments/index', comment: @comment, article: @article
      - if user_signed_in?
        = render 'comments/form', comment: @comment, article: @article
```
コメント一覧のHTMLとformのHTMLは部分テンプレートにしています。
コメントのformに関しては　user_signed_in?のメソッドでログインしていなければ表示されないようにしています。

コメント一覧を部分テンプレートにしているのは、javascriptの処理の中で、このコメント一覧のHTMLを呼び出して非同期処理で更新するためです。

**部分テンプレート部分⇨comments/_index.html.haml**
```
#comments
  - @comments.each do |comment|
    .user_icon
      =image_tag comment.user.avatar_image
      .user_name
        = "#{comment.user.display_name}さん"
        %p #{comment.created_at.strftime('%Y年%m月%d日')}
      .comment
        %p= comment.content
      - if comment.user_id == current_user.id
        .comment_btn
          = link_to article_comment_path(@article.id, comment.id), method: :delete, remote:true, data: { confirm: '本当に削除しますか？' , commentId: comment.id } do
            = image_tag 'delete.png'
```

**formの部分テンプレート**
```
%ul
  - article.errors.full_messages.each do |message|
    %li= message
= form_with(model: [article, comment], url: article_comments_path(article), html: { id: 'comment-form' }) do |f|
  = f.text_area :content, placeholder: "コメントを入力してください"
  = f.submit "コメントを追加", class: "btn"
```


##### jsファイルの作成
次にviewでcommentsというフォルダを作成します。
これはcomments_controllerなのでcommentsという名前にしています。
commentsファルダの中に create.js.erbというファイルを作成します。
そこに以下のように処理を書きます。
```
//create.js.erb
const commentsArea = document.getElementById("comments");
commentsArea.innerHTML = "<%= j(render 'comments/index', { comments: @comments }) %>";
document.querySelector("textarea").value = "";
```
ここではdocument.getElementByIdでcommentsのIDを持つ要素を指定しています。
index.html.hamlの一番上の#commentsのことですね。それをcommentsAreaと定義します。
そして、指定した要素にinnerHTMLを使用し、#commentsの要素のHTMLを先程作った。comments/indexの部分テンプレートに置き換えられるようにします。このrenderの前のjメソッドは、そのままjavascriptの形式でHTMLを当てこむと特殊文字などのせいで表示が崩れるようです。なのでこちらのメソッドを使用することでエラーの原因になる特殊文字などをエスケープして正しく表示できるようにしてくれる働きがあるようです（ChatGPTさんに聞きました笑）
そしてquerySelectorでtextareaを指定し、コメント入力欄を空にしています。

destroyも同様に作成します。
```
//destroy.js.erb
const commentsArea = document.getElementById("comments");
commentsArea.innerHTML = "<%= j(render 'comments/index', { comments: @comments }) %>";
document.querySelector("textarea").value = "";
```

#### いざ実行!!
さぁ、これで動くはず。と思い実行してみると　あれ？？？
コメントが正常に投稿され、非同期で更新される時もあれば、されない時もある。
でもリロードしたら、更新されている。
何故？ログを見てみるとこのようなエラーメッセージが・・・
```
Uncaught SyntaxError: Identifier 'commentsArea' has already been declared　　　　　　　　　　　　　
```
翻訳してみるとcommentsAreaという変数は既に宣言されていますよ。
という意味だった。
ん？そういえばこの前読んだjavascriptの書籍でconstは再宣言できない的なことを書いてあった気がする。
そう思いletにしてみる。ただ結果は変わらない・・・
改めて読み直すと、どちらも再宣言はできなかった。letは再代入はできるだけだった。

具体的な解決策がわからずChatGPTに聞いてみることに・・・

すると変数ではなく、create.js.erbの処理を一つの関数にまとめることで再宣言ができずに、コメント一覧が更新されない問題が解消されるとのことでした。
**変数の再宣言に関する問題は、変数のスコープが関数内に制限されるため、関数を使用することで解決できる。関数内で定義された変数は、関数が呼び出されるたびに新しく作成されるため、同じ変数を再宣言するエラーが発生しない。**
とのこと。
以下のように関数にするといいと答えが返ってきました（流石ChatGPT・・)
```
// common.js
function updateCommentsArea(comments) {
  // 関数内で変数を定義
  const commentsArea = document.getElementById("comments");
  commentsArea.innerHTML = comments;
  document.querySelector("textarea").value = "";
}
```
引数をcommetsとして処理を実行する時にidと部分テンプレートのHTMLを渡せるようにすると良いみたいですね。

```
//create.js.erb //destroy.js.erb
updateCommentsArea(document.getElementById("comments"), '<%= j(render("comments/index", { comments: @comments })) %>');
```
このようにしてみます。
すると今度はupdateCommentsAreaという関数が定義されていません。というエラーが。
何故？?色々試した結果application.jsで書くと定義できていることが確認できた。
このcreate.js.erbでは何故かできない。

原因がわからず、結局どこでも関数が使えるようにグローバルスコープにインポートすれば半ば無理やり使えそうだったので、やってみました。

```
/packs/common.js
window.updateCommentsArea = function(commentsArea, comments) {
    commentsArea.innerHTML = comments;
    document.querySelector("textarea").value = "";
  }; 
```
そしてapplication.jsで
```
/packs/applicaton.js
import 'packs/common';
```
読み込む。
これで定義した関数を呼び出し、非同期でコメントを投稿、削除を実装することができました！！！
長かった・・・


windowをつけることでグローバルオブジェクトとなり、どこからでも呼び出せるようになるみたいです。
ただ、グローバル化はリスクもあるようで、あまり使用しない方が良いとも聞くので、もっと良い方法があれば知りたいです。
そして、理解も浅いため、間違っている点、望ましくない書き方など色々とあると思います。ご指摘あれば、すみませんがよろしくお願いします

## 参考文献・記事
https://qiita.com/bty__/items/c7d6213139a48833e21a

https://qiita.com/taddy_korokoro/items/4d75e33ceb5c7a5124a8

**「スラスラわかるJavaScript」 桜庭 洋之 (著), 望月 幸太郎 (著)**