Router Basics - Full Stack Web Development with Backbone.js (2014)

Full Stack Web Development with Backbone.js (2014)

Chapter 4. Router Basics

In the previous chapter, we tracked mouse clicks to select movies. We also discussed how changes in models and collections can notify Backbone.Views. Yet, the state of a model or collection was invisible from the outside.

Referencing state across the Web is very important, however. Links are one of the main drivers behind hypertext media. How can we let users share their browser states with other users? And how can Backbone.js support us with “deep” linking?

Addressability of state is provided by Backbone.Router, and this is the scope for this chapter. Additionally, we will render details of a movie and see how the router orchestrates the setup of views.

In summary, we will discuss the following topics:

§ Using a router for navigating between states

§ Sharing a layout across routes

§ Rendering child views

Addressing State

With Backbone views, we were able to trigger state changes in a Backbone collection. Now let’s look at another way to select movies: by using routes. In an abstract sense, both a router and a view are similar in controlling state changes.

The goal of a Movies router is to provide a mapping from the URL for movies to an application state (e.g., a selected movie).

Users will then be able to share details of The Artist with a friend, or simply bookmark the URL for later:

http://example.com/#movies/the-artist

Note the hash in the URL. The hash (or sometimes hashbang) indicates a separation from server-side and client-side parts of a URL. This break in the URL can cause problems for some use cases, as search engines prefer semantic URLs without hashes or hashbangs.

But with newer browsers that support so-called pushState() from the HTML5 history API, it is also possible to keep semantic URLs:

http://example.com/movies/the-artist

What approach you should use depends on your application. Does your application face search engines? Can your application stack integrate a pushState setup?

For many cases where you want to share content with others, it is advisable to use the new functions around the HTML5 History API. If you want to follow the upcoming examples with pushState enabled, you will need to work with a server process that will deliver index.html for all requested routes.

You could install the pushstate-server project with:

npm install pushstate-server --save

Then you can set up a simple server process with:

var server = require('pushstate-server');

server.start({

port: 5000,

directory: './static'

});

You can run this server with:

$ node server.js

And from here on, you will have the advantage of using semantic URLs.

Besides tracking URL changes, you can use a router to a certain degree to organize views. This chapter shows how to use a Layout view for this purpose.

Preparing

Before entering the router realms, let’s shortly recap the setup we have from the previous chapter.

So far, we build a collection view (MoviesList) that can support users in selecting a movie. The main application made the views and data modules available, such that when you “required” the “app” module, you could play around with the views and data.

Let’s first make a small change in the HTML for the upcoming examples, by moving the index.html file into the static/index.html directory:

<html>

<head>

<script src="/bundle.js"></script>

<link rel="stylesheet" href="/style.css" type="text/css">

</head>

<body>

<a href="/">Home</a>

<div id="movies">

</div>

<script>

</script>

</body>

</html>

If you work with the pushState server, it makes sense to have all static files in the same directory, as you see for the paths of the bundle.js and style.css files.

Also, we clean up the app/main.js file, because most of the application will be loaded from the router:

var Backbone = require('backbone');

var $ = require('jquery-untouched');

Backbone.$ = $;

$(document).ready(function() {

console.log('Init app ...');

});

To start the app as soon as it is loaded, you can use a shorter browserify command, leaving out the -r option from earlier:

$ browserify app/main.js > static/bundle.js

Give this setup a try, and we are ready to start.

Defining Routes

To understand what a Backbone router can do, we look at some code next. You should create an app/routers directory first:

$ mkdir app/routers

$ cd app/node_modules

$ ln -sf ../routers

Then, you write the following module in app/routers/movies.js:

var Backbone = require('backbone');

// data

var Movies = require('collections/movies');

var data = require('../../movies.json');

var movies = new Movies(data);

// views

var Movies = require('collections/movies');

var MoviesList = require('views/moviesList');

This is not different so far from other examples. The first router-specific syntax is defining a routes hash, URL fragments that will trigger a callback function. Let’s look at this idea in the second part of app/routers/movies.js:

var MoviesRouter = Backbone.Router.extend({

routes: {

'movies/:id': 'selectMovie',

'': 'showMain'

},

selectMovie: function(id) {

this.moviesList.render();

this.movies.selectByID(id);

},

showMain: function() {

this.moviesList.render();

},

initialize: function() {

this.movies = movies;

this.moviesList = new MoviesList({

el: options.el,

collection: movies

});

}

});

module.exports = MoviesRouter;

In this example, you have defined two routes. The first route matches the pattern /movies/:id. and triggers a callback selectMovie. The second route matches empty routes and triggers the showMain callback. Note how similar the Movies router is to the MoviesList view. Both encapsulate the same steps to set up the views. The approach to manage views in the router will quickly change though.

