読者です 読者をやめる 読者になる 読者になる

seri::diary

プログラミングのこととかポエムとか

今更ながらTurbolinksを初めて仕事で使ってみたので色々調べてみた

Rails Ruby Turbolinks

f:id:serihiro:20140802065933j:plain

※このエントリーで使用している検証環境の各種バージョンは下記の通りです。

  • Railsのバージョンは4.1.4
  • Rubyのバージョンは2.1.2p95
  • Chromeのバージョンは36.0.1985.125 m

※このエントリーの最終更新日は2014.8.11です

2013年辺りのRails4について書かれたブログを読むとTurbolinksに関するエントリが結構多いんですね。

ざっとググって1ページ目に来るのがこれらのエントリー。

そして同じぐらい目にするのが「Turbolinksをオフにする方法」

Railsを使ってなかった頃からtwitterとかでちょいちょい流れてくるTurbolinksの記事を流し読み程度に読んでいたのですが、色んな人が解説エントリを書くほど注目されている一方で、何故か無効にする方法がやたら充実しています。
使っていいのダメなの??ということが外から判断できないということで、どんな恐ろしい機能なのかとjavaを書きながら横目でチラチラを見守っていたのですが、遂に今の職場で使う機会に巡りあいました。これも何かの運命でしょうか。

で、良い機会ですのでTurbolinksについてじっくり調べてみました。 さらに身を持ってその仕様を体験するためにちょっとした実験をやってみたのでその結果をご紹介します。

なお、本エントリーはRailsでアプリを作る人がturbolinksを使う時に気をつけること、というレベルでまとめたので、Turbolinksの実装の詳細についてはあまり触れていませんが、冒頭で紹介したリンク先の内容に詳しく記述されていますのでそちらを読むとさらに理解が深まると思います。

Turbolinksとは

Githubの本家リポジトリrails/turbolinks · GitHubでは以下のように説明されています。

  • Turbolinksはあなたのアプリケーションを早くするよ!
  • ブラウザにjs,cssをリコンパイルさせずに現在のページをActiveに保ってbodyとtitleとheadだけを書き変えるよ!
  • pjaxと似てるけど、Turbolinksは何も考えずにbodyをガバっとreplaceしてくれるのでサーバ側で特別なことをせずにpjaxを使うのとほぼ同じような恩恵を得られるよ!
  • もちろん(現在のページをActiveに保つということは)ブラウザ上でjsの長時間のプロセスを持続することになるので、処理の肥大化やメモリリークに気をつける必要があるけど、あなたがファンキーなことをしなければ多分大丈夫だよ!

要するにページ遷移(GETのみ)を通常のページ移動ではなくajaxで取得したhtmlでbodyを入れ変えるだけの処理に差し替えることで高速化するgemです。 似たようなことをしてくれるライブラリとしてpjaxというjqueryライブラリがあります。

Turbolinksにおけるjsの取り扱い

しかし手放しで何も考えずに入れとけばちゃんと動くというものでもないようです。

  • 最初にロードしたページがActive状態であり続ける
  • jsのプロセスが持続する

この事実を考慮すると今まで書いていたjsがそのまま動くのかちょっと不安になります。

例えばこんなコードはTurbolinksを使ってるページでもページ遷移毎に実行されるのでしょうか?

<script>
window.onload = function(){
  console.log('nya-n');
});
</script>

window.onloadの実行タイミングはページ全体がロードされた後のはず。 ってことは、最初の1ページ目の表示では問題なくwindow.onloadが発火しそうですが、Turbolinksが有効になっているページでページ遷移した時は動くのでしょうか? ついでに頻繁に使う$(document).ready

迷ったら実験しましょう。

実験 各イベントはいつ発火するのか

rails4.1.4で簡単なサンプルを作りました。
サンプルコードのリポジトリこちらです。

$ rake routes
Prefix Verb URI Pattern      Controller#Action
  root GET  /                index#index
 other GET  /other(.:format) index#other

/views/index/index.html.erb

/views/index/other.html.erb

/assets/javascripts/index.js.coffee

rails sして実際にアクセスしてみます。

実験1  index/indexのURLを指定してアクセス

f:id:serihiro:20140810144656p:plain

  1. ベタ書きしたjsの即時関数
  2. assets配下のCoffeeScriptに書いた$(document).ready
  3. ベタ書きした$(document).ready
  4. page:change
  5. page:update
  6. assets配下のCofeeScriptに書いた$(window).load
  7. ベタ書きした$(window).load

実験2 リンクをクリックしてindex/otherへページ遷移

f:id:serihiro:20140810145149p:plain

  1. page:before-change
  2. page:fetch
  3. page:receive
  4. ベタ書きしたjsの即時関数
  5. ベタ書きした$(document).ready
  6. page:change
  7. page:update
  8. page:load

実験3 リンクをクリックしてindex/indexへページ遷移

f:id:serihiro:20140810145730p:plain

  1. page:before-change
  2. page:fetch
  3. page:receive
  4. ベタ書きしたjsの即時関数
  5. ベタ書きした$(document).ready
  6. page:change
  7. page:update
  8. page:load

