Railsで高レスポンスかつ初期表示も速いスマホ用Webサイトを作る

# SPAについて

## 前書き
スマホはネイティブアプリを使う機会が多いため、すばやいレスポンスが当たり前の感覚になっています。
Webサイトでも同じような感覚を実現できるよう、SPAという仕組みで構築することが昨今の流行となっています。
Rails4ではレスポンス向上のためにturbolinksというSPAに近い仕組みが用意されていますが、簡単に実現できるかわりに、パフォーマンス改善も限定的になってしまうため、ここではSPAを取り上げます。

##SPAとは
SPAとはSinglePageApplicationの略で、最初にWebサーバからHTMLを取得した後は、JavaScriptで画面を切り替えていき、JavaScriptライブラリやCSSのLoadingやParseを最初のHTML取得時のみにして、さらにサーバとの通信も最小限とすることで、ネイティブアプリと変わらないような快適なレスポンスを実現する仕組みです。
最近はSPAのフレームワークとしてAngularJSが取り上げられることが多くなりました。

# SPAの問題点
SPAを使うことで、ネイティブアプリと遜色ない高レスポンスのWebアプリを構築することができますが、すべての画面の処理を記述したJavaScriptにより、jsファイルが大きくなって、Loading & Parseに時間がかかることにより、初期表示が遅くなってしまいがちです。

特にスマホの場合、PCと比べて回線も遅く、処理能力も落ちるため、より初期表示の遅さが問題になりやすいです。

SPAなんだから、初期表示遅いのは仕方ないよねと諦めてしまいがちなのですが、[サイト表示が2秒遅いだけで直帰率は50％増加！](http://web-tan.forum.impressrd.jp/e/2014/07/08/17757)の記事にもあるように、折角SPAで快適なページ遷移を実現しても、ページを遷移するまでそのサイトがSPAかどうかは誰もわからないため、初期表示の段階でこのサイト重いと認識されると直帰されてしまいます。

# SPAでも初期表示を速くする対応

## JavaScriptをできるだけコンパクトになるようにする

下記のようにフレームワークやライブラリをコンパクトなものを選択したり、さらには必要最小限になるよう自分で実装するなど頑張る必要があります。

|SPAフレームワーク  |サイズ|
|:------------------|------:|
|AngularJS          |108Kb|
|Backbone+Underscore|11.7Kb|

|ライブラリ        |サイズ|
|:-----------------|------:|
|jQuery            |82Kb|
|Zepto(jQuery互換) |25Kb|   


## 初期表示時にサーバとの通信を極力行わない
SPAで作成する場合、各モデルごとにサーバとデータを同期する必要がありますが、データを初期のHTMLにJSONデータとして埋め込んでしまうことで、Ajax通信を回避することが可能です。すべてのモデルで行うとサーバ自体の処理が重くなったり、ページのサイズが大きくなってしまうので、初期表示に必要な分だけのデータが望ましいです。

```index.html.erb
<script type="text/javascript">
  var initialData = <%= raw(@initialData.to_json) %>
</script>
```

## jsファイルを分割してOn Demand Loadingにする
パフォーマンス改善の対策として、全部の処理をひとつのjsにまとめることでリクエスト数を減らすことが一般的ですが、SPAの場合、全部の画面の処理をjsにまとめてしまうと、機能追加する度にjsファイルが大きくなってサイトが重くなってしまい、せっかく軽いライブラリを使っていてもその効果が薄くなってしまいます。
そのため、最初は必要最低限のjsだけ読み込んで、以降は必要に応じてjsを動的に読み込むことで、jsのサイズを抑え、初期表示を早くすることができます。

Railsの場合はsprocketsを使って簡単にjsのグループ分けすることができます。On Demandのjsのロードは下記のように実装することができます。

``` js_loader.js
JsLoader = function(srcMap){
 this.srcMap = {};
 for(key in srcMap){
   this.srcMap[key] = {state: "unload",src: srcMap[key],cb: []}
 }
};
JsLoader.prototype = {
  load: function(m,cb){
    var that = this;
    if(!this.srcMap[m]){
      throw m + " was not found";
    }
    if(this.srcMap[m].state == "unload"){
      var fjs = document.getElementsByTagName("script")[0]
      var script = document.createElement("script");
      this.srcMap[m].state = "loading"
      that.srcMap[m].cb.push(cb);
      script.onload = function(){
        that.srcMap[m].state = "loaded"
        while(that.srcMap[m].cb.length > 0){
          that.srcMap[m].cb[0]();
          that.srcMap[m].cb.shift()
        }
      }
      script.onreadystatechange = function(){
        if (script.readyState == "loaded" || script.readyState=="complete"){
          that.srcMap[m].state = "loaded"
          while(that.srcMap[m].cb.length > 0){
            that.srcMap[m].cb[0]();
            that.srcMap[m].cb.shift()
          }
        }
      };
      script.src = this.srcMap[m].src;
      fjs.parentNode.insertBefore(script,fjs);
    }else if(this.srcMap[m].state == "loading"){
      this.srcMap[key].cb.push(cb);
    }else{
      cb()
    }
  }
}

```


```application.js
//sprockets Topページ表示に最低限必要なjs
//= require jquery
//= require ./lib/underscore
//= require ./lib/backbone
//= require ./lib/js_loader.js
//= require_tree ./templates
//= require_tree ./backbone
```

```misc.js
//sprockets それ以外のページ表示に必要なjs
//= require_tree ./misc_templates
//= require_tree ./misc_backbone
```

```sample_controller.rb
class SampleController < ApplicationController
  def index
    @src_map = {"misc" => ActionController::Base.helpers.asset_path("misc.js")}
  end
end
```

```index.html.erb
<html>
<head>
  <meta charset="UTF-8">
  <%= layout_meta_tags %>
  <%= stylesheet_link_tag    "application", media: "all" %>
  <%= javascript_include_tag "application" %>
  <%= csrf_meta_tags %>
  <title>Sample</title>
</head>
<body>
  <script type="text/javascript">
   var router = new SampleRouter({jsLoader: new JsLoader(<%= raw(@src_map.to_json) %>)});
  </script>
</body>
</html>
```

```router.js
var SampleRouter = Backbone.Router.extend({
  initialize: function(options){
    this.jsLoader = options.jsLoader
  },
  routes: { 
    "topics": "topics",
    ".*": "top"
  },
  top: function(){
    //描画
  },
  topics: function(){
    this.jsLoader.load("misc",function(){
      //描画
    }.bind(this))
  }
});
```

#まとめ
私が今、開発、運用している無料のFX情報サイトである[smartFX](http://smartfx.minkabu.jp)(smartfx.jp)には、上記の3つの方法を実践しています。
そこそこ大きいSPAのサイトですが、SPAではないサイトに負けない初期表示の早さが実現できていると思っているので、ぜひ見てみてください。