To see the MoviesRouter in action, you need to tell Backbone to monitor events from URL changes. You do this by adding the following steps in app/main.js:

$(document).ready(function() {

var router = new MoviesRouter({el: $('#movies') });

Backbone.history.start({

pushState: true,

root: '/'

});

});

Monitoring route changes happen by calling start() on the history API. We pass pushState: true to use pushState features. You can use pushState: false, if you prefer to work with hashes in the URL. We set the root property to /, because the Backbone.js application will be the main application. If we wanted the Backbone application only active for browsing search results, we might change the root to /search.

Next, we check that our setup works by changing routes manually in the browser. When you enter:

/movies/1

or, you set:

/

You should be able to select and unselect all movies, just as you did with the mouse clicks earlier. And, from here on, you can share this link, by email, for example.

The URLs can also be linked from movie views. Then, clicking the anchor tag can automatically trigger the movies route, without the need for processing other view callbacks. In the movies view app/views/movie.js, you can edit the template such:

template: '<h1><a href='/movies/<%= id %>'><%= title %></a><hr></h1>'

When you now click on the movie’s title, you should see the URL change as in Figure 4-1.

Users can now share the application state with the help of a URL

Figure 4-1. Users can now share the application state with the help of a URL

Navigating

You can load application states from a URL not only via links in anchor tags, but also from inside the application. For example, the Movie view captures click events and should be able to set the URL of a selected movie.

For this, Backbone.Router provides the navigate function.

For example, in the Movie view, you can call navigate as follows after a movie is selected:

selectMovie: function(ev) {

console.log('event on ' + this.model.id);

if (!this.model.get('selected')) {

this.model.collection.resetSelected();

this.model.collection.selectByID(this.model.id);

this.router.navigate("/movies/" + this.model.id);

}

}

The navigate function accepts an option hash. By passing {trigger: true}, the code in the router is executed after the URL is updated. Like this, you could share the same code between router and view:

this.router.navigate("movies/" + this.model.id, {trigger: true});

There is another option that might be useful: say you want to keep the application state changes private from the browser history. This is interesting, for example, if a user browses tens or hundreds of movies, as she should be able to go back to the beginning with one click on the browser Back button. This interaction can be implemented with the replace: true option. Try it out with:

this.router.navigate("movies/" + this.model.id, {trigger: true, replace: true});

As a careful reader, you might have wondered where the this.router reference is set. Good question. The following code is necessary to pass the router as reference into the views. First, you must set a router reference on the MoviesList instance. This works as follows:

initialize: function(options) {

this.movies = movies;

this.moviesList = new MoviesList({

el: options.el,

collection: movies

});

_.extend(this.moviesList, {router: this});

}

Then, you pass the router reference from MoviesList to its children. In the constructor of app/views/moviesList.js, you do the following:

initialize: function(options) {

this.router = options.router;

}

And, when creating the movies item views, you can do this:

var that = this;

var moviesView = this.collection.map(function(movie) {

return (new MovieView({model : movie, router: that.router})).render().el;

});

When you now reload the page, click the movies, and then click Back in the browser—you should be taken back to the inital page where you started.

Orchestrating Views

A router is a common place to set up views of an application. But be careful, as a router can quickly be overloaded with concerns that should be managed elsewhere. To prevent a large router that manages many views, let’s look at a specialized object to set up and hide views.

Preparing for a Layout View

In the example application so far, there was not yet much need to add and remove views. In reality, the situation is different. Depending on the URL state, or on the state of collections and models, views are dynamically added or removed.

To manage views, you have some options again. By default, there is no explicit “controller” in a Backbone application, but you can easily create one. If you prefer to reuse best practices, you can take a look at Backbone Marionette or Chaplin. Both frameworks support a “controller” abstraction out of the box, and links will be mentioned in The Role of Frameworks.

Let’s prepare an application setup where views can easily be added, changed, and removed. To start, you should first hide the construction of views in the router.

Let’s create a app/views/layout.js file to support us with that:

var Backbone = require('backbone');

// import the moviesList

var MoviesList = require('views/moviesList');

var Layout = Backbone.extend({

render: function() {

this.$el.append(this.moviesList.render().el);

return this;

},

initialize: function(options) {

this.moviesList = new MoviesList({

el: options.el,

collection: options.collection,

router: options.router

});

}

});

To hide the view construction in the router, the Layout can construct a view instance including the movies list. In app/views/layout.js, you can add this:

var instance;

Layout.getInstance = function(options) {

if (!instance) {

instance = new Layout({

el: options.el,

router: options.router,

collection: options.router.movies

});

}

return instance;

}

module.exports = Layout;

You can now clean up references to the MoviesList view in the router and proceed with the Layout instance to address view concerns in the router:

