Introduction

Modern web applications have many moving parts, and traditional webapp routing is far too restrictive to deal with real-world apps.

A modern webapp may have many independent bits of serializable state that must be correctly transmitted when a URL is sent to another user. For example, a music app may want to send the current song, position within the song, and location within a browsing window. A search app may want to transmit the current query, selected results, expansion of those results, and user preferences.

It is not always possible to store complex state in localStorage or cookies, if you want to transmit that complex state to other users via a URL. It can very quickly become unwieldy to create massive 'multi-routes', where sections of the URL delegate to subrouters. Every time a new widget with state is added, a new section must be added to the route, and all links updated.

Querystrings are a perfect solution to this problem, and with HTML5 pushState, they can easily be used on the client and the server.

Example

var QueryAwareRouter = Backbone.Router.extend({

  // Normal routes definition - this is unchanged.
  routes: {
    ...
  },

  // QueryRoutes are defined here. They are defined in 
  // the format:
  // {String} keys : {String} handlerName
  queryRoutes: {
    // Here you can specify which keys you want to listen to.
    // The attached handler will be fired each time any of 
    // the keys are added, removed, or changed.
    'volume': 'setVolume',
    // To listen to multiple keys, separate them with commas. 
    // Whitespace is ignored.
    'playState, songID' : 'playSong'
  },
  // handler definitions...
});

Annotated Code

45  
46  'use strict';
47  
48  

CommonJS includes. This is a browserify module and will run both inside Node and in the browser.

47  
48  var Backbone = (window && window.Backbone) || require('backbone');
49  var _ = (window && window._) || require('underscore');
50  var querystring = require('qs');
51  var diff = require('deep-diff');
52  
53  
54  

Backbone.History overrides.

Type
Backbone.History
56  
57  var QueryHistory = Backbone.History.extend( /** @lends QueryHistory# **/{
58  
59  
60  

Override history constructor to init some properties and set the embedded query model listener.

Type
Backbone.History
64  
65    constructor: function() {
66      this.previousQuery = {};
67      this.queryHandlers = [];
68      this.listenTo(this.query, 'change', this.onQueryModelChange);
69      Backbone.History.call(this);
70    },
71  
72  
73  

Extracts querystrings from routes.

Type
RegExp
75  
76    queryMatcher: /^([^?]*?)(?:\?([\s\S]*))?$/,
77  
78  
79  

Model emcompassing current query state. You can read and set properties on this Model and Backbone.history.navigate() will automatically be called. If Backbone.NestedModel is loaded, it will be used to support nested change events.

Type
Backbone.Model
83  
84    query: Backbone.NestedModel ? new Backbone.NestedModel() : new Backbone.Model(),
85  
86  
87  

Parse a fragment into a query object and call handlers matching.

Params
fragment String Route fragment.
options Object Navigation options.
90  
91    loadQuery: function(fragment, options) {
92      var query = this._fragmentToQueryObject(fragment);
93      var previous = this.previousQuery;
94  
95  
96  

Save previous query. We intentionally do not use this.query.previousAttributes(), as it can be overwritten by a user set.

96  
97      this.previousQuery = query;
98  
99  
100  

Diff new and old queries.

99  
100      var diffs = this._getDiffs(previous, query);
101      if (!diffs.length) return;
102  
103  
104  

Set embedded model to new query object, firing 'change' events.

103  
104      this.stopListening(this.query, 'change', this.onQueryModelChange);
105      this.resetQuery(query);
106      this.listenTo(this.query, 'change', this.onQueryModelChange);
107  
108  
109  

Call each function that subscribes to these items. This is intentional, rather than fire events on each changed item; this way, you don't have to debounce your handlers since they are only called once, even if multiple query items change.

111  
112      _.each(this.queryHandlers, function(handler) {
113        var intersections = _.intersection(diffs, handler.bindings);
114        if (intersections.length) {
115          handler.callback(fragment, intersections, _.pick(query, intersections));
116        }
117      });
118    },
119  
120  
121  

Compare previous base fragment to current base fragment. If it is the same, do not fire the url handler.

Params
{String} fragment History fragment.
Returns
Boolean True if a route was matched.
125  
126    loadUrl: function(fragment) {
127      if (this._previousBaseFragment !== this._stripQuery(fragment)) {
128        return Backbone.History.prototype.loadUrl.apply(this, arguments);
129      }
130      return false;
131    },
132  
133  
134  

Add loadQuery hook.

Params
fragment String History fragment.
options Object Navigation options.
138  
139    navigate: function(fragment, options) {
140      if (!Backbone.History.started) return false;
141      if (!options) options = {};
142  
143  
144  

Save base fragment for comparison in loadUrl.

143  
144      this._previousBaseFragment = this._stripQuery(this.fragment);
145  
146  
147  

Fire querystring routes.

146  
147      if (options.trigger) {
148        this.loadQuery(fragment, options);
149      }
150  
151  
152  

Call navigate on prototype since we just overrode it.

151  
152      Backbone.History.prototype.navigate.call(this, fragment, options);
153    },
154  
155  
156  

Navigate a base route only, while maintaining the current query. Strips any querystrings from the input fragment and appends the current querystring.

Params
{String} fragment Route fragment.
{Object} options Navigation options.
161  
162    navigateBase: function(fragment, options) {
163      var currentQuery = this._fragmentToQueryString(Backbone.history.fragment);
164      return this.navigate(this._stripQuery(fragment) + "?" + currentQuery, options);
165    },
166  
167  
168  

When the query model changes, run all associated routes.

Params
{Model} model Attached model.
{Object} options Change options.
171  
172    onQueryModelChange: function(model, options) {
173      var fragment = this.fragment || '';
174      var oldQS = querystring.stringify(this.previousQuery);
175      var newQS = querystring.stringify(model.toJSON());
176  
177  
178  

If the old querystring exists, replace it with the new one, otherwise just append.

177  
178      if (oldQS) {
179        fragment = fragment.replace(oldQS, newQS);
180      } else {
181  
182  

No existing querystring, add it directly to the fragment.

181  
182        if (fragment.slice(-1) !== '?') fragment += '?';
183        fragment += newQS;
184      }
185      this.navigate(fragment, {trigger: true});
186    },
187  
188  
189  

Add a query to be tested when the fragment changes.

Params
{Array} bindings Query keys to listen to.
{Function} callback Callback to call when these keys change.
192  
193    queryHandler: function(bindings, callback) {
194      this.queryHandlers.push({bindings: bindings, callback: callback});
195    },
196  
197  
198  

Reset the internal query model to a certain state. Performs set() and unset() internally to reset the model's attributes to the correct state, while firing the correct events. Similar to Backbone.Collection.reset(), but with model attributes rather than models.

Params
queryObject Object New query object.
202  
203    resetQuery: function(queryObject, options) {
204      if (_.isString(queryObject)) {
205        queryObject = this._fragmentToQueryObject(queryObject);
206      }
207      if (!options) options = {};
208      var queryModel = this.query;
209  
210  
211  

Suppresses intermediate 'change' events; 'change:key' will still fire. This has the added benefit of making the internal changed hash actually correct for this operation, which means previousAttributes() and changedAttributes() will actually work correctly.

213  
214      queryModel._changing = true;
215  
216  
217  

Unset any keys inside the existing query. To disable, set {unset: false} in the options.

217  
218      if (options.unset !== false) {
219        _.each(queryModel.attributes, function(attr, key){
220          if (!queryObject[key]) queryModel.unset(key);
221        });
222      }
223  
224  
225  

Set new keys. To disable, set {set: false} in the options.

224  
225      if (options.set !== false) {
226        _.each(queryObject, function(attr, key){
227          queryModel.set(key, attr);
228        });
229      }
230  
231  
232  

Unset changing flag and fire change event.

231  
232      queryModel._changing = false;
233      if (!options.silent && !_.isEmpty(queryModel.changed)){
234        queryModel.trigger('change', queryModel, options);
235      }
236    },
237  
238  
239  

Given two objects, compute their differences and list them. When diffing deep objects, return one string for the object and one for each child. This allows functions to bind to deep properties or its parent. E.g. a change to a.b.c returns ['a', 'a.b', 'a.b.c']

This uses DeepDiff (flitbit/diff), which can detect changes deep within objects. We don't use objects in querystrings quite yet, but we do arrays. And that might change.

Params
{Object} lhs Left hand object.
{Object} rhs Right hand (new) object.
Returns
Array Array of string differences.
254  
255    _getDiffs: function(lhs, rhs) {
256      var diffs = diff(lhs, rhs);
257      var diffKeys = _.reduce(diffs, function(result, diff) {
258        var paths = _.map(diff.path, function(path, i) {
259          return _.first(diff.path, i + 1).join('.');
260        });
261        return result.concat(paths);
262      }, []);
263      return _.uniq(diffKeys);
264    },
265  
266  
267  

Given a fragment, return a query object.

Params
{String} fragment Route fragment.
Returns
Object Query object.
270  
271    _fragmentToQueryObject: function(fragment) {
272      try {
273        return querystring.parse(this._fragmentToQueryString(fragment));
274      } catch(e) {
275        throw new Error("Unable to parse fragment into query object: " + fragment);
276      }
277    },
278  
279  
280  

Given a fragment, return a query string.

Params
{String} fragment Route fragment.
Returns
String Query string.
283  
284    _fragmentToQueryString: function(fragment) {
285      if (!fragment) return '';
286      var match = fragment.match(this.queryMatcher);
287      return match[2] || '';
288    },
289  
290  
291  

Strip a querystring from a fragment.

Params
{String} fragment Route fragment.
Returns
String Fragment without query.
294  
295    _stripQuery: function(fragment) {
296      return fragment ? fragment.split('?')[0] : '';
297    }
298  });
299  
300  var RouterProto = Backbone.Router.prototype;
301  
302  

Backbone.Router overrides.

Type
Backbone.Router
304  
305  var QueryRouter = Backbone.Router.extend(/** @lends QueryRouter# */{
306  
307  

Bind query routes.

Remember that handlers will only fire once per navigation. If for some reason you'd like a handler to fire for each individual change, bind to the 'change:{key}' events on Backbone.history.query, which is just a Backbone.Model (and fires all of the usual events).

They are expected to be attached in the following configuration:

queryRoutes: [
  'key1,key2,key3': 'handlerName',
  'q, sort, rows': function() { // ... },
  'nested.object': 'deepHandler'
]
323  
324    _bindRoutes: function() {
325      if (!this.queryRoutes) return;
326      this.queryRoutes = _.result(this, 'queryRoutes');
327      var qRoute, qRoutes = _.keys(this.queryRoutes);
328      while ((qRoute = qRoutes.pop()) != null) {
329        this.queryHandler(qRoute, this.queryRoutes[qRoute]);
330      }
331      RouterProto._bindRoutes.apply(this, arguments);
332    },
333  
334  
335  

Navigate a base route only, while maintaining the current query. Delegates to Backbone.history.navigateBase.

Params
{String} fragment Route fragment.
{Object} options Navigation options.
339  
340    navigateBase: function(fragment, options) {
341      Backbone.history.navigateBase(fragment, options);
342      return this;
343    },
344  
345  
346  

Bind a queryHandler. Very similar to Backbone.Router#route, except that args are provided by Backbone.history#queryHandler, rather than being extracted in the router from the fragment.

Params
{String|array} bindings Query key bindings.
{String} [name] Listener name.
{Function} callback Listener callback.
352  
353    queryHandler: function(bindings, name, callback) {
354      bindings = this._normalizeBindings(bindings);
355      if (_.isFunction(name)) {
356        callback = name;
357        name = '';
358      }
359      if (!callback) callback = this[name];
360      if (!callback) throw new Error("QueryHandler not found: " + this[name]);
361      var router = this;
362      Backbone.history.queryHandler(bindings, function(fragment, queryKeys, queryObj) {
363        router.execute(callback, [queryKeys, queryObj]);
364        router.trigger.apply(router, ['route:' + name].concat(queryKeys));
365        router.trigger('route', name, queryKeys, queryObj);
366        Backbone.history.trigger('route', router, name, queryKeys, queryObj);
367      });
368      return this;
369    },
370  
371  
372  

Normalize bindings - convert to array and trim whitespace.

Params
{String} bindings Bindings definition.
Returns
Array Normalized bindings.
375  
376    _normalizeBindings: function(bindings) {
377      if (_.isString(bindings)) {
378        bindings = bindings.split(',');
379      }
380      return _.invoke(bindings, 'trim');
381    }
382  });
383  
384  
385  

Override default Backbone.Router constructor.

384  
385  Backbone.Router = QueryRouter;
386  
387  
388  

Replace Backbone.history.

387  
388  Backbone.history = new QueryHistory();
389