LoginSignup
3
5

More than 5 years have passed since last update.

Backbone.js再入門はソースコードリーディングから【その5】

Posted at

Backbone.jsのソースコードを読んでみる

今回はBackboneのRouter, History
正直、ちょっと疲れてきたのと、Backbone.History難しいのでキレがなくなってきました。
間違って解釈していることもあるつもりでいるので助けてください。

対象Version

Backbone.js 1.2.3

Backbone.Router


  // RouterはMVCでいうところのControllerと思って良い, Marionette.jsではRouter, Controllerで役割を分けることができるがBackbone.jsでは両方の役割を持つことになる
  // 引数optionsには.Router.routesを上書きするための情報を入力することができる
  // ModelやViewと同じく、ユーザーがー定義している.initializeを最後に評価する
  var Router = Backbone.Router = function(options) {

    // optionsがあれが.routesにて
    options || (options = {});
    if (options.routes) this.routes = options.routes;

    // ルーティング情報の登録を行う
    this._bindRoutes();
    j
    // ユーザー定義の初期化処理の実行
    this.initialize.apply(this, arguments);
  };
  j
  // 文字列を正規表現オブジェクト変換する際に使う
  var optionalParam = /\((.*?)\)/g;
  var namedParam    = /(\(\?)?:\w+/g;
  var splatParam    = /\*\w+/g;
  var escapeRegExp  = /[\-{}\[\]+?.,\\\^$|#\s]/g;

  _.extend(Router.prototype, Events, {

    initialize: function(){},

    // ルーティング情報の登録を行う
    // 引数の順番をずらす仕組みが入っているので少し見づらい
    route: function(route, name, callback) {

      // routeが正規表現のオブジェクトじゃないは場合に正規表現のオブジェクトに変換する
      if (!_.isRegExp(route)) route = this._routeToRegExp(route);

      // 2つめの文字列にはcallback用の関数が入ってくることがあるので
      // その場合は引数の役割をずらす 
      if (_.isFunction(name)) {
        callback = name;
        name = '';
      }
      // ここでやっと変数callbackに関数が入ることを約束できる
      if (!callback) callback = this[name];

      // 一旦整理すると
      // route: ルーティング情報のkey名となる正規表現オブジェクト
      // name: ルーティング実行時のcallback関数のメソッド名(Router.xxx)と読める場合にのみ利用、callbackが無名関数の場合は特に役割がない
      // callback: ルーティング実行時のcallback関数


      // この記述に関してはvar self = this;と同じ意味合いで使っている
      // ↓のhistory.routeにて無名関数と登録するがその関数のcontextがRouterとは異なるからこうなっている
      var router = this;

      // ルーティングの関する情報はBackbone.historyに登録する
      // Backbone.history.routeに関しては単純にデータストアに登録するだけである
      // 第二引数の無名関数がルーティング成立時に実装する関数になる
      // fragmentっていうのは実際にブラウザのURLに使われている【blog/1】のようなハッシュの内容の文字列を指している
      Backbone.history.route(route, function(fragment) {

        // ルーターに登録されている引数の情報を取り出す。
        // 要は、blog/(:id)みたいな感じで仮に登録されていたとすると、[id, ...]みたいな配列にparseさせる役割を持つ
        var args = router._extractParameters(route, fragment);

        // router.executeにてルーティング時のcallbackを実行している
        // falseならrouteイベントを実行しない、って言うことをやっているが.executeの評価結果は常にundefinedになるはずなので意味ない気がする。なんんだろうかこれは・・・・
        if (router.execute(callback, args, name) !== false) {


          // ルーティング時にイベントを3つ発行している
          // Routerの 【route:ルーティング名】, 引数にパラメータ
          // Routerの 【route】, 引数にパラメーター
          // historyの【route】, 引数にパラメーター
          router.trigger.apply(router, ['route:' + name].concat(args));
          router.trigger('route', name, args);
          Backbone.history.trigger('route', router, name, args);
        }
      });
      return this;
    },

    // ルーターに登録されているcallbasckを実際に実行する役割を持つ
    // 見ての通り、callbackの実行時のコンテキストはRouterになるので注意
    execute: function(callback, args, name) {
      if (callback) callback.apply(this, args);
    },

    // Backbone.history.navigateへのショートカットの役割である。
    // Backbone.history.navigateがどのような役割かはそっちの開設の時に
    navigate: function(fragment, options) {
      Backbone.history.navigate(fragment, options);
      return this;
    },

    // .routesに定義されているルーティング情報の登録を行う
    // Routerの役割はほぼこれが全てであり、定義されているルーティング情報をBackbone.historyに登録し、
    // ルーティング完了時のcallbackを実行し、イベントを発行できるようにしているだけである。
    _bindRoutes: function() {
      if (!this.routes) return;
      this.routes = _.result(this, 'routes');
      var route, routes = _.keys(this.routes);

      // popによって後ろの要素から登録していってるのに注目!!
      // ネタバレをすると、ルーティングに関する情報はBackbone.historyに全部登録されます
      // その際はここでpopした要素をBackbone.history.handlersという配列にunshiftで追加していくので結果的にBackbone.Routerに登録されている順番で、最終的に登録されます。
      // 最後に、フラグメント(URLのハッシュの文字列)にマッチしたルーティングを検索する際はBackbone.history.handlersを上から順番に探索していきます。
      // 要は何を言いたいかというと、よくアクセスされるページはBackbone.Routerで定義する際になるべく小さいインデックスにすべきです。最終的な探索コストを減らすことができます
      while ((route = routes.pop()) != null) {

        // route: ルーティング時の名前, mypage,とかblog(/:id)とかの文字列が入る
        // this.routes[route]: ↑に紐付いたルーティング成立時のcallbackの関数名or関数がはいる
        // this.routeによって実際にルーティング情報を登録することができる
        this.route(route, this.routes[route]);
      }
    },

    // 文字列を正規表現オブジェクトに変換することができる
    // 何やってるか全く理解できないけど、これとかUnderscore.jsに組み込んだらいいんちゃうんかな?
    _routeToRegExp: function(route) {
      route = route.replace(escapeRegExp, '\\$&')
                   .replace(optionalParam, '(?:$1)?')
                   .replace(namedParam, function(match, optional) {
                     return optional ? match : '([^/?]+)';
                   })
                   .replace(splatParam, '([^?]*?)');
      return new RegExp('^' + route + '(?:\\?([\\s\\S]*))?$');
    },

    // URLのフラグメントの内容からRouterに登録している正規表現を元に配列を生成する
    // 要は、blog/1みたいなフラグメントで、blog/(:id)みたいにRouterで登録していた際に[1]のような配列を生成させる
    _extractParameters: function(route, fragment) {

      // 引数routeは正規表現オブジェクトなので、route.execはRegExp.prototype.execのことである
      // https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/RegExp/exec
      // 文字列を正規表現オブジェクトで評価する
      var params = route.exec(fragment).slice(1);

      return _.map(params, function(param, i) {
        // Don't decode the search params.
        if (i === params.length - 1) return param || null;
        return param ? decodeURIComponent(param) : null;
      });
    }

  });

  • ルーティングの際のエンドポイントは正規表現オブジェクトとして評価される
  • ルーティングに関する情報は最終的にBackbone.Historyにて管理されることになる
  • ルーティング確立時にはBackbone.Routerの【route】【route:xxx】イベントを発行する
  • ルーティング確立時にはBackbone.historyの【route】イベントを発行する

Backbone.History


  // Backbone.HistoryはHTML5のpushStateなどのAPIをラップすることで、URLの変更を管理し、Routerにイベントを発火させる仕組みを提供している
  // pushStateなどに対応していないブラウザにも対応するために、独自のURL管理のためのポーリングの仕組みを提供することも行なっている
  // -- おさらい --
  // history.pushState: ブラウザの履歴を追加するための仕組み(HTML5のAPIのこと) 
  // popStateイベント: 履歴を移動したら発火するイベント
  // hashchangeイベント: URLのハッシュだけが変わった場合に発火するイベント 
  var History = Backbone.History = function() {

    // ルーティングの情報を格納するための配列
    this.handlers = [];

    // this.checkUrlをHistoryのコンテキストで実行できることを保証している
    this.checkUrl = _.bind(this.checkUrl, this);

    // Ensure that `History` can be used outside of the browser.
    // window.location/window.historyのショートカットを作成する
    if (typeof window !== 'undefined') {
      this.location = window.location;
      this.history = window.history;
    }
  };

  // Cached regex for stripping a leading hash/slash and trailing space.
  var routeStripper = /^[#\/]|\s+$/g;

  // Cached regex for stripping leading and trailing slashes.
  var rootStripper = /^\/+|\/+$/g;

  // Cached regex for stripping urls of hash.
  var pathStripper = /#.*$/;

  // .startが実行されたかどうかをメモしておく。
  // Backbone.Historyに直接メモすることでHistoryの実装を継承したものが仮に複数あったとしても、1つしか実行できないことを保証している
  // Backbone.Historyはブラウザの状態(URL)などを直接管理するものなので、唯一無二であることが望ましい
  History.started = false;

  _.extend(History.prototype, Events, {

    // URLの変更を検知するためにポーリングの仕組みを利用するが
    // そのポーリングの周期を定義している
    interval: 50,

    // Are we at the app root?
    atRoot: function() {
      var path = this.location.pathname.replace(/[^\/]$/, '$&/');
      return path === this.root && !this.getSearch();
    },

    // Does the pathname match the root?
    matchRoot: function() {
      var path = this.decodeFragment(this.location.pathname);
      var root = path.slice(0, this.root.length - 1) + '/';
      return root === this.root;
    },

    // Unicode characters in `location.pathname` are percent encoded so they're
    // decoded for comparison. `%25` should not be decoded since it may be part
    // of an encoded parameter.
    decodeFragment: function(fragment) {
      return decodeURI(fragment.replace(/%25/g, '%2525'));
    },

    // In IE6, the hash fragment and search params are incorrect if the
    // fragment contains `?`.
    getSearch: function() {
      var match = this.location.href.replace(/#.*/, '').match(/\?.+/);
      return match ? match[0] : '';
    },

    // Gets the true hash value. Cannot use location.hash directly due to bug
    // in Firefox where location.hash will always be decoded.
    getHash: function(window) {
      var match = (window || this).location.href.match(/#(.*)$/);
      return match ? match[1] : '';
    },

    // Get the pathname and search params, without the root.
    getPath: function() {
      var path = this.decodeFragment(
        this.location.pathname + this.getSearch()
      ).slice(this.root.length - 1);
      return path.charAt(0) === '/' ? path.slice(1) : path;
    },

    // 現在のfragmentの文字列を取得する
    // fragmentの文字列とは【router.html#mypage】でいうところの【mypage】みたいなものである
    getFragment: function(fragment) {
      if (fragment == null) {
        if (this._usePushState || !this._wantsHashChange) {
          fragment = this.getPath();
        } else {
          fragment = this.getHash();
        }
      }
      return fragment.replace(routeStripper, '');
    },

    // Backbone.Historyの起動コマンド
    // 渡せるoptions
    // silent: start実行時のfragmentを元にルーターに登録されているcallbackを即時実行するか否か
    // hashChange: onhashChangeを使ったURLの変更管理を行うか否か
    // pushState: pushStateを使ったURLの変更管理を行うか否か
    // root: URLのどこを起点にルーターを設置するか。デフォルトでは【/】なっている。何かしらの理由で/mypageから始めたいとかの場合にこれを利用する
    start: function(options) {

      // Backbone.Historyは実行中であれば、例外を返す
      if (History.started) throw new Error('Backbone.history has already been started');
      History.started = true;

      // rootと呼ばれるものは上書きすることができる。
      // rootとは、URLのどこを起点にルーターを設置するかを決めるものだと思っていい
      this.options          = _.extend({root: '/'}, this.options, options);
      this.root             = this.options.root
      ;
      // onhashchangeに対応しているブラウザかを確認して、結果をメンバに持たせる
      // optionsにてhashChangeを使わない用に設定することも可能である
      // ユーザーが使いたいかどうかと、ブラウザが対応しているかどうかの結果を元に、実際に使うか否かを評価している
      this._wantsHashChange = this.options.hashChange !== false;
      this._hasHashChange   = 'onhashchange' in window && (document.documentMode === void 0 || document.documentMode > 7);
      this._useHashChange   = this._wantsHashChange && this._hasHashChange;

      // onhashchangeと同じく、window.history.pushStateを使うか否かを評価している
      this._wantsPushState  = !!this.options.pushState;
      this._hasPushState    = !!(this.history && this.history.pushState);
      this._usePushState    = this._wantsPushState && this._hasPushState;

      // 現在のフラグメントの文字列を取得している
      this.fragment         = this.getFragment();

      // Normalize root to always include a leading and trailing slash.
      this.root = ('/' + this.root + '/').replace(rootStripper, '/');

      // Transition from hashChange to pushState or vice versa if both are
      // requested.
      if (this._wantsHashChange && this._wantsPushState) {

        // If we've started off with a route from a `pushState`-enabled
        // browser, but we're currently in a browser that doesn't support it...
        if (!this._hasPushState && !this.atRoot()) {
          var root = this.root.slice(0, -1) || '/';
          this.location.replace(root + '#' + this.getPath());
          // Return immediately as browser will do redirect to new url
          return true;

        // Or if we've started out with a hash-based route, but we're currently
        // in a browser where it could be `pushState`-based instead...
        } else if (this._hasPushState && this.atRoot()) {
          this.navigate(this.getHash(), {replace: true});
        }

      }

      // pushStateもhashChangeも使わない・使えない場合はiframeを使って履歴管理を行う?
      if (!this._hasHashChange && this._wantsHashChange && !this._usePushState) {
        this.iframe = document.createElement('iframe');
        this.iframe.src = 'javascript:0';
        this.iframe.style.display = 'none';
        this.iframe.tabIndex = -1;
        var body = document.body;
        // Using `appendChild` will throw on IE < 9 if the document is not ready.
        var iWindow = body.insertBefore(this.iframe, body.firstChild).contentWindow;
        iWindow.document.open();
        iWindow.document.close();
        iWindow.location.hash = '#' + this.fragment;
      }

      // これはaddEventListenerのクロスブラウザ対応の書き方
      // addEventListenerが対応していないブラウザの場合はattachEventが実行される
      // IE8以前のやつの対応になる 
      var addEventListener = window.addEventListener || function (eventName, listener) {
        return attachEvent('on' + eventName, listener);
      };

      // pushStateを使えるばあい(使いたい場合)はpopstateにイベントを登録してURLを確認させてルーターのcallbackを実行させる
      // pushStateを使わない場合はhashchangeにイベントを登録させてURLを確認させてルーターのcallbackを実行させる
      // ↑の両方とも使わない場合(hashchangeっぽいことはやりたい)場合はポーリングさせ、URLを確認させてルーターのcallbackを実行させる
      // 要はpopStateによる、URLの検知方法を優先して使います
      if (this._usePushState) {
        addEventListener('popstate', this.checkUrl, false);
      } else if (this._useHashChange && !this.iframe) {
        addEventListener('hashchange', this.checkUrl, false);
      } else if (this._wantsHashChange) {
        this._checkUrlInterval = setInterval(this.checkUrl, this.interval);
      }

      // start時にsilentオプションを設定できる、これは現在のfragmentの情報を元に即時にRouterのcallbackを実行するかどうかを制御するためのものである。
      // デフォルトではundefinedになっているので即時実行される。
      if (!this.options.silent) return this.loadUrl();
    },

    // Backbone.historyによる、URLの変更検知を止める
    // 何かしらの色々なイベントやタイマーを止めるだけで特に特別なことはしていない
    stop: function() {

      // addEventListenerの時と同じくIE8対応
      var removeEventListener = window.removeEventListener || function (eventName, listener) {
        return detachEvent('on' + eventName, listener);
      };

      // Remove window listeners.
      if (this._usePushState) {
        removeEventListener('popstate', this.checkUrl, false);
      } else if (this._useHashChange && !this.iframe) {
        removeEventListener('hashchange', this.checkUrl, false);
      }

      // Clean up the iframe if necessary.
      if (this.iframe) {
        document.body.removeChild(this.iframe);
        this.iframe = null;
      }

      // Some environments will throw when clearing an undefined interval.
      if (this._checkUrlInterval) clearInterval(this._checkUrlInterval);

      History.started = false;
    },

    // ルーティングの情報.handlersに保存する。
    // 基本的にはBackbone.Routerのコンストラクタによって実行されて、Backbone.Routerで定義されているルーティング情報が登録されると思っておけば良い
    // 要はBackbone.historyがルーティング情報を持つことになるので、結局Backbone.Routerは別に使わなくたっていい(routeイベントが発行されなくなるだけである)
    route: function(route, callback) {
      this.handlers.unshift({route: route, callback: callback});
    },

    // 現在のURLを元にルーターに登録されているcallbackを実行するためのメソッド
    checkUrl: function(e) {

      // 現在のフラグメントを取得
      var current = this.getFragment();

      // If the user pressed the back button, the iframe's hash will have
      // changed and we should use that for comparison.
      if (current === this.fragment && this.iframe) {
        current = this.getHash(this.iframe.contentWindow);
      }

      if (current === this.fragment) return false;
      if (this.iframe) this.navigate(current);
      this.loadUrl();
    },

    // フラグメントの文字列を元にルーターにcallbackが存在するかを調べてマッチした場合にcallbackを実行する
    // ルーティング情報はすべて正規表現のオブジェクトとしての検索キーを所有しているので、それを使ってフラグメントの文字列を評価する
    loadUrl: function(fragment) {
      // If the root doesn't match, no routes can match either.
      if (!this.matchRoot()) return false;

      // ここでBackbone.history.fragmentを更新している
      fragment = this.fragment = this.getFragment(fragment);
      return _.some(this.handlers, function(handler) {
        // 正規表現オブジェクトを使ってルーティングが存在するかを評価
        if (handler.route.test(fragment)) {
          handler.callback(fragment);
          return true;
        }
      });
    },

    // フラグメントの内容をhistoryに保存させ、loadUrlを実行し、Routerに登録されているcallbackを実行する
    // 保存するフラグメント(URLを決定させる文字列)とoptionsを引数に受ける
    // optionsに設定できる項目
    // trigger: Routerに登録されているcallbackを最終的に実行するかどうかを評価する
    // replace: this.history.replaceState, this.history.pushStateのどっちを利用するかを決定することができる。要は上書きか・追加か
    navigate: function(fragment, options) {
      if (!History.started) return false;

      // optionsがtrueの場合は{trigger: true}に変換される
      // optionsがundefinedの場合は{trigger: false}に変換される
      if (!options || options === true) options = {trigger: !!options};

      // 現在のフラグメントを取得
      fragment = this.getFragment(fragment || '');

      // Don't include a trailing slash on the root.
      var root = this.root;
      if (fragment === '' || fragment.charAt(0) === '?') {
        root = root.slice(0, -1) || '/';
      }
      var url = root + fragment;

      // Strip the hash and decode for matching.
      fragment = this.decodeFragment(fragment.replace(pathStripper, ''));

      // fragmentに更新がない場合(URLのハッシュに変更がない場合)
      // は何も処理をせずに終了する
      if (this.fragment === fragment) return;
      this.fragment = fragment;

      // If pushState is available, we use it to set the fragment as a real URL.
      if (this._usePushState) {

        // options.replaceの内容によって上書きでのhistory保存か、追加かを決定し、実行する
        this.history[options.replace ? 'replaceState' : 'pushState']({}, document.title, url);

      // If hash changes haven't been explicitly disabled, update the hash
      // fragment to store history.
      } else if (this._wantsHashChange) {

        // pushStateを利用しないでhashchangeでやりたい場合はURLのハッシュを上書きし、iframeを使っている場合はiframeのページの切り替えを行い、履歴を辿れるようにしている
        this._updateHash(this.location, fragment, options.replace);
        if (this.iframe && (fragment !== this.getHash(this.iframe.contentWindow))) {
          var iWindow = this.iframe.contentWindow;

          // Opening and closing the iframe tricks IE7 and earlier to push a
          // history entry on hash-tag change.  When replace is true, we don't
          // want this.
          if (!options.replace) {
            iWindow.document.open();
            iWindow.document.close();
          }

          this._updateHash(iWindow.location, fragment, options.replace);
        }

      // If you've told us that you explicitly don't want fallback hashchange-
      // based history, then `navigate` becomes a page refresh.
      } else {
        // pushState, hashchangeのどっちも対応していない場合は単純にリダイレクトさせる
        return this.location.assign(url);
      }

      // triggerオプションが指定されている場合はfragmentに対応したルーターのcallbackを実行することになる
      if (options.trigger) return this.loadUrl(fragment);
    },

    // URLのハッシュの書き換えを行う
    _updateHash: function(location, fragment, replace) {
      if (replace) {
        var href = location.href.replace(/(javascript:|#).*$/, '');
        location.replace(href + '#' + fragment);
      } else {
        // Some browsers require that `hash` contains a leading #.
        location.hash = '#' + fragment;
      }
    }

  });

  // Create the default Backbone.history.
  // ここでもうHistoryのインスタンスを作っているので
  // Backbone.historyを扱う場合はBackbone.historyの参照を利用すべき
  Backbone.history = new History;


  • Backbone.History難しすぎる
  • Backbone.RouterとBackbone.Historyはセットで考えたほうが良い
  • pushStateによって履歴を管理するが、pushStateを使えない環境ではiframeを使うようになっている?
  • hashchangeのイベントを元にフラグメントの変更を検知してルーティングを切り替えるが、hashchangeを使えない環境だと、ポーリングさせてフラグメントの変更を検知する
  • 同一フラグメントへのページ遷移はページ遷移とはみなさない
  • ルーティング確立時にはBackbone.historyの【route】イベントを発行する
  • addEventListenerの書き方がイケてる

感想

Backbone.Historyが環境依存とまじめに付き合っていて、JavaScriptのフレームワークって大変なんやなと感じた

3
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
5