uu59のメモ

調べたことのメモなど。旧ブログはこちら

⚡About me

Jul 01, 2014

RailsとJS(vue.js)の連携

前書き

Railsを使いつつJSもそこそこ書きたい、という条件であればまず前提としてjQuery脳を捨てましょう。jQueryスタイルで考えるかぎり何をどうやっても破綻するのでJSを諦めるか保守性を諦めるかして覚悟を決めましょう。

捨てるのは「jQuery」ではなく「jQuery脳」です。jQueryでグローバルな領域に進出してメソッドチェインで狼藉を働いたり、いま現在目の前にあるHTMLだけを考えてDOM操作をしたり、$.onと$.triggerを使ったクロージャ内部へのGOTOなどを記憶から消しましょう。

可能な限りスコープを小さく保つのはプログラミングの基本原則といえます。その原則を思い出し、JSを軽く扱わず、一般的なプログラミングと同様に閉じられた関心事にのみ注力するようにしましょう。

RailsとJSと役割分担

Railsもviewとしてテンプレートエンジンの処理を持っていますが、Railsが関与できるのは文字列としてのHTMLをどのように生成するか、といったところまでです。クライアントサイドJSから見るとそのHTMLはただの文字列ではなく、親子構造を持っていたりイベントのハンドリングを担ったりするDOMオブジェクトとなります。JSONも同様にRailsにとってはただの文字列でしかありませんが、JSにとってはオブジェクト(ハッシュ・配列・etc)です。

GitHubのコメント機能での例

コメントの編集ボタンをクリックするとその場で本文がtextareaになり、送信ボタン押下でAjaxでPUT(PATCH)して更新する、という機能について考えます。

コメント更新時の処理は大まかにいって3通りのやりかたがあります。

  1. 送信をAjaxで行い、RailsはHTMLの断片を返し、クライアントサイドでは受け取ったHTML断片をコメント部分にそのまま(innerHTMLやjQuery.html()などで)適用する
  2. 送信をAjaxで行い、RailsはHTMLの断片をコメント部分にinnerHTMLするようなJSのコードを返し、クライアントサイドではレスポンスをevalする(partialに<script>...</script>がくっついているイメージ)
  3. 送信をAjaxで行い、RailsはJSONを返し、クライアントサイドで受け取ったJSONをもとにレンダリングする

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)

疲れたので今日はここまでにします。

Tweet

似てるかもしれない記事

  • Pryのプラグイン情報 2012-10-13
  • ザ・インタビューズで勝手にインタビューするワームの中身
  • livedoorクリップをGmailに持っていって検索を便利にする
  • config.ru内でいろいろしてるrackアプリをrspecやcapybaraでうまくテストする
  • Apache SolrをUbuntu 12.04でカジュアルに使う
Home
最近の過去記事
  • 2014-07-01RailsとJS(vue.js)の連携
  • 2014-05-29XPのサポートも切れたのでSSL切ってTLSオンリーでhttpsにした
  • 2014-05-21雑に作ったウェブアプリをDockerとngrokで雑に扱う
  • 2014-05-14pipで同じパッケージの別バージョンをいっぱい持って使い分ける
  • 2014-05-05AWS EC2の料金をグラフで見るやつを作った
  • 2014-04-12OpenSSH 6.6p1の各cipherのスループットを計測した
  • 2014-04-01環境汚したくないのでブログビルドマシンをDockerで作った
  • 2014-03-31UbuntuからArchに移行した
  • 2014-03-16spamhaus覚え書き
  • 2014-03-01semverによって演繹される世界とnpmのバージョン指定がちょっと変わる話
Categories
  • ruby(31)
  • javascript(20)
  • misc(14)
  • vim(8)
  • security(7)
  • git(6)
  • sinatra(5)
  • node.js(5)
  • php(5)
  • chrome(3)
Copyright © uu59.org All Rights Reserved.
Powered by Jekyll, Designed by FasionHasion