前回の”Backbone.js 1.2.1コードリーディング徹底解体!その2“の続きです!一応今回でbackbone 1.2.1のリーディングに関しては完結です!

見て行く前に一つ社内コードリーディング会でコメントのあったところご紹介。

backbone.jsの冒頭のコードでAMD対応でdefineの中でreturnしてないのに大丈夫なのか?という疑問です。
調べてみたらexportsというものを渡してセットしているので大丈夫だということがわかりました。

さて、今回はRouterから最後までみていきます!Backbone.js 1.2.1のソースコード片手にビールでも飲みながらどうぞ。


全体の構成

まずは全体のセクションの構成を紹介。

  1. Factory & Module: L10-L32
  2. Setup: L36-L98
  3. Events: L100-L365
  4. Model: L367-L721
  5. Collection: L723-L1152
  6. View: L1154-L1315
  7. Sync: L1317-L1408
  8. Router: L1410-L1509
  9. History: L1511-1813
  10. Helper: L1815-L1869

8. Router: L1410-L1509

  var Router = Backbone.Router = function(options) {
    options || (options = {});
    if (options.routes) this.routes = options.routes;
    this._bindRoutes();
    this.initialize.apply(this, arguments);
  };

  // Cached regular expressions for matching named param parts and splatted
  // parts of route strings.
  var optionalParam = /\((.*?)\)/g;
  var namedParam    = /(\(\?)?:\w+/g;
  var splatParam    = /\*\w+/g;
  var escapeRegExp  = /[\-{}\[\]+?.,\\\^$|#\s]/g;

  // Set up all inheritable **Backbone.Router** properties and methods.
  _.extend(Router.prototype, Events, {

    // Initialize is an empty function by default. Override it with your own
    // initialization logic.
    initialize: function(){},

    // Manually bind a single named route to a callback. For example:
    //
    //     this.route('search/:query/p:num', 'search', function(query, num) {
    //       ...
    //     });
    //
    route: function(route, name, callback) {
      if (!_.isRegExp(route)) route = this._routeToRegExp(route);
      if (_.isFunction(name)) {
        callback = name;
        name = '';
      }
      if (!callback) callback = this[name];
      var router = this;
      Backbone.history.route(route, function(fragment) {
        var args = router._extractParameters(route, fragment);
        if (router.execute(callback, args, name) !== false) {
          router.trigger.apply(router, ['route:' + name].concat(args));
          router.trigger('route', name, args);
          Backbone.history.trigger('route', router, name, args);
        }
      });
      return this;
    },

    // Execute a route handler with the provided parameters.  This is an
    // excellent place to do pre-route setup or post-route cleanup.
    execute: function(callback, args, name) {
      if (callback) callback.apply(this, args);
    },

    // Simple proxy to `Backbone.history` to save a fragment into the history.
    navigate: function(fragment, options) {
      Backbone.history.navigate(fragment, options);
      return this;
    },

    // Bind all defined routes to `Backbone.history`. We have to reverse the
    // order of the routes here to support behavior where the most general
    // routes can be defined at the bottom of the route map.
    _bindRoutes: function() {
      if (!this.routes) return;
      this.routes = _.result(this, 'routes');
      var route, routes = _.keys(this.routes);
      while ((route = routes.pop()) != null) {
        this.route(route, this.routes[route]);
      }
    },

    // Convert a route string into a regular expression, suitable for matching
    // against the current location hash.
    _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]*))?$');
    },

    // Given a route, and a URL fragment that it matches, return the array of
    // extracted decoded parameters. Empty or unmatched parameters will be
    // treated as `null` to normalize cross-browser behavior.
    _extractParameters: function(route, fragment) {
      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;
      });
    }

  });

