のらねこの気まま暮らし

技術系だけど、Qiita向きではないポエムとかを書くめったに更新されないヤツ

Backbone.jsのpopstate時にルートページのcallbackが実行される

Backbone.jsとjQuery pjaxを併用してフロントエンド充してるところで、思わぬ罠にハマったので記録しとく。

問題

var MyRouter = Backbone.Router.extend({
    "events" : {
        ""        : 'home',
        "path/to" : 'fuga'
    },
    "home" : function () {
        console.log('--route:home--');
    },
    "fuga" : function () {
        console.log('--route:fuga--');
    }
});

$(function () {
    var router = new MyRouter();
    Backbone.history.start({"pushState" : true});
});

こういうコードが合った時、以下のような遷移を行うとする。

/ -> /huga/hoge -> /path/to -> (ブラウザバック)

そうすると、次のような関連性でcallbackが行われる。

遷移 URL console.log
/ / --route:home--
/huga/hoge /huga/hoge
/path/to /path/to --route:fuga--
ブラウザバック /huga/hoge --route:home--

callbackを登録してない"/hoge/huga"のパスでhomeのコールバックが呼ばれてしまう。

原因

console.logとかでchrome developerToolを使ってデータの遷移を追ってみた。

backbone.jsでpopstateされてからコールバックが実行されるまでの流れ

  1. popstateのイベントに登録されているのは、Backbone.History.checkUrlメソッド
  2. Backbone.History.checkUrlの中で呼ばれるBackbone.History.loadUrlメソッド呼ばれる
  3. Backbone.loadUrlはBackbone.History.getFragmentからfragmentを受け取り・上書きする
  4. Backbone.loadUrlはgetFragmentで書き換えたfragmentを元にcallbackを選択・実行する

どうやらBackbone.History.loadUrlメソッドがfragmentを書き換えているらしい

1170: var fragment = this.fragment = this.getFragment(fragmentOverride);

this.fragmentにブラウザバックしたときのURLが入っている。
しかし、fragmentOverrideはundefinedが代入されており、それがthis.getFragmentに渡される。

getFragment内でfragmentOverrideが == null だった場合、fragmentを書き換える。
その結果、fragment === ''という判定がくだされ、""のrouteに紐づくhomeのコールバックが実行される。

fragment == null

undefined == null はtrueとして扱われるため、このよくわからない上書きが発生する。
もし、undefinedを許可したくないのであれば(fragment === null)と型を見ればいい。

Backbone.History.getFragmentはundefinedも許容したい。

上記判定を行うとすると、fragment = ''の時だけfragmentの取得が行われる。
しかし、実は""へのアクセス時のfragmentOverrideはundefinedが格納される。

じゃあこうするしか無いな。

if (fragment === null || fragment === undefined)

・・・・あれ? ソレ結局元にもどっただけじゃんか。

解決策

前回に引き続き@が解決策を提案してくれたよ!
提案というか、発見なんだけど。こうすればいいんじゃね?と。

http://stackoverflow.com/questions/6088073/default-routes-in-a-backbone-js-controller

routesに*{なまえ}のrouteを登録するとデフォルトでrouteを設定してくれる。
こいつを設定すると、homeのtrigger実行されなくなる、と。

"routes" : {
    "*path" : "defaultRoute"
}

ためしてみた。

var MyRouter = Backbone.Router.extend({
    "events" : {
        "*path"   : 'defaultRoute', // <- 追加
        ""        : 'home',
        "path/to" : 'fuga'
    },
    "defaultRoute" : function () {
        console.log('--route:default--');
    },
    "home" : function () {
        console.log('--route:home--');
    },
    "fuga" : function () {
        console.log('--route:fuga--');
    }
});

$(function () {
    var router = new MyRouter();
    Backbone.history.start({"pushState" : true});
});

/ -> /huga/hoge -> /path/to -> (ブラウザバック)

遷移 URL console.log
/ / --route:home--
/huga/hoge /huga/hoge --route:default--
/path/to /path/to --route:fuga--
ブラウザバック /huga/hoge --route:default--

本当だ・・・!

なんでかってーと

pushStateがtrueの時、Backbone.History.loadUrlでfragmentからcallbackが該当するものを探しだすんスよ。
その結果がcheckUrlに渡されて、callbackがあるものはtrueを返すが、trueが無かったら今度はHashから探しにいくんすよ。
Hashでもcallbackが見つからなかったら、""が実行されるらしくて。

なんで、全部のrouteにcallbackが登録されていれば、そいつ(defaultRoute)が実行される。

なんてわかりにくい・・・!

結論

routesにdefaultを登録してやれ。

たぶんBackbone.jsはRoutesにすべてのrouteのこーるばっくを登録している前提で組まれているから。


まったく、Backboneのコンセプトは好きだが、コードは好きになれない。


今回もヘルプで助けてくれた@と、JSで困ったらとりあえず質問を投げてアドバイスくれる@にさんくす。