おやまのエンジニアリングブログ

某ゲーム開発会社のフルスタックエンジニアしてます

Turbolinksを実装するにあたっての注意点

はじめに

Rails 4になってTurbolinkspjaxなどの非同期通信を使ってページを更新する方法が広く知られるようになりました。自分も積極的にプロジェクトに取り込んだのですが、結構癖があり、実装するにあたって色々考慮が必要だったのでその実装方法をまとめておきます。

ページ遷移後に$.readyが実行されない

jQueryの.readyが実行されないというのがありますが、実はjquery-turbolinksというgemをGemfileに加えて、追加されたファイルをJavascriptマニフェストファイルに加えるだけで解決するので特に問題ではありません。

グローバルスコープがクリアされない

技術の特性として、コンテンツ部分だけを置き換えているので、Javascriptのスコープはページ遷移をしてもクリアされません。なので、グローバルスコープに関数や変数を定義し続けると、どんどんメモリーが消費されますし、変数の上書き等が原因で想定しない動作をする危険性がでてきます。また、bodyなどにイベントをバインドするコードがページ内にある場合、ページ遷移をする度に実行されるイベントの回数が増えてしまいます。以上のような問題を回避するためには、即時関数等を使ってページ内に埋め込まれているJavascript処理のスコープを閉じれることが重要です。そうすることで、ページから離れた時にGC(Garbage Collection)の回収対象にすることができ、GCが実行されたタイミングでメモリーが解放されます。

setTimeoutとsetIntervalが破棄されない

通常の画面繊維を行う場合、画面が切り替わった時点でsetTimeoutとsetIntervalは勝手にクリアされますが、turbolinksを使った場合は、これがクリアされません。なので、非同期でページが切り替わるときにタイマーをクリアする仕組みが必要です。これを実現する方法は色々あるともいますが、とりあえず実装方法を2つ紹介しておきます。まず1つ目は、自分でタイマーidを管理し、ページが切り替わる時のイベントハンドラーが呼ばれた時にクリアするコードを仕込む方法です。実装がシンプルという利点がありますが、各ページのすべてのタイマーを個別管理するという欠点があります。実装は以下のような感じになります。

var intervalId = setInterval(function() {
  console.log(1);
}, 100);

$(document).on('page:change', function() {
  clearInterval(intervalId);
});

もう一つの方法はsetTimeout/setInterval/clearTimeout/clearIntervalを書き換えてしまう方法です。各ページ固有のJavascriptに対して、turbolinksの存在を意識しなくてよいという利点があります。また、turbolinksを切ってもちゃんと動作する事が保証されるという利点もあります。しかしデフォルトでブラウザに入っている関数を上書いてしまうので、実装するときには細心の注意が必要です。実装例は以下の通りです。

var intervals = {};
var _setInterval = window.setInterval;

window.setInterval = function(fn, interval) {
  fn.intervalId = _setInterval(fn, interval);
  intervals[fn.intervalId] = fn;
  return fn.intervalId;
};

var _clearInterval = window.clearInterval;
window.clearInterval = function(intervalId) {
  _clearInterval(intervalId);
  delete intervals[intervalId];
};

$(document).on("page:change", function() {
  // clear all timers in queue
  for(var intervalId in intervals) {
    _clearInterval(intervals[intervalId]);
  }
  intervals = {};
});

※ 上の例はintervalの方だけカバーしています。timeoutも同様の実装が必要です。

こうすることで普通のページ繊維とほぼ同じ挙動になります。ですが、上書きされる前の状態で定義されたタイマーはページ遷移でクリアされなかったり、ライブラリなどで自動でスタートされるタイマーなどがこの中に含まれてしうとページ遷移時に消えてしまうので注意が必要です。利点が大きい分、欠点も大きいのがこの実装の特徴です。

通信中の非同期通信が止まらない

この問題は、ちゃんとハンドリングしていないと非常に厄介です。なぜならこれはタイミングの問題なで毎回発生する訳ではないからです。本来ページ遷移が行われた場合、通信中の非同期通信は、すべてキャンセルされます。ということは、当然コールバックは実行されません。しかし、Turbolinksの場合、ページ遷移も非同期通信であるため、ページが変わっても他の非同期通信が切断されませんし、コールバックも実行されてしまい、想定外の挙動をしたり、エラーが出たりします。この問題を回避するために、ページ取得後に必ず、通信中の非同期通信をすべてキャンセルする仕組みが必要となります。
以下が実際に実装してみたコードをシンプルにまとめたものです。

var ajaxCalls = {};
var uniqId = (function() {
  var id = 0;
  return function() { return ++id; };
}());

$(document).ajaxSend(function(e, xhr) {
  xhr.ajaxId = uniqId();
  ajaxCalls[xhr.ajaxId] = xhr;
});

$(document).ajaxComplete(function(e, xhr) {
  delete ajaxCalls[xhr.ajaxId];
});

$(document).on("page:change", function() {
  for(var id in ajaxCalls) {
    ajaxCalls[id].abort();
  }
  ajaxCalls = {};
});
最後に

如何でしょうか。これでとりあえずTurbolinksを使っていても安定動作を実現できると思います。めんどくさい実装ですが、リワードは結構大きいと思いますので、新規プロジェクトなどで試してみる事をお勧めします。