Railsを使いつつJSもそこそこ書きたい、という条件であればまず前提としてjQuery脳を捨てましょう。jQueryスタイルで考えるかぎり何をどうやっても破綻するのでJSを諦めるか保守性を諦めるかして覚悟を決めましょう。
捨てるのは「jQuery」ではなく「jQuery脳」です。jQueryでグローバルな領域に進出してメソッドチェインで狼藉を働いたり、いま現在目の前にあるHTMLだけを考えてDOM操作をしたり、$.on
と$.trigger
を使ったクロージャ内部へのGOTOなどを記憶から消しましょう。
可能な限りスコープを小さく保つのはプログラミングの基本原則といえます。その原則を思い出し、JSを軽く扱わず、一般的なプログラミングと同様に閉じられた関心事にのみ注力するようにしましょう。
Railsもviewとしてテンプレートエンジンの処理を持っていますが、Railsが関与できるのは文字列としてのHTMLをどのように生成するか、といったところまでです。クライアントサイドJSから見るとそのHTMLはただの文字列ではなく、親子構造を持っていたりイベントのハンドリングを担ったりするDOMオブジェクトとなります。JSONも同様にRailsにとってはただの文字列でしかありませんが、JSにとってはオブジェクト(ハッシュ・配列・etc)です。
コメントの編集ボタンをクリックするとその場で本文がtextareaになり、送信ボタン押下でAjaxでPUT(PATCH)して更新する、という機能について考えます。
コメント更新時の処理は大まかにいって3通りのやりかたがあります。
innerHTML
やjQuery.html()
などで)適用するinnerHTML
するようなJSのコードを返し、クライアントサイドではレスポンスをevalする(partialに<script>...</script>
がくっついているイメージ)1のメリットはRailsの資産を流用できることでしょう。Rails側は更新されたコメントのmodelをrender :partial
して返せばいいだけで、JS側もレスポンスをinnerHTML
するだけなので実装の難易度も低いです。保守性も悪くありません。
2は最悪です。悪意があってそんな実装にしてるのかと疑われても仕方ないレベルで論外です。何の利点も見いだせません。
3は最近のJSフレームワークでよく要求される形です。Railsの責務を縮小して、データ処理してJSONくれればそれでいい、レンダリングはJSで全部やるから、というようなJSに重きを置いたスタイルと言えます。
「コメントを画面遷移なしで編集できる」というだけであれば1がもっとも妥当な選択に思えます。しかしここにプレビュー機能や削除を追加するとなるとどうでしょうか。
1のやり方で通すとすれば、まずプレビューは/hoge/preview
のようなURLへAjaxし、Railsが受け取ったデータを元にHTML断片を返し、それを受け取ったJSが所定の場所へinnerHTMLする、という形でなんとかなります。しかし削除に関しては、RailsがHTML断片を返したところでJSとしてはコメントを削除したいわけなので助けにはなりません。削除に成功したコメントをJS側でDOMから削除する必要があります。その結果、JSの比重が大きくなり、最初にあった「実装が単純」というメリットは薄れます。またコメントの更新は受け取ったHTMLをinnerHTMLするだけ、コメントの削除はJSで書く、というようにちぐはぐな実装にもなってしまいます。「Railsでコメントを削除したあと新しいコメント一覧のHTMLを返し、JSでコメント一覧のHTMLを丸ごと置き換える」という手もありますが、非効率ですし、他についているコメントが多い場合はUIスレッドが固まる可能性もあるため筋が悪いです。
3のやり方では、元々JSが主導権を握っているのでそこに機能を追加していくことになります。プレビューは1と同様に特定のURLへAjaxでリクエストし、Railsはその結果をJSONとして返し、クライアントサイドではそのJSONを元に(他のコメントと同様に)レンダリングすることになります。削除についてはAjaxでDELETEリクエストし、削除に成功すれば持っていたコメントデータを削除して再レンダリングするだけです。フレームワークにもよりますが、データバインディングがあるならcomments.splice(deletedIndex, 1)
のような単純な処理で事足りるでしょう。
ここまでで見てきたように、ごく単純な機能であれば1のスタイルで充分です。しかし込み入った機能が必要であれば、初期コストは掛かるものの3のほうが破綻リスクは小さいといえます。以降はvue.jsでの例を使って、どのようにコメント機能を構成するか書いていきます。
※軽く言いましたが、3に掛かる初期コストはそこそこ大きいです。レンダリングをJSでやるからにはJSで扱えるテンプレートを用意する必要があり、下手すると初回アクセス時にRailsがrenderするためのviewと、Ajax後にJSがrenderするためのテンプレートの2つが混在することになります。初回アクセス時にもJSでのみrenderするようにしてしまえば二重管理からは開放されますが、要件によっては受け入れられないこともあるでしょう。以下ではRailsはJSONとJS用のテンプレートのみを返し、HTMLの生成はJSでのみ行うという仮定で進めます。
vue.jsはMVVMを謳っていますが、ModelといってもBackbone.Model/Collectionのような重厚なものではなく、ほとんど素のJavaScript Objectです。またViewもMustacheテンプレートに加え、コンポーネントのrootとなるDOM要素のみを扱います。肝となるのはViewModel(VM)で、ほぼVMとMustacheテンプレートを書いていくことになります。
というわけで抽象的に書くとよくわからなるので具体的に書いていきます。具体的ですが唯一無二の正解というわけではなく、最近はこうしている人が居るという事例として見てください。
まずコメント一覧のJSONはこんな感じにします。GitHubを完全再現するとめんどいので簡略化します。
json.comments comments do |comment|
json.body comment.body
json.createdAt comment.created_at
json.authorName comment.author.screen_name
if comment.persisted?
json.resourceUrl article_comment_path(comment.article, comment)
end
end
RailsサイドでMustacheテンプレートを生成して初回アクセス時に返すようにします。これは例えばapp/views/shared/vue/_comment.html.erb
のようなものになり、app/views/repo/issues/show.html.haml
などからrender partial: "shared/vue/comment"
のようにして埋め込むことにします(hamlだと読みづらいし書きにくいのでerbにしてる)。
vue用のMustacheテンプレートは以下のような感じにします(同じく簡略化してます)。
- # show.html.haml
#comments
= render partial: "shared/vue/comment", locals: { comments_url: comments_path(@issue), preview_url: comment_preview_path(@issue), current_user_name: current_user.try(:screen_name) }
# vue/_comment.html.erb
# jekyllが「{」が続いてるとなんか表示しなくなるので「{ {」とスペースいれてる
<div id="vue-comment"
previewUrl="<%= preview_url %>"
commentsUrl="<%= comment_url %>"
currentUserName="<%= current_user_name %>"
>
<div v-repeat="comments">
<div class="comment-header">
<span v-if="isMyComment && !isPreview" v-on="click: edit(this)">Edit</span>
<span v-if="isMyComment && !isPreview" v-on="click: delete(this)">Delete</span>
<span v-if="isPreview">(プレビュー)</span>
<span class="timestamp">{ { timestamp }}</span>
</div>
<div class="comment-body">{ { { body }}}</div>
<p class="author">
Commented by { { author }}
</p>
</div>
</div>
次にJS側ですが、これはapp/assets/vue/comment.js.coffee
として置いておき、application.jsからはrequireするだけです。実際はv-repeatがscopeを作るのでもう少しやることはあるんですが、面倒なのでこんな感じということにします。
$ ->
new Vue
el: "#vue-comment"
paramAttributes: ["previewUrl", "commentsUrl", "currentUserName"]
data:
comments: []
created: ->
@fetchComments()
compound:
timestamp: ->
date = (new Date(@createdAt))
# なんかn秒前とかm日前とかにする
isPreview: ->
!!@resourceUrl == false
isMyComment: ->
@authorName == @currentUserName
methods:
fetchComments: ->
jQuery.getJSON @commentsUrl, (comments) =>
@comments = comments
deleteComment: (comment)
# 消す
edit: (comment) ->
# textarea化
delete: (comment) ->
@deleteComment(comment)
疲れたので今日はここまでにします。