この結果から以下のことが言えます。

  • $(window).loadはページの初回表示時のみ実行され、リンクをクリックして遷移した場合は実行されない
  • $(document).readyはページの初回表示時のみ実行され、リンクをクリックして遷移した場合は実行されない
  • テンプレートファイルにベタ書きしたjsはページ遷移毎に実行される
  • テンプレートファイルにベタ書きした$(documebt).readyはページ遷移毎に実行される
  • テンプレートファイルにベタ書きした$(window).loadはリンクをクリックして遷移した場合は実行されない

assets配下のCoffeScriptに書いた処理はTurbolinksの管理配下の挙動になり、テンプレートファイルに書いたjsはページ遷移毎にそのまま実行されるようです。
こちらの記事にも書いてありますが、ページ遷移の度にロードされたhtml内のbodyタグ内のjsタグを取得して、新規のjsタグを組み立て再度差し込んで実行させているようです。

処理的には、turbolinks本体のCoffeeScriptを読み込んだ時にAタグをクリックした際にxhrオブジェクトを作ってxhrオブジェクトからリクエストを飛ばす処理をbindしているのですが、ページ遷移時の詳細な挙動はQiitaのこちらの記事に詳しく説明されています。 CoffeeScript側のソースだけなら436行しかないのですぐ読めますので気になる方は一度全部読んでみると良いと思います。実装自体は割と素直に書いてありますので読みやすいです。

Turbolinks導入のメリット

最初に書いたようにTurbolinksを導入することの利点はページロードの高速化です。html全体を再ロードしないため、ブラウザ上での体感速度向上が期待できます。

Turbolinks導入のデメリット

これまで書いてきたjsが動かなくなる可能性が高い

通常のwebページとjsの挙動が大きく変わるためこれまで作ってきたjsが上手く動作しない可能性が高くなります。
これまでRails3で作ってきたrailsアプリケーションをrails4にアップグレードした途端に正しく動かなくなるといったことが簡単に発生します。

ページ遷移してもmetaタグが更新されない

ページ遷移してもCSRF-Token以外のmetaタグが更新されないという挙動になっています。

