#295 Sharing Mustache Templates pro
- Download:
- source codeProject Files in Zip (93.7 KB)
- mp4Full Size H.264 Video (48.4 MB)
- m4vSmaller H.264 Video (21.2 MB)
- webmFull Size VP8 Video (23.3 MB)
- ogvFull Size Theora Video (55.6 MB)
Mustacheはとてもシンプルなテンプレート用言語で、RubyとJavaScriptを含む多くのプログラミング言語でサポートされています。複数の言語でテンプレートを共有しなければいけない場合、Mustacheはすばらしいソリューションです。デモサイトにはいろいろ試せるサンプルのテンプレートがあります。以下に例を示します。
<h1>{{header}}</h1> {{#bug}} {{/bug}} {{#items}} {{#first}} <li><strong>{{name}}</strong></li> {{/first}} {{#link}} <li><a href="{{url}}">{{name}}</a></li> {{/link}} {{/items}} {{#empty}} <p>The list is empty.</p> {{/empty}}
Mustacheテンプレートは、二重の波カッコで属性を定義します。波カッコとハッシュ記号(#)を使ってブロックを定義して、複数の項目で繰り返し処理をおこなったりif条件として使用します。上のテンプレートに下のJSONデータを渡します。
{ "header": "Colors", "items": [ {"name": "red", "first": true, "url": "#Red"}, {"name": "green", "link": true, "url": "#Green"}, {"name": "blue", "link": true, "url": "#Blue"} ], "empty": false }
すると、次のような出力を生成します。
<h1>Colors</h1> <li><strong>red</strong></li> <li><a href="#Green">green</a></li> <li><a href="#Blue">blue</a></li>
ではRailsアプリケーションにMustacheを追加する方法を見ていきましょう。
Mustacheを使ってJavaScriptでリストに項目を追加する
今回使用するRailsアプリケーションには、商品の一覧を表示するページがあり、現状はデータベースにあるうちの最初の10件だけを表示しています。これを改良して、ユーザが画面を下にスクロールするのに従ってさらに商品情報を読み込んで、際限なくスクロールしているような効果を得られるようにします。
このページのテンプレートは単純です。商品のid
付きのdiv
があり、この中で商品をループして一つずつ表示します。
<h1>Products</h1> <div id="products"> <% @products.each do |product| %> <div class="product"> <h2><%= link_to product.name, product %></h2> <div class="details"> <%= number_to_currency(product.price) %> <% if product.released_at %> | Released <%= product.released_at.strftime("%B %e, %Y") %> <% end %> </div> </div> <% end %> </div>
ユーザが下方向にスクロールしてページの終わりに近づいたことを検知したら、div
内にさらに商品を追加します。products.js.coffee
の中にこの処理をおこなうコードを記述します。このコードをまずは以下のように書いてみました。
jQuery -> if $('#products').length new ProductsPager() class ProductsPager constructor: -> $(window).scroll(@check) check: => if @nearBottom() $(window).unbind('scroll', @check) alert 'near bottom' nearBottom: => $(window).scrollTop() > $(document).height() - $(window).height() - 50
このコードではまずDOMがロードされてproducts
のdivがページに存在することをチェックします。スクロールの処理と商品の読み込みのロジックは、これが完成する頃にはかなり複雑になるため、ProductsPager
クラスとして別に定義することにします。このクラスのコンストラクタに、ページがスクロールされるごとにcheck
関数を起動するイベントハンドラを追加します。
checkを定義するときに、細い矢印(->
)ではなく太い矢印(=>
)を使用します。これによってコンテキストが常に同じであることが保証されます。 つまり、scroll
イベントがバインドしたものではなく、常にProductsPager
インスタンスを参照します。
check
関数内で、ユーザがページの一番下近くにスクロールしたかどうかをチェックします。一番下に近づいたら、scroll
イベントから関数のバインドを解き、それ以上起動され続けないようにします。ここで次ページ分の商品を取得してページに追加しますが、今はとりあえずアラートを表示するだけにします。ユーザがページの一番下近くにスクロールしたことをnearBottom
関数でチェックするのですが、この関数はウィンドウのスクロールバーがページの最下部から50ピクセル以内かどうかをチェックします。
jQuery -> if $('#products').length new ProductsPager() class ProductsPager constructor: -> $(window).scroll(@check) check: => if @nearBottom() $(window).unbind('scroll', @check) alert 'near bottom' nearBottom: => $(window).scrollTop() > $(document).height() - $(window).height() - 50
ここでページをリロードしてページを下の方にスクロールするとアラートが表示されます。しかし「dismiss」をクリックしたあとは、イベントのバインドが解かれるため再度アラートが表示されることはありません。
サーバからさらに商品を取得する
コードが機能することがわかったので、alert
の部分をRailsアプリケーションからさらに商品を取得してページに表示するコードに置き換えます。通常はjQueryのgetScript
関数を使って、RailsアプリケーションにJavaScriptテンプレートを作ってそこですべてを処理します。しかし、場合によってはサーバから取得したJSONデータを使って、すべてをクライアント側で処理したいときもあります。JSONはMustacheテンプレートで扱うことができるので、ここではそれを使用します。
JSONデータを取得する元のURLが必要ですが、これを直接CoffeeScriptファイルに埋め込むのではなく、products div
のデータ属性に追加します。
<div id="products" data-json-url="<%= products_url(:format => :json) %>">
ここでCoffeeScriptコードのアラートの部分をgetJSON
の呼び出しに置き換えて、そのURLから商品のJSONデータを取得します。getJSON
関数は、データが返されたときに起動するコールバック関数をとるので、新規に作成するrender
という関数を渡します。この関数は今はとりあえず返されたデータをアラート表示するだけです。
jQuery -> if $('#products').length new ProductsPager() class ProductsPager constructor: -> $(window).scroll(@check) check: => if @nearBottom() $(window).unbind('scroll', @check) $.getJSON($('#products').data('json-url'), @render) nearBottom: => $(window).scrollTop() > $(document).height() - $(window).height() - 50 render: (products) => alert products
このJSONリクエストがProductController
のindex
アクションを起動します。このアクションのコードを修正して、JSONリクエストを受け付けて商品情報の配列をJSONとして返すようにします。
def index @products = Product.order("name").limit(10) respond_to do |format| format.html format.json { render json: @products } end end
ページをリロードしてページの最後までスクロールすると、商品のリストがアラートで表示されます。
Mustache.jsで商品を表示する
商品のリストをサーバから取得できるようになりましたが、まだこれをページに表示する必要があり、ここでMustacheが登場します。テンプレートが必要なので、index
ビュー内にすでにあるものをベースにして各商品を表示するテンプレートを作成します。
Mustacheはシンブルな言語でlink_to
などのヘルパーメソッドは提供されないため、これらを標準のリンクに置き換えなくてはいけません。テンプレートをページに表示させたくないので、それをscript
タグでラップしid
を付与してJavaScriptから参照できるようにします。
<script type="text/html" id="product_template"> <div class="product"> <h2><a href="/products/{{id}}">{{name}}</script></a></h2> <div class="details"> {{price}} {{#released_at}} | Released {{released_at}} {{/released_at}} </div> </div> </script>
mustache.jsプロジェクトを利用することでMustacheテンプレートをJavaScriptで表示することができるようになります。このプロジェクトに含まれているJavaScriptファイルをダウンロードしてRailsアプリケーションで利用できます。これは外部のJavaScriptファイルなので、curl
を使ってダウンロードし、新規に作成したvendor/assets/javascriptsディレクトリに置きます。
$ mkdir -p vendor/assets/javascripts noonoo:store eifion$ curl https://raw.github.com/janl/mustache.js/master/mustache.js > vendor/assets/javascripts/mustache.js
最後にapplication.js
を修正してこのファイルをインクルードします。
//= require jquery //= require jquery_ujs //= require mustache //= require_tree .
これで商品を表示するために必要なものがほぼすべて揃いました。あとはrender関数の中のalert
を、商品を表示するコードに置き換えます。これは商品をループしてひとつずつ表示しproducts
divに追加していきます。商品を表示するために、Mustache.to_html
を呼び出して、テンプレートと商品情報を渡します。各商品を表示したらscrollイベントを再度有効化して、次にページの最後にスクロールしたときにさらに商品がリストに追加されるようにします。
render: (products) => for product in products $('#products').append Mustache.to_html($('#product_template').html(), product) $(window).scroll(@check)
ここでページをリロードして下方向にスクロールすると、ページの最後までスクロールすると新しい商品が追加されます。しかし一つ問題があります。下方向にスクロールするたびに同じ商品がロードされるので、次にこれを修正します。
次のページの商品を取得する
次にどのページの商品を取得すればいいかがわかるように、products.js.coffeeファイルで現在のページ数を記録し、productsのJSONデータを取得するRailsリクエストの中に入れて送信します。ProductsPager
のコンストラクタにpage
インスタンス変数を作成し、ページの最後に達するごとに1を追加し、現在の値をJSONリクエストに入れて送信します。
class ProductsPager constructor: (@page = 1) -> $(window).scroll(@check) check: => if @nearBottom() @page++ $(window).unbind('scroll', @check) $.getJSON($('#products').data('json-url'), page: @page, @render)
商品の最後のページを表示したらscroll
イベントのチェックをやめるべきなので、コールバック関数の中に商品が返されたかどうかのチェックを追加して、返された場合のみイベントを再度有効にするようにします。
render: (products) => for product in products $('#products').append Mustache.to_html($('#product_template').html(), product) $(window).scroll(@check) if products.length > 0
index
アクションでこのpageパラメータを使って、オフセットを定義することで商品の正しいページが返されるようになりました。
def index @products = Product.order("name").limit(10) @products = @products.offset((params[:page].to_i - 1) * 10) if params[:page].present? respond_to do |format| format.html format.json { render json: @products } end end
ページをリロードして下方向にスクロールし続けると、各商品が一度ずつ表示されます。
重複を取り除く
Mustacheテンプレートを介して表示されるコードは少しフォーマットを直す必要がありますが、その前にRailsとJavaScriptで商品の表示のされ方にいくつか重複があるので、それを取り除きます。index
アクションには現在、MustacheテンプレートとErbテンプレートという2つの非常によく似たテンプレートが存在します。使用するテンプレートを1つにすることでコードをきれいにできるので、両方でMustacheテンプレートを使うことにします。このためにはRubyでMustacheテンプレートを表示できるようにする方法が必要ですが、これにはmustache gemを使うことができます。このgemと合わせて使用することでRailsにMustacheを統合して使いやすくするその他のgemもありますが、ここではすべて手動でおこなうことにして、テンプレートハンドラがどういう仕組みで機能するかをよりよく理解しましょう。
mustache gemをインストールするには通常の方法でGemfile
に追加してbundle
コマンドを実行します。
source 'http://rubygems.org' gem 'rails', '3.1.1' gem 'sqlite3' # Gems used only for assets and not required # in production environments by default. group :assets do gem 'sass-rails', '~> 3.1.4' gem 'coffee-rails', '~> 3.1.1' gem 'uglifier', '>= 1.0.3' end gem 'jquery-rails' gem 'mustache'
次にmustacheテンプレートをindex
ビューから部分テンプレート(partial)に移します。部分テンプレートはMustacheテンプレートなので、拡張子は通常の.erb
ではなく、.mustache
を付けます。
<div class="product"> <h2><a href="/products/{{id}}">{{name}}</a></h2> <div class="details"> {{price}} {{#released_at}} | Released {{released_at}} {{/released_at}} </div> </div>
index
でこの部分テンプレートを呼び出します。
<script type="text/html" id="product_template"> <%= render 'product' %> </script>
ここでページをリロードするとMissingTemplate
エラーが表示されます。これはアプリケーションにmustacheテンプレートのハンドラがないからです。それを/config/initializers
ディレクトリに作成します(あるいは/lib
ディレクトリに入れることも可能です)。テンプレートハンドラはRails 3.1で大きく変わりました。以前はrender
メソッドとcompile
メソッドをオーバーライドする必要がありましたが、Rails 3.1ではcall
を定義するだけです。
module MustacheTemplateHandler def self.call(template) "#{template.source.inspect}.html_safe" end end ActionView::Template.register_template_handler(:mustache, MustacheTemplateHandler)
ここではモジュールを使用しましたが、その代わりにクラスやprocやlambdaのような、call
にレスポンスするものであれば何でも使えます。call
メソッドが変わっている点は、Rubyコードが含まれた文字列を返さなくてはいけないところです。もし"1 + 1"
を返したら、テンプレートは“2”
を表示します。その代わりにtemplate.source
を呼び出します。これは表示されるテンプレートの内容のことなので、今回の場合は先に作成したMustacheの部分テンプレートになります。ここでinspect
を呼び出すとオブジェクトが返されますが、これは文字列をエスケープしたRuby形式で表現されたバージョンになります。そしてここからの出力に対してhtml_safe
を呼び出して、正しくエスケープされていることを保証します。最後にregister_template_handler
を呼び出してハンドラ名とモジュールを渡すことで新しいハンドラを登録します。
初期化ファイルを新規に作成したり修正した場合には、サーバを再起動する必要があります。するとページが読み込まれ、前と同じように動作します。
これでMustacheテンプレートを用いて、index
ビューで最初の商品リストを表示させることができます。各product
をテンプレートに渡すことで、mustache
テンプレートを用いて表示するということを知らせます。商品をmustache
というローカル変数として渡し、JavaScriptからテンプレートに渡されるデータと同じ形式であることを保証するためにそれをJSONフォーマットに変換します。
<h1>Products</h1> <div id="products" data-json-url="<%= products_url(:format => :json) %>"> <% @products.each do |product| %> <%= render 'product', :mustache => product.as_json %> <% end %> </div> <script type="text/html" id="product_template"> <%= render 'product' %> </script>
Mustacheテンプレートハンドラでmustache
オプションをチェックして、リクエストがAJAXリクエストではなく、index
アクションから直接来たものであることを確認します。これをおこなうために、渡されたキーが含まれるtemplate.localsをチェックします。indexから直接来ている場合は、Ruby Mustacheを使って、現在の商品が含まれたmustache変数を渡して、テンプレートを表示します。そうでなければ、クライアント側で処理するためにJavaScript用のテンプレートをそのまま表示します。
module MustacheTemplateHandler def self.call(template) if template.locals.include? :mustache "Mustache.render(#{template.source.inspect}, mustache).html_safe" else "#{template.source.inspect}.html_safe" end end end ActionView::Template.register_template_handler(:mustache, MustacheTemplateHandler)
サーバを再起動してページをリロードすると、JavaScriptで表示される商品だけはなく、Mustacheテンプレートを用いてすべての商品が表示されます。
出力をフォーマットする
重複を取り除いたので、次はフォーマットを正しく直すことに集中して、価格と発売日が希望する形式になるように修正します。テンプレートに渡すことができる正しくフォーマットされた商品を返す、product_for_mustacheというヘルパーメソッドを作成します。
module ProductsHelper def products_for_mustache(product) { url: product_url(product), name: product.name, price: number_to_currency(product.price), released_at: product.released_at.try(:strftime, "%B %e, %Y") } end end
このメソッドはかなり単純ですが、ここに複雑なロジックを置きたい場合は、エピソード287で紹介した方法で、presenterクラスに移動することを検討するのもいいかも知れません。しかし、今回の場合はシンプルなヘルパーメソッドで十分です。
indexビューでこのメソッドを呼び出して、テンプレートに正しくフォーマットされた商品情報が渡されるようにします。
<div id="products" data-json-url="<%= products_url(:format => :json) %>"> <% @products.each do |product| %> <%= render 'product', :mustache => products_for_mustache(product) %> <% end %> </div>
Mustacheテンプレートを少し修正します。ヘルパーメソッドが商品ごとのURLを返すので、テンプレートに決め打ちされたURLをその値に置き換えます。
<div class="product"> <h2><a href="{{url}}">{{name}}</a></h2> <div class="details"> {{price}} {{#released_at}} | Released {{released_at}} {{/released_at}} </div> </div>
最後に一つ修正が必要です。ProductsController
のindex
アクションにJSONリクエストを送信すると、JSONで商品リストが返されます。それぞれが正しくフォーマットされるように、個別にヘルパーメソッドを介して渡したいのですが、それにはmap
を使って各商品で繰り返し処理を行ってヘルパーメソッドを介します。コントローラからヘルパーメソッドを呼び出しているので、view_context
を介さなくてはいけないことに留意してください。
def index @products = Product.order("name").limit(10) @products = @products.offset((params[:page].to_i - 1) * 10) if params[:page].present? respond_to do |format| format.html format.json do render json: @products.map { |p| view_context.products_for_mustache(p) } end end end
商品ページをリロードすると、商品は希望したとおりにフォーマットされています。
RailsとJavaScriptでMustacheテンプレートを共有する今回のエピソードは以上です。これで、最初の10項目をRubyで表示した場合と、ページを下にスクロールしたときにJavaScriptで表示した場合で、同じように項目を表示するページができました。
もしMustacheを気に入ったら、Handlebarsも一見の価値があります。これはMustacheを拡張し、テンプレートでさらに複雑なことができるような機能を付加します。またパフォーマンス向上のために独立したコンパイルステップを追加します。