LoginSignup
0
0

More than 1 year has passed since last update.

【Rails6】Turbolinksを同居させたままActionCableで非同期通信コメント機能を実装したい!

Last updated at Posted at 2022-02-03

なに、同じコメントが2つ、3つ追加されただと…?

というわけでRailsとJavaScriptの初学者がぶつかったTurbolinksという大きな壁について、今回はAjax通信のコメント欄の実装においてどのような課題解決をしたかアウトプットしようと思います。

環境

  • Ruby : ruby 2.6.5p114 (2019-10-01 revision 67812) [x86_64-darwin20]
  • Rails : 6.0.0
  • Turbolinks5
  • Action Cable 6.0.4.4

前提

Turbolinksとはなんぞやという方は以下の記事を参考にしていただきたい。

何をしようとしているか

現在、掲示板形式の相談サイトを作成しようとしている
記事に対するコメント機能を設け、Ajax通信(Javascript)を用いてリアルタイムに反映させようとしている
参考記事にもある通り、Turbolinksはページのレンダリングにおいて高い優位性を発揮するため、なるべくその機能を殺さずに機能実装したいと考えている

問題

一度のPOSTで全く同じコメント(html)が複数個挿入される

  • 特定のページ内でのみjsファイルを読み込むように設定している
  • ページ遷移を繰り返したのちにコメント投稿を行うとその遷移回数の分だけ同じコメントが挿入されてしまう
  • リロード後は問題なく表示されている

## 解決方法

  1. 挿入するHTML要素にidを付与する(ページ内の他の要素と被らないように注意)
  2. jsファイル内で、既にそのidをもつHTML要素がページ内(DOM)にあるかどうかをifで判定
  3. もし既に存在する場合はそれ以上挿入しないように条件式を設定する
  4. 存在しない場合は「唯一」の要素としてHTML要素を挿入する

※ポイントは挿入する各HTML要素に個別の番号をつけてあげること

アプリケーションの設定

step1 必要なchannelを作成

terminal
rails g channel comment

これにより、以下のファイルを作成
今回はActionCableを用いてコントローラー→channel経由でjavaScriptを使用します

terminal
Running via Spring preloader in process XXXXX
      invoke  test_unit
      create  test/channels/comment_channel_test.rb
      create  app/channels/comment_channel.rb              #今回使用
   identical  app/javascript/channels/index.js
   identical  app/javascript/channels/consumer.js
      create  app/javascript/channels/comment_channel.js   #今回使用

step2 channelファイルの設定

JavaScriptが使うchannelを設定。ここでは、consultationを指定することにより、投稿時に他の相談のコメント欄にまで反映されるのを防いでいます。

app/channels/comment_channel.rb
class CommentChannel < ApplicationCable::Channel
  def subscribed
    @consultation = Consultation.find(params[:consultation_id])
    stream_for @consultation
  end

  def unsubscribed
    # Any cleanup needed when channel is unsubscribed
  end
end

step3 コントローラー側のアクションを作成

ここでは 相談(consultation)が親モデル、コメント(comment)が子モデルとなっています
ユーザー(user)はどちらにも紐づいていますね

app/controllers/comments_controller.rb
class CommentsController < ApplicationController
  before_action :authenticate_user!

  def create
    @consultation = Consultation.find(params[:consultation_id])
    @comments = @consultation.comments
    @comment = Comment.new(comment_params)
    if @comment.save
      CommentChannel.broadcast_to @consultation, {
        comment: @comment, user: @comment.user, comments: @comments
      }     #comment_channel.jsファイルに渡す「data」を作成
    end
  end

  private

  def comment_params
    params.require(:comment).permit(:c_text).merge(user_id: current_user.id, consultation_id: @consultation.id)
  end
end

step4 viewファイルの設定

以下のように、コメント投稿フォームとコメント一覧表示部分を作成
(form_withに渡すまっさらなcommentインスタンスはconsultations_controller#showに記載)

app/views/consultations/show.html.erb
<div class="content-box">
        
  <%= form_with model: [@consultation, @comment], class:"comment-form-wrapper", id:"comment_form_wrapper" do |f| %>
    <p><strong>※コメントは取り消せません<br>※投稿者は自身のコメントに責任を持ちます</strong></p>
    <% if user_signed_in? %>
      <%= f.text_area :c_text, class:"input-form", id:"c_text", placeholder:"コメントを入力(150文字まで)", maxlength:"150" %>
        <div class="button-box">
          <%= f.submit '送信', class:"submit-btn" %>
        </div>
    <% else %>
      <p><strong>※コメントをするにはログインが必要です</strong></p>
    <% end %>
  <% end %>
        
  <div class="comments-wrapper", id="comments">
    <p id="comment_num"><%= "#{@comments.size}件のコメント" %></p>
    <% @comments.each do |comment| %>
      <div class="comment-box">
        <p class="comment-text"><%= comment.c_text %></p>
        <p class="comment-username">by: <%= link_to comment.user.nickname, "#" %></p>
        <p class="comment-date">__<%= comment.created_at %></p>
      </div>
    <% end %>
  </div>

</div>

step5 jsファイルの設定

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

document.addEventListener('turbolinks:load', function() {  //・・・①

  if (location.pathname.match(/\/consultations/) && location.pathname.match(/\d$/)){  //・・・②
 
    console.log('読み込み完了') //いつ読まれたかわかるように記載
  

    consumer.subscriptions.create({      //・・・③
      channel: "CommentChannel",
      consultation_id: location.pathname.match(/\d+/)[0]
    }, {

      connected() {
        // Called when the subscription is ready for use on the server
      },

      disconnected() {
        // Called when the subscription has been terminated by the server
      },
      received(data) {       //・・・④
        const html = `
          <div class="cons-comment-box>
            <p class="cons-comment-text">${data.comment.c_text}</p>
            <p class="cons-comment-username">by: <a href="#">${data.user.nickname}</a></p>
            <p class="cons-comment-date">__New!</p>
          </div>`            //・・・⑤
        const commentNum = document.getElementById('comment_num');
        commentNum.innerHTML = `${data.comments.length}件のコメント`;   //・・・⑥
        commentNum.insertAdjacentHTML('afterend', html)   ;          //・・・⑦
        const commentForm = document.getElementById('comment_form_wrapper')
        commentForm.reset();      //・・・⑧
      }

    });

  };

});

それぞれ以下のような仕様


turbolinkが適用されていてもjsファイルが読み込まれるように、ページをロードした段階で{}内の処理がおこなれるよう記述


特定のページ(ここでは相談(consultation)の詳細表示ページ)だけが読み込まれるようにifで分岐。条件式はpathが正規表現に引っ掛かるかどうかで判定


現在いるページ(個別の相談詳細表示ページ)にのみ、描写が適用されるように指定


コントローラーから渡されたdataを引数に指定する
するとブロック内でconsultation, comment, commentsが使えるようになるので組み込み


JavaScriptに慣れていないとバッククォート記法とか気をつけないとすぐに"#{}"で囲もうとする…
ちなみにjsでは当然link_toは使えないので素直にaタグで記述


コントローラー側のcreateアクションで既に投稿されたコメントはテーブルに保存されているため、data.commentslengthをくっつければ要素(相談に紐づいているコメント)の数を表示できる
ここでは、innerHTMLを利用して元あった要素のvalueをそっくり交換してしまった


そしてここで先ほど定義したhtmlcommentNumの後ろに突っ込む


最後に、コメントフォームのvalueをreset()で空っぽにすることで同じコメントを複数回投稿できないようにした


ここで本題

さて、これでうまくいくだろうと思ったら大間違い。
確かに最初に相談詳細ページにたどり着いた段階では問題なく投稿でき、問題なくHTML要素の挿入ができます。
しかし、ページ遷移をして同ページへ戻ってきた場合はどうなるでしょうか。

コンソールを確認すると…

読み込み完了が蓄積されていっている…?!
この状態で投稿を行うとどうなるかというと、まあ冒頭の説明の通り、同じ投稿が読み込み回数の分だけ挿入されてしまうわけですね。

原因については参考文献にある通り
Turbolinks 公式ドキュメント(冪等性の確保)

Often you’ll want to perform client-side transformations to HTML received from the server. For example, you might want to use the browser’s knowledge of the user’s current time zone to group a collection of elements by date.

Suppose you have annotated a set of elements with data-timestamp attributes indicating the elements’ creation times in UTC. You have a JavaScript function that queries the document for all such elements, converts the timestamps to local time, and inserts date headers before each element that occurs on a new day.

Consider what happens if you’ve configured this function to run on turbolinks:load. When you navigate to the page, your function inserts date headers. Navigate away, and Turbolinks saves a copy of the transformed page to its cache. Now press the Back button—Turbolinks restores the page, fires turbolinks:load again, and your function inserts a second set of date headers.

To avoid this problem, make your transformation function idempotent. An idempotent transformation is safe to apply multiple times without changing the result beyond its initial application.

One technique for making a transformation idempotent is to keep track of whether you’ve already performed it by setting a data attribute on each processed element. When Turbolinks restores your page from cache, these attributes will still be present. Detect these attributes in your transformation function to determine which elements have already been processed.

A more robust technique is simply to detect the transformation itself. In the date grouping example above, that means checking for the presence of a date divider before inserting a new one. This approach gracefully handles newly inserted elements that weren’t processed by the original transformation.


Javascriptでの描画の注意点

公式ドキュメントのMaking Transformations Idempotent(冪等性の確保)の章で書かれている内容。

turbolinks-classic では on page:load 以下に処理を記入するのが一般的だった。page:loadは現在のApplication Visitが行われる時に発火するイベント。

Turbolinks 5以降では on turbolinks:load 以下に処理を記入する。この turbolinks:load は Application VisitとRestoration Visits 両方で発火する。

そのため、ページを遷移し戻るボタンで戻ってきた時に再度イベントが発火してしまう。この時にJavascriptでDOMを描画していたりすると、2重に実行され意図しない挙動になることがある。turbolinks:load以降の処理は冪等性を考慮して書く必要がある。
Making Transformations Idempotent(冪等性の確保)では、data attributeを使って実行済みか判断するテクニックや、生成されるDOMを捜査し存在するかどうかの条件分岐を入れるテクニックが紹介されている。後者がよりシンプルで堅牢であると紹介されている。


## 解決策を考える

ここでの単純な解決方法としては、個別のページごとにがっつりturbolinksをオフにすることが考えられます。
しかしこれでは結局turbolinksの旨味を捨てて実装をすることになるため、なんとなく負けた気がしますね…

3,部分的にオフにする(ページ毎)

そこで、今回は公式ドキュメントにて紹介されている**「生成されるDOMを捜査し存在するかどうかの条件分岐を入れるテクニック」**を使ってこの問題を回避していきたいと思います。

生成されるDOMを捜査し、存在するかどうか確かめる?
…なんのこっちゃ。

簡単です、ここでは単純に挿入したHTML要素と読み換えましょう。
では、どうやって挿入したHTML要素が存在すると確かめるか…?

私は一番手っ取り早い「idで検出する」という方法を思いつきました。
早速適当なidをHTML要素に追加してみましょう。

今回はコントローラーから送られてきたdataを再利用します。

comment_channel.js(一部抜粋)

// (省略)

received(data) {
  const html = `
    <div class="comment-box" id="added_box_`${data.comments.length}`">
      <p class="comment-text">${data.comment.c_text}</p>
      <p class="comment-username">by: <a href="#">${data.user.nickname}</a></p>
      <p class="cons-comment-date">__New!</p>
    </div>`   // ・・・①
    const commentNum = document.getElementById('comment_num');
    commentNum.innerHTML = `${data.comments.length}件のコメント`;
    const addedBox = document.getElementById('added_box_`${data.comments.length}`); // ・・・②
    if (addedBox === null) {
      commentNum.insertAdjacentHTML('afterend', html);
    }; // ・・・③
    const commentForm = document.getElementById('comment_form_wrapper');
    commentForm.reset();
}

// (省略)

簡単に補足しますね。
① 
<div class="comment-box">に対して id="added_box_${data.comments.length}"を付与。こうすることで、元からある同名のclassとは差別化されたdiv要素が生成、挿入される
加えて、さらに別のコメントを投稿した場合もidに付与される番号が変わるため、条件式として「同じもの」としてはみなされなくなります。

例えば、もとからあるコメント数が5個だとすれば、最初に投稿したコメントはid="added_box_6"、その次に投稿したコメントはid="added_box_7"となるわけです。


先ほど定義したhtmlを変数に代入


もしまだそのidを持つHTML要素がなければそのまま挿入。
既に存在した場合は何もしない

こうすることで、二回目以降の処理はスルーされ、同じ投稿が複数表示されるという事態を防ぐことができるわけですね!

結論

生成するDOM(HTML要素)に唯一性を持たせることができれば同じ要素が複数挿入される心配はなくなる!
冪等(べきとう)性の確保には唯一性で対応だ!

TurbolinksをオフにせずともAjax通信は実装できる!
JavaScriptを扱った経験が少なく苦手意識のあった私ですが、今回の実装で少しだけ仲良くなれたような気がします。

もちろん、まだまだ見識が浅いので何かあればご指摘いただけると幸いです。

0
0
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
0
0