ルーター自身は思ったよりコードが少なく、regexでパターンにマッチしたものをrouteしています。

  • L1419: 初期化時に受け取ったroutesを自身にセットして_bindRoutesを呼ぶ
  • L1443: URLパターンとそのcallbackの登録するroute。route直接呼ぶ時はregexで渡せるんですね。通常初期化時にroutesに渡す時のパターンは文字列で渡すと思うのです、それらはthis._routeToRegExpを通ってregexに変換されます。そのあと、次のセクションで紹介するBackbone.history.routeへrouteパターンとcallbackを登録します。
  • L1453: ここのexecuteでrouteに登録していたcallbackを実行する(falseを返すとtriggerしないらしいが、executeのreturnがないのでtriggerがスキップされることはない気がする)
  • L1454: triggerの実行。この行はapplyで”route:” prefixでroutesで定義したnameを第一引数に追加して発火してますが、thisのスコープはroute自身なので、なんで次の行のように普通にrouter.trigger(‘route’, name, args)としなかったのだろうか。。(nameを第二引数に渡すかどうかの違いしかないように見えますが)
  • L1478: routesにセットしたobject/functionをkeyをとってiterateしてrouteを呼びます
  • L1501: regexのrouteを評価してマッチした部分をそれぞれのdecodeURIComponentかけてリストを返しています。ふむふむ。

9. History: L1511-1813

backbone最後の難関Historyです。2セクションに区切って見ていきます。

  var History = Backbone.History = function() {
    this.handlers = [];
    _.bindAll(this, 'checkUrl');

    // Ensure that `History` can be used outside of the browser.
    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 = /#.*$/;

  // Has the history handling already been started?
  History.started = false;

  // Set up all inheritable **Backbone.History** properties and methods.
  _.extend(History.prototype, Events, {

    // The default interval to poll for hash changes, if necessary, is
    // twenty times a second.
    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;
    },

    // Get the cross-browser normalized URL fragment from the path or hash.
    getFragment: function(fragment) {
      if (fragment == null) {
        if (this._usePushState || !this._wantsHashChange) {
          fragment = this.getPath();
        } else {
          fragment = this.getHash();
        }
      }
      return fragment.replace(routeStripper, '');
    },

    // Start the hash change handling, returning `true` if the current URL matches
    // an existing route, and `false` otherwise.
    start: function(options) {
      if (History.started) throw new Error('Backbone.history has already been started');
      History.started = true;

      // Figure out the initial configuration. Do we need an iframe?
      // Is pushState desired ... is it available?
      this.options          = _.extend({root: '/'}, this.options, options);
      this.root             = this.options.root;
      this._wantsHashChange = this.options.hashChange !== false;
      this._hasHashChange   = 'onhashchange' in window;
      this._useHashChange   = this._wantsHashChange && this._hasHashChange;
      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});
        }

      }

      // Proxy an iframe to handle location events if the browser doesn't
      // support the `hashchange` event, HTML5 history, or the user wants
      // `hashChange` but not `pushState`.
      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;
      }

      // Add a cross-platform `addEventListener` shim for older browsers.
      var addEventListener = window.addEventListener || function (eventName, listener) {
        return attachEvent('on' + eventName, listener);
      };

      // Depending on whether we're using pushState or hashes, and whether
      // 'onhashchange' is supported, determine how we check the URL state.
      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);
      }

      if (!this.options.silent) return this.loadUrl();
    },

    // Disable Backbone.history, perhaps temporarily. Not useful in a real app,
    // but possibly useful for unit testing Routers.
    stop: function() {
      // Add a cross-platform `removeEventListener` shim for older browsers.
      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;
    },

    // Add a route to be tested when the fragment changes. Routes added later
    // may override previous routes.
    route: function(route, callback) {
      this.handlers.unshift({route: route, callback: callback});
    },

    // Checks the current URL to see if it has changed, and if it has,
    // calls `loadUrl`, normalizing across the hidden iframe.
    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();
    },

    // Attempt to load the current URL fragment. If a route succeeds with a
    // match, returns `true`. If no defined routes matches the fragment,
    // returns `false`.
    loadUrl: function(fragment) {
      // If the root doesn't match, no routes can match either.
      if (!this.matchRoot()) return false;
      fragment = this.fragment = this.getFragment(fragment);
      return _.any(this.handlers, function(handler) {
        if (handler.route.test(fragment)) {
          handler.callback(fragment);
          return true;
        }
      });
    },
  • L1521: _.bindAllはまとめてbindする用の便利メソッド
  • L1523: “Ensure that `History` can be used outside of the browser”とコメントがありますが、ブラウザ外でhistoryを使うユースケースがぱっと思い浮かばない。
  • L1588: path.charAt(0) === ‘/’ ? path.slice(1) : path; 初めの/とる1行コード。
  • L1613: パラメーターが色々でてきますが、基本的にはhashchange方式でいくか、history APIのpushState方式でいくかのためのフラグのセットです。wantsXxxがユーザーがoptionsで使用したいと指定しているか、hasXxxが環境が使用可能か、useXxxは実際に使うかどうか、それぞれフラグを表しています。ここで少しトリッキーなのが、this.options.hashChange !== falseの部分。hashChangeをoptionsで明示しない時はundefinedなので、その時はwantsHashChangeがtrueになります。一方wantsPushStateの方は!!this.options.pushStateなので、undefinedだとfalseになります。これで明示しなかった時のデフォルトはhashChangeになります。(ちなみに両方falseで明示するのはおそらく想定外で何もhistoryは機能しなくなります)
  • L1614: ‘onhashchange’ in window;この記述!!window[‘onhashchange’]と何が違うんだろうと思ったら、実はwindow.onhashchangeだとnullが返ることがわかりました。不思議ですが、enumerableがtrueでgetterでnull返せばそういうプロパティは作れそうですね。
  • L1627: ちょっと複雑ですが、_wantsHashChangeと_wantsPushState両方がtrueの時に、ブラウザが実はpushStateをサポートしていなかったら、hashのURLに切り替えて再度初めから処理。pushStateが使える環境かつhashがURLについている場合は逆に、hashの値からpushStateのURLに切り替えてます。
  • L1647: ここはクロスブラウザの互換の辛いところ。古いIEでhashchangeが使えない環境では、iframeを使ったhackがあるようです。iframeを生成して、contentWindowのdocument.open()とdocumnet.close()を呼んでhashを変更するとhistoryに追加されるらしいです…。なので、後述のpollingとiframeを組み合わせれば、historyの操作とhashchangeを検知できて動作するようです。
  • L1672: 上記の古いIE環境などではonhashchangeイベントが呼ばれないので、なんとintervalでpollingしてます。
  • L1707: Routerが呼んだrouteでcallbackを渡されて、それをhistoryのhandlers.unshiftしています。これはloadUrlの中で_.anyを使ってcallbackを呼んだ時に、後からhandlerに足したものが前のものをoverrideする仕組みになっている
  • L1729: routeで登録されたcallback(Routerからセットされたもの)を実行しています。呼ばれるタイミングはhashchangeやpopstateやpollingによって。