initialize: function(options) {

this.movies = movies;

this.layout = Layout.getInstance({

el: '#movies', router: this

});

this.layout.render();

}

This might not look like much of a win yet, but the idea of a layout to manage subviews will become more concrete in the upcoming sections.

Parent and Child Views

Building views with subviews can quickly become complicated. In this section, you are going to learn a simple strategy to render subviews from a parent view.

First, let’s define the parent view in app/views/layout.js:

var _ = require('underscore');

var Backbone = require('backbone');

var Layout = Backbone.View.extend({

template: _.template(' \

<div id="overview"> \

</div> \

<div id="details"> \

</div>')

// ... more to come

});

Here you use the templating engine of Underscore.js, as is common for many Backbone examples. You will learn more on using different view templating engines in Chapter 6. In the template, there are two interesting DOM elements to which we will attach subviews: $("#overview") and$("#details").

Let’s start with the overview on movies, which will be our MoviesList from earlier. In the constructor of app/views/layout.js, we create the views as follows:

initialize: function(options) {

this.overview = new MoviesList({

collection: options.router.movies,

router: options.router

});

this.currentDetails = new ChoseView();

}

Note how we leave out the el properties for the this.overview and this.currentDetails subviews for now. The references to the DOM will be made when we render the layout.

The render function of the Layout view is the place where we bring in the DOM references as follows:

render: function() {

this.$el.html(this.template());

this.currentDetails.setElement(this.$('#details')).render();

this.overview.setElement(this.$('#overview')).render();

return this;

}

By using setElement, you prevent destroying elements in the DOM and reuse existing DOM nodes. As this.currentDetails and this.overview are Backbone views, you can re-render these after the initial DOM nodes are created by the Layout template.

How can we now update these subviews from the router? In the layout app/views/layout.js, you can add some small helper to set a new DetailsViews as needed, and re-render the parent. For this, you use the following:

setDetails: function(movie) {

if (this.currentDetails) this.currentDetails.remove();

this.currentDetails = new DetailsView({model: movie});

this.render();

}

Similarly, you can add a helper for a “chose” view in app/views/layout.js when you don’t want to show details of a movie:

setChose: function() {

if (this.currentDetails) this.currentDetails.remove();

this.currentDetails = new ChoseView();

this.render();

},

To prevent memory leakage in the application, it is important to remove an old view. Backbone supports removing view with remove().

After having defined this layout view including its helpers, you surely can’t wait to see the rendering of a DetailsView in action. For this, you add the following view to app/views/details.js:

var Backbone = require('backbone');

var _ = require('underscore');

var DetailsView = Backbone.View.extend({

el: '#details',

template: _.template('<%= showtime %> <br> <%= description %>'),

render: function() {

this.$el.html(this.template(this.model.toJSON()));

return this;

}

});

module.exports = DetailsView;

To see the view switching in action, you can now run the setDetails function from the router app/routers/movies.js:

selectMovie: function(id) {

this.movies.resetSelected();

this.movies.selectByID(id);

this.layout.setDetails(this.movies.get(id));

}

By extending the data in movies.json with showtimes and descriptions, you should be able to click your way through the movies program, as shown in Figure 4-2.

The router now calls the Layout for any significant view updates

Figure 4-2. The router now calls the Layout for any significant view updates

As a minor additional detail, you might want to welcome new visitors with a welcome view. The layout can take care of this as well:

var Backbone = require('backbone');

var ChoseView = Backbone.View.extend({

template: '<h1>Welcome to Munich Cinema</h1>\

<h2>Please choose a movie</h2>',

className: 'details',

render: function() {

this.$el.html(this.template);

return this;

}

});

module.exports = ChoseView;

And, you can add a reference in the router, too:

showMain: function() {

this.movies.resetSelected();

this.layout.setChose();

}

With the live example at http://pipefishbook.com/ch_4/subviews, visitors and your project manager might be happy about the interface for selecting and browsing movies. But technically, there is more to come. For example, how do you improve browsing the movies with filters and sorting? How do you create view templates?

We will address these questions soon, but for now, it’s worth mentioning the following Backbone plug-ins that can help with managing complicated views:

§ Backbone.Subviews

§ Backbone.Assembler

§ Backbone.LayoutManager

§ Backbone.Viewmaster

§ Backbone.XView

Conclusion

This chapter gave you an overview on state changes by using the URL in the browser. The URL is an important source for application state, and we can monitor and write the URL in the browser with the help of the Backbone.Router.

The router is also an important place to set up the layout of the user interface. You first learned how to use the singleton pattern to refer a view layout. You then have filled the layout with details of a movie.

So far, our example application is managing only three movies, but in real-world applications, we often deal with much more data. That is the goal of the next chapter, where we will look closer at setting up an API and introduce a Backbone plug-in to boost data transformations.