そのため、普通ページ毎に内容が異なるogタグ等もページ遷移しても更新されません。 しかし、実際にfacebookのいいね!ボタンをクリックしたりした場合においては、フルロードしたコンテンツを各SNSが取得してくれるはずなので問題無いのでは?というようなコメントがついています。DHHも「それが必要なユースケースは聞いたことがない(I haven't heard a compelling use case yet)」とコメントしています。

既存のjsが動かなくなるケース

既存のjsをそのまま使おうとした時にどんな問題が起きるか具体例を上げて説明します。

$(document).readyにイベントをbindする処理を書いてたら最初にアクセスしたページではbindされるが遷移したらbindされない

$(document).readyはリンククリックでのページ遷移では発火しないので、同じjsファイルを読み込んでいるのに「外部リンクから最初にやってきた時しか正しく動かない」ということが起きます。

$(document)に同じイベントが多重bindされて正しい挙動にならない

全ページで読み込むjsで(例えば共有しているテンプレートファイルにベタ書き)即時関数で以下のbind処理を記述したとします。

<script>
$(function(){
  $(document).on('click', '#button', function(){
    console.log('ニャーン');
  })();
});
</script>

で、何度もページ遷移してから#bindをクリックするとページ遷移した分だけconsoleに「ニャーン」と表示されます。猫好きの私歓喜!とか言ってる場合じゃありません。

これが発生する原因は、最初に書いたとおりjsのプロセスがずっと継続するため、$(document)のオブジェクトはページ遷移してもずっと同一のものが生存し続けています。
そのため、ページ遷移する度にbindするようにすると、同一の$(document)オブジェクトに再度bindしてしまうため、何重にも同じイベントがbindされることになります。 私も最初この挙動にハマって小一時間悩みました…。1回のアクションでajaxリクエストが複数回飛んでるっぽい?などの分かりにくい問題が起こります。

なんでみんなオフにしたがるのか?

既存アプリにTurbolinksを導入する場合、「デメリットの方が多くなるケースが多い」ということに尽きると思います。 具体的には * jsをTurbolinksの挙動に合わせて修正するコストが高い * Turbolinksの挙動を正しく理解していないと簡単にトラブルになる * フロントエンドを担当するエンジニアへの負担が大きくなる といった所が問題になりそうです。想像の範囲ですが。

それでもTurbolinksを有効にする場合に気をつけること

urbolinksを使用する際のjs実装について気をつけることについて説明します。

ページ遷移の度に実行したいjsの実装

lightboxやmasonryなどのデザイン系のjQueryプラグインを適用するケースによくある「ページ遷移の度に実行したい」場合。 多重実行されるとやっぱり挙動がおかしくなるので、「複数回実行はさせたくないがページ遷移毎に1回だけ実行したい」という要件になります。

これを実現するにはいくつか方法があります。

1. テンプレートファイルにベタ書きする

毎回実行されることが保証されていますのでテンプレートファイルに記述すれば良さそうです。ただ、jsはassetsで管理してapplication.jsの1ファイルにまとめてロードするというrailsの作法からはちょっと外れる形にはなります(これも厳密に守るのはかなり難しい作法ですが)

2. $(document).readypage:loadの両方で実行されるようにassets管理下のjs,coffeescriptに記述する

$(document).readyはページ初回表示時にのみ実行され、page:loadはページ遷移時にのみ実行されます。ページ初回表示時には実行されません。

なので、同じ処理を両イベントでbindしておけば、常にページ遷移ごとに一回だけ実行されることが保証されます。

以下のように記述します。

$(document).on 'ready page:load', -> 
  console.log 'ready and load'

これでページ遷移毎に実行され、かつ一回だけ実行されます。

ただし、ブラウザバックで戻った時にはpage:loadが実行されないので、ブラウザバックで実行されなくて困る処理はpage:changeにbindする必要があります。 しかし、turbolinks使用時にブラウザの戻るボタンをクリックした場合、turbolinksが内部でキャッシュしているpageCacheからページが復元され(turbolinksはURLの書き換え等にhisotry APIを使っています)、pushStateが使えない場合はリダイレクトされる(location.hrefを書き変えてます)ので大体の場合において問題は起きないと思います。

イベントのbind

難しいのはこっちで、いくつか方法が考えられますがどの方法を取るにしても開発チーム内で話し合って決めておく必要があります。 主な関心事としては「多重bind」をいかに防止するかということになりますが、逆に多重bindされても問題がないようにイベントのハンドラを実装しておくというの手です。

まず前提条件としてテンプレートファイルにベタ書きせず、assetで管理されているjs、CoffeeScriptのファイルに記述することが条件になります。
テンプレートファイルにイベントをbindする処理を普通に書くとページ遷移毎にイベントがbindされるので$(document)の場合は上手くワークしません。 テンプレートファイルにjsを書かなければいけないケースはControllerから値を渡すようなケースに限定して、使わないように開発チーム内で取り決めをしておく必要があります。

1.$(document)にbindする場合

ajaxで後からロードするhtmlに対してイベントを定義しておく場合などによく用いられるケースです。

assetでapplication.js1つにjsがまとめられる場合は、$(document).ready内でbindするように記述します。

■/assets/path/index.js.coffee

$(document).ready ->
  $(document).on 'click', '#popup', (e)->
    alert('popup') 
  $(document).on 'hover', '.menu', (e)->
    e.currentTarget.addClass('hovered')

上述の通り初回表示時にのみ実行されるため、初回表示時に$(document)にbindする全てのイベントがbindされその後ページ遷移しても実行されません。
これにより確実に一回だけイベントがbindされます。ページ遷移毎に$(document) にbindするように実装すると上述した通り同じイベントが同じDOMに何重にもbindされて不具合を起こすケースがあります。

イベントのbind自体は$(document)に直接bindするのが一番早いようですし、パフォーマンス的にも問題は起こりにくい方法だと考えられますので、特別な理由が無い限り、イベントのbindは$(document).on({event}, {selector}, {callback})の形式に統一してしまうのが良いのではないかと思います。
参考:高速で安全なjQueryを書くために今できること | Dress Cording

ページごとにjsを読み込む場合は、そもそもturbolinksのページ遷移にならず通常の遷移になるので気にする必要はないですが、turbolinksの恩恵を受けられなくなります。

2.指定のセレクタにbindする場合

$(document).'ready page:load'でbindするようにすればページ遷移毎に実行されますので、これで対応出来ると思います。

こんな面倒なことしてられるか!俺はRails4でも今までどおりに開発させてもらう!という場合

特定のリンク時のみturbolinksで遷移せず通常のページ遷移をするようにする場合

aタグにdata-no-turbolinkという属性を付けます。 例:

<a href="/user" data-no-turbolink>User List</a>

全ページでリンククリック時のページ遷移にturbolinksを使わない場合

gemファイルとturbolinksを読み込んでる箇所をjs,htmlから削除します。turbolinksのソース読み込んだ時にAタグにbindingされるんでそりゃそうですが。
詳しい手順はこちらを参照。

まとめ

以上、簡単ですがturbolinksの挙動と実際に使う場合の注意点について述べました。
文中でも書いたとおり、これまでの色々なweb上でのjsの常識が通じなくなりますので、既存のjsを流用する場合はかなりの手間が必要になります。
またAngular.jsなどのフロントエンドフレームワークを使う場合においても対応が必要なようです。

そのためjsを多用したSPAを作る場合や、開発メンバーが不慣れな場合はturbolinks自体使わないという選択肢もアリだと思います。正直私も最近初めて使ったのですが最初は色々なものが動いたり動かなかったりでなんじゃこりゃと思いました。

ただ、Rails4から標準で有効になっている以上、Railsの作法の一つとして実装されているものだと考えています。
今後のバージョンアップでどうなるか分かりませんが、今後Railsで新規アプリを作っていくのであれば、うまい付き合い方を知っておいた方が結果的にRails Wayの恩恵をより受けられるようになっていくのではないかと勝手に考え、今回の機会にまとめてみました。