ここまではhistoryのセットアップと開始の部分でした。
次は実際にURLを切り替えてhistoryに追加したりする部分のnavigateです。いよいよ終わりに近づいてます。

    navigate: function(fragment, options) {
      if (!History.started) return false;
      if (!options || options === true) options = {trigger: !!options};

      // Normalize the fragment.
      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, ''));

      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) {
        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) {
        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 {
        return this.location.assign(url);
      }
      if (options.trigger) return this.loadUrl(fragment);
    },

    // Update the hash location, either replacing the current entry, or adding
    // a new one to the browser history.
    _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.
  Backbone.history = new History;
  • L1770: pushStateを使用している場合は、replaceがtrueだとpushStateの代わりにreplaceStateしてhistoryに追加。
  • L1775: hashChangeの場合は、updateHash(location.replaceもしくはlocation.hashを書き換える)をして、historyに追加。
  • L1776: iframeハックを使う環境の場合は、iframeのlocationに同様のhashの変更を加えてhistoryに追加します。
  • L1795: triggerがtrueの時はRouterでセットしたcallbackを呼びます。

10. Helper: L1815-L1869

extendなどutility的なものが最後に少しありますので軽くコメント。

  var extend = function(protoProps, staticProps) {
    var parent = this;
    var child;

    // The constructor function for the new subclass is either defined by you
    // (the "constructor" property in your `extend` definition), or defaulted
    // by us to simply call the parent constructor.
    if (protoProps && _.has(protoProps, 'constructor')) {
      child = protoProps.constructor;
    } else {
      child = function(){ return parent.apply(this, arguments); };
    }

    // Add static properties to the constructor function, if supplied.
    _.extend(child, parent, staticProps);

    // Set the prototype chain to inherit from `parent`, without calling
    // `parent` constructor function.
    var Surrogate = function(){ this.constructor = child; };
    Surrogate.prototype = parent.prototype;
    child.prototype = new Surrogate;

    // Add prototype properties (instance properties) to the subclass,
    // if supplied.
    if (protoProps) _.extend(child.prototype, protoProps);

    // Set a convenience property in case the parent's prototype is needed
    // later.
    child.__super__ = parent.prototype;

    return child;
  };

  // Set up inheritance for the model, collection, router, view and history.
  Model.extend = Collection.extend = Router.extend = View.extend = History.extend = extend;

  // Throw an error when a URL is needed, and none is supplied.
  var urlError = function() {
    throw new Error('A "url" property or function must be specified');
  };

  // Wrap an optional error callback with a fallback error event.
  var wrapError = function(model, options) {
    var error = options.error;
    options.error = function(resp) {
      if (error) error.call(options.context, model, resp, options);
      model.trigger('error', model, resp, options);
    };
  };

  • L1821: prototypeのmixin(_.extend)だけでは継承にはならないので、正しく継承するため(constructorと__proto__が正しくchainするため)のhelper(有名なgoog.inheritsと同じような実装のようです。)詳しくはこの辺りの記事がわかりやすかったです。
    http://qiita.com/LightSpeedC/items/d307d809ecf2710bd957
  • L1829: Model.extendを呼ぶ時に渡す値がconstructorを持っているクラスである場合そのconstructorをsubclassにセットする -> つまりsubclassのparentはextendを実行しているクラスではなく引数に受け取った方になるということ(?)
  • L1831: 通常のobjectを渡した時には現在のextendを実行しているクラスがparentとなる
  • L1839: この部分ぱっと見よくわからないですが、以下の記事によると、Object.createがサポートされていない環境でのworkaroundだったようです。
    http://stackoverflow.com/questions/16224536/whats-the-advantage-of-goog-inherits-use-of-a-temporary-constructor
    (ちなみにnew Surrogateはnew Surrogate()と同じです。

  • L1858: Syncのところででてきたerrorをthrowするfunction
  • L1863: error callbackとしてoptions.errorで渡されたものをwrapしてtriggerを呼んだりするwrapper

以上!!お疲れ様です。
3回に分けてまとめたBackbone.jsコードリーディングの記事は終了です。
思ったより長かったですかね。ただ、非常に学びがありました。

次は何のリーディングするかなー


最後に再掲。

コードリーディングをしているとメモをとりたかったり、モバイルで見にくかったりして、もっと良いツールが作れるんじゃないかなと思って、MeetAppというサービスで趣味開発の募集しておりますw

OSSコードリーダー
https://meetapp.tokyo/app/detail/8ddf62ee-9ac2-403e-a39c-5fcb059da705


Categories: JS

Related Posts

JS

Facebookログインボタンを設置する方法

Facebookの認証を使用したアプリケーションを作る際に必要なlogin button設置の方針について今回は記事を書きます。 自作のWebアプリケーションでfacebookのログインを実装する方法は大きく2つあります。一つは、”Facebookが提供しているクライアント側のみで認証処理を行うJavaScript SDKを使う方法”。もう一つは、昔ながらの”サーバー側で認証の処理を行い、クライアント側には単なるボタンのリンクを貼る方法”です。 もちろんアプリケーションが提供する機能や実装の都合など、場合に応じて選択するアプローチは異なってくるでしょう。今回はボタンを設置する場合にデザインのカスタマイズや国際化という観点から両者を比較してみます。

JS

非同期処理でのwindow.openはポップアップブロックされる

実はあまり知らなかったですがユーザーアクション(onClick等)の処理の中にajaxやsetTimeoutによる非同期な処理があるとwindow.open()によるpopup blockが起きてしまいます。 何故かブロックされるケースとされないケースがあったので、初めはクロスドメインだとブロックされるのかなと思ったりしていましたが関係なかったようです。 以下、詳細と回避方法。

JS

Vue.jsのcomponentを使ってみた

最近結構流行ってきているようなので、Vue.js使ってみました。 私はangularJSはあまり好きになれず、backbone派なJSエンジニアですが、ちょっとした小規模なWebアプリを作成する際にbackboneは少し煩わしいところがあるのは確かです。 vue.jsはangularをシンプルにしたものというイメージで、学習コストも低く、公式のドキュメントに掲載されているガイドは英語ですが1日あれば全て読めると思います。 これを使えば小規模なwebアプリの開発を効率化できそうな予感がしたので、使用してみました。(MVCとかMVVMとかそういうことを語るつもりはありません。)