Implementing Routing
In this chapter, we'll take a brief look at the history of JavaScript web apps so we can understand the need for routing. We'll then examine how Marionette aims to solve the issues we uncover.
History
One of the main benefits to building a JavaScript application is showing/hiding
views based on user input without lengthy calls to the server. For example, when
choosing an item from a list, we can just render that item based on information
stored in a Collection
without making any lengthy calls to the server to fetch
data we have in our browser already. Over time this method has been successfully
used in many applications, reducing server load and increasing the
responsiveness of our web applications.
However, a major downside of this method has historically been the inability to get back to that page. You'll undoubtedly remember the frustration of accidentally clicking back, refreshing a page, or closing your browser only to get sent to the home page and have to find your way back to the screen you were working on, or that interesting article you were reading.
Solutions
Over the years different methods were tried, including having the server try to remember your position in an application, mostly with poor results, bugs, complex code, inability to bookmark or share a post, and many other issues you have likely encountered. What we really wanted was to just use the trusted browser URL in our applications.
Twitter popularized the use of the fragment - the portion of the URL after #
-
to store information about the current location. This part of the URL isn't read
by the server but is understood by web browsers to jump to certain sections of
a page. It is also the part of the window.location
object that we can change
without causing the browser to change the whole page from underneath us. When we
display some information we may want to retrieve later, we could simply do:
window.location.hashCode = 'newLocation';
and the fragment would be updated.
When we want to retrieve the data on page load, we can read the value of
window.location.hashCode
and map it to the code we want to execute to retrieve
and render the information referenced by the fragment.
Routing in Marionette
The Marionette AppRouter
is an attempt to abstract over this behavior and
provide a URL routing engine that mirrors those found in the more mature
server-side web frameworks such as those provided by Django, Flask, and Ruby on
Rails.
There are two aspects to routing in Marionette:
- Set the fragment for state to restore later.
- Render the view matching the fragment on page load.
Setting the fragment
To remember a state that we want to come back to later, we simply call:
Backbone.history.navigate('route/to/restore');
to update the fragment. The key
thing we need to remember is that this is all navigate
does! We must not
call navigate
if we want to run code from our router, we'll see how to do this
in the Router and Controller section below.
Router and Controller
Routing in Marionette is split into two connected parts: a router and a controller. The router takes a map of URL fragments to listen for and maps them to method names on an attached controller to call. The controller is a simple object with matching methods to be called by the router.
The Marionette class used is called the AppRouter
. To use it we simply attach
an appRoutes
object to map the routes to methods:
var Router = Marionette.AppRouter.extend({
appRoutes: {
'blog/': 'blogList',
'blog/:entry': 'blogEntry',
'blog/:entry/comments/:comment': 'blogComment'
}
});
module.exports = Router;
The methods referenced in appRoutes
must exist on an attached controller. We
can attach this controller in a number of ways which
we'll explore shortly.
What happens now?
The router watches the fragment when the page loads, and calls the mapped mathod
for that route. For example, http://example.com#blog/
will, once the router is
initialized, call blogList
for us.
The other two routes have variables in their URLs (entry
and comment
) which
we pass into the mapped methods. Some examples:
http://example.com#blog/3
will callblogEntry('3')
http://example.com#blog/5/comments/32
will callblogComment('5', '32')
http://example.com#blog/my-title/comments/2015-02-11
will callblogComment('my-title', '2015-02-11')
As we learned above, the controller's methods will not be called when
Backbone.history.navigate
is called. For example,
Backbone.history.navigate('blog/5/comments/32')
will set the fragment to
#blog/5/comments/32
and that's it.
Attaching a Controller
Once you've created your AppRouter
you'll need to
attach a controller
to it before any routes will be activated.
To attach a controller, simply extend Marionette.Object
with the method names
matching the values of appRoutes
and attach an instance to our
AppRouter.controller
attribute:
var Controller = Marionette.Object.extend({
blogList: function() {
// ...
},
blogEntry: function(entry) {
// ...
},
blogComment: function(entry, comment) {
}
});
var Router = Marionette.AppRouter.extend({
controller: new Controller(),
appRoutes: {
// ...
}
});
A simple app
Now we have all the building blocks in place, we can look at a simple app that
uses a single region and manages which view is being rendered using routes.
As is common in many applications, the Controller
is going to be the core of
our app, initializing our layouts and handling rendering.
We'll just show the Router and View here. For the full app, visit the appendix.
Our Router
Our router and controller will be stored in router.js
and looks like:
var LayoutView = require('./blog');
var Controller = Marionette.Object.extend({
initialize: function() {
var layout = new LayoutView();
layout.render();
this.options.layout = layout;
},
blogList: function() {
var layout = this.getOption('layout');
layout.triggerMethod('show:blog:list');
},
blogEntry: function(entry) {
var layout = this.getOption('layout');
layout.triggerMethod('show:blog:entry', entry);
}
})
var Router = Marionette.AppRouter.extend({
appRoutes: {
'blog/': 'blogList',
'blog/:entry': 'blogEntry'
},
controller: new Controller
});
module.exports = Router;
Here we setup our top-level view in the controller, which simply renders by triggering events on our view. The view, described below, then renders the correct view based on the layout.
Our View
In our blog.js
file, we'll just outline the top-level layout. If you want to
see the full application, check out the appendix:
var LayoutView = Marionette.LayoutView.extend({
template: require('./templates/layout'),
regions: {
main: '.app-hook'
},
onShowBlogList: function() {
this.showChildView('main', new BlogListView());
},
onShowBlogEntry: function(entry) {
var model = this.collection.get(entry);
this.showChildView('main', new BlogEntryView({model: model}));
},
/** Called when `BlogEntryView` triggers `show:blog:list` */
onChildviewShowBlogList: function() {
this.triggerMethod('show:blog:list');
Backbone.history.navigate('blog/');
},
/** Called when `BlogListView` triggers `show:blog:entry` */
onChildviewShowBlogEntry: function(entry) {
this.triggerMethod('show:blog:entry', entry);
Backbone.history.navigate('blog/' + entry);
}
});
module.exports = LayoutView;
The key is that the layout can listen to its children, renders its main region
and calls Backbone.history.navigate
to let us know that a URL change occurred.
Our router then, at page load, attempts to match a fragment and triggers the
main layout to render the correct view.
Starting the Router
When you load the page, you'll notice the page hasn't responded to the fragment. There's one last thing we need to do - start the routing framework. Luckily for us, this is quite simple. We just need to call the following method after initializing our application:
Backbone.history.start();
Browser History API
More recently, browser vendors recognized the benefits of this pattern and began working on the Browser History and Push State APIs. These combined the benefits of using the fragment with more natural looking URLs that could be recognized by the server too.
Backbone and Marionette support this API by setting {pushState: true}
in the
options passed to start
like so: Backbone.history.start({pushState: true})
.
The History API is a great way to support natural-looking URLs while still
using a JavaScript-powered SPA. However, it only works if your web server
backend has been designed to support it.