なに、同じコメントが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ファイルを読み込むように設定している
- ページ遷移を繰り返したのちにコメント投稿を行うとその遷移回数の分だけ同じコメントが挿入されてしまう
- リロード後は問題なく表示されている
## 解決方法
- 挿入するHTML要素にidを付与する(ページ内の他の要素と被らないように注意)
- jsファイル内で、既にそのidをもつHTML要素がページ内(DOM)にあるかどうかをifで判定
- もし既に存在する場合はそれ以上挿入しないように条件式を設定する
- 存在しない場合は「唯一」の要素としてHTML要素を挿入する
※ポイントは挿入する各HTML要素に個別の番号をつけてあげること
アプリケーションの設定
step1 必要なchannelを作成
rails g channel comment
これにより、以下のファイルを作成
今回はActionCableを用いてコントローラー→channel経由でjavaScriptを使用します
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を指定することにより、投稿時に他の相談のコメント欄にまで反映されるのを防いでいます。
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)はどちらにも紐づいていますね
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に記載)
<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ファイルの設定
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.comments
にlength
をくっつければ要素(相談に紐づいているコメント)の数を表示できる
ここでは、innerHTML
を利用して元あった要素のvalueをそっくり交換してしまった
⑦
そしてここで先ほど定義したhtml
をcommentNum
の後ろに突っ込む
⑧
最後に、コメントフォームの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.
公式ドキュメントの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の旨味を捨てて実装をすることになるため、なんとなく負けた気がしますね…
そこで、今回は公式ドキュメントにて紹介されている**「生成されるDOMを捜査し存在するかどうかの条件分岐を入れるテクニック」**を使ってこの問題を回避していきたいと思います。
生成されるDOMを捜査し、存在するかどうか確かめる?
…なんのこっちゃ。
簡単です、ここでは単純に挿入したHTML要素と読み換えましょう。
では、どうやって挿入したHTML要素が存在すると確かめるか…?
私は一番手っ取り早い「idで検出する」という方法を思いつきました。
早速適当なidをHTML要素に追加してみましょう。
今回はコントローラーから送られてきたdata
を再利用します。
// (省略)
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を扱った経験が少なく苦手意識のあった私ですが、今回の実装で少しだけ仲良くなれたような気がします。
もちろん、まだまだ見識が浅いので何かあればご指摘いただけると幸いです。