Data Visualization with JavaScript (2015)
Chapter 10. Building Data-Driven Web Applications: Part 2
In Chapter 9, we set up the framework of our web application and walked through the visualizations that will be displayed for each view. But before our web application is complete, we have several other details to attend to. First, we have to make the web application communicate with the Nike+ service and account for some quirks specific to that service. Then we’ll work on making our application easier to navigate. In this chapter we’ll look at the following:
§ How to connect application models with an external REST API
§ How to support web browser conventions in a single-page application
Connecting with the Nike+ Service
Although our example application relies on the Nike+ service for its data, we haven’t looked at the details of that service’s interface. As I’ve mentioned, Nike+ doesn’t quite conform to common REST API conventions that application libraries such as Backbone.js expect. But Nike+ isn’t very unusual in that regard. There really isn’t a true standard for REST APIs, and many other services take approaches similar to Nike+’s. Fortunately Backbone.js anticipates this variation. As we’ll see in the following steps, extending Backbone.js to support REST API variations isn’t all that difficult.
Step 1: Authorize Users
As you might expect, Nike+ doesn’t allow anyone on the Internet to retrieve details for any user’s runs. Users expect at least some level of privacy for that information. Before our app can retrieve any running information, therefore, it will need the user’s permission. We won’t go into the details of that process here, but its result will be an authorization_token. This object is an arbitrary string that our app will have to include with every Nike+ request. If the token is missing or invalid, Nike+ will deny our app access to the data.
Up until now we’ve let Backbone.js handle all of the details of the REST API. Next, we’ll have to modify how Backbone.js constructs its AJAX calls. Fortunately, this isn’t as tricky as it sounds. All we need to do is add a sync() method to our Runs collection. When a sync() method is present in a collection, Backbone.js calls it whenever it makes an AJAX request. (If there is no such method for a collection, Backbone.js calls its primary Backbone.sync() method.) We’ll define the new method directly in the collection.
Running.Collections.Runs = Backbone.Collection.extend({
sync: function(method, collection, options) {
// Handle the AJAX request
}
As you can see, sync() is passed a method (GET, POST, etc.), the collection in question, and an object containing options for the request. To send the authorization token to Nike+, we can add it as a parameter using this options object.
sync: function(method, collection, options) {
options = options || {};
_(options).extend({
data: { authorization_token: this.settings.authorization_token }
});
Backbone.sync(method, collection, options);
}
The first line in the method makes sure that the options parameter exists. If the caller doesn’t provide a value, we set it to an empty object ({}). The next statement adds a data property to the options object using the extend() utility from Underscore.js. The data property is itself an object, and in it we store the authorization token. We’ll look at how to do that next, but first let’s finish up the sync() method. Once we’ve added the token, our request is a standard AJAX request, so we can let Backbone.js take it from there by calling Backbone.sync().
Now we can turn our attention to the settings object from which our sync() method obtained the authorization token. We’re using that object to hold properties related to the collection as a whole. It’s the collection’s equivalent of a model’s attributes. Backbone.js doesn’t create this object for us automatically, but it’s easy enough to do it ourselves. We’ll do it in the collection’s initialize() method. That method accepts two parameters: an array of models for the collection, and any collection options.
Running.Collections.Runs = Backbone.Collection.extend({
initialize: function(models, options) {
this.settings = { authorization_token: "" };
options = options || {};
_(this.settings).extend(_(options)
.pick(_(this.settings).keys()));
},
The first statement in the initialize() method defines a settings object for the collection and establishes default values for that object. Since there isn’t an appropriate default value for the authorization token, we’ll use an empty string.
The next statement makes sure that the options object exists. If none is passed as a parameter, we’ll at least have an empty object.
The final statement extracts all the keys in the settings, finds any values in the options object with the same keys, and updates the settings object by extending it with those new key values. Once again, we take advantage of some Underscore.js utilities: extend() and pick().
When we first create the Runs collection, we can pass the authorization token as a parameter. We supply an empty array as the first parameter because we don’t have any models for the collection. Those will come from Nike+. In the following code fragment, we’re using a dummy value for the authorization token. A real application would use code that Nike provides to get the true value.
var runs = new Running.Collections.Runs([], {
authorization_token: "authorize me"
});
With just a small bit of extra code, we’ve added the authorization token to our AJAX requests to Nike+.
Step 2: Accept the Nike+ Response
When our collection queries Nike+ for a list of user activities, Backbone.js is prepared for a response in a particular format. More specifically, Backbone.js expects the response to be a simple array of models.
[
{ "activityId": "2126456911", /* Data continues... */ },
{ "activityId": "2125290225", /* Data continues... */ },
{ "activityId": "2124784253", /* Data continues... */ },
// Data set continues...
]
In fact, however, Nike+ returns its response as an object. The array of activities is one property of the object.
{
"data": [
{ "activityId": "2126456911", /* Data continues... */ },
{ "activityId": "2125290225", /* Data continues... */ },
{ "activityId": "2124784253", /* Data continues... */ },
// Data set continues...
],
// Response continues...
}
To help Backbone.js cope with this response, we add a parse() method to our collection. The job of that function is to take the response that the server provides and return the response that Backbone.js expects.
Running.Collections.Runs = Backbone.Collection.extend({
parse: function(response) {
return response.data;
},
In our case, we just return the data property of the response.
Step 3: Page the Collection
The next aspect of the Nike+ API we’ll tackle is its paging. When we request the activities for a user, the service doesn’t normally return all of them. Users may have thousands of activities stored in Nike+, and returning all of them at once might overwhelm the app. It could certainly add a noticeable delay, as the app would have to wait for the entire response before it could process it. To avoid this problem, Nike+ divides user activities into pages, and it responds with one page of activities at a time. We’ll have to adjust our app for that behavior, but we’ll gain the benefit of a more responsive user experience.
The first adjustment we’ll make is in our request. We can add parameters to that request to indicate how many activities we’re prepared to accept in the response. The two parameters are offset and count. The offset tells Nike+ which activity we want to be first in the response, while count indicates how many activities Nike+ should return. If we wanted the first 20 activities, for example, we can set offset to 1 and count to 20. Then, to get the next 20 activities, we’d set offset to 21 (and keep count at 20).
We add these parameters to our request the same way we added the authorization token—in the sync() method.
sync: function(method, collection, options) {
options = options || {};
_(options).extend({
data: {
authorization_token: this.settings.authorization_token,
count: this.settings.count,
offset: this.settings.offset
}
});
Backbone.sync(method, collection, options);
}
We will also have to provide default values for those settings during initialization.
initialize: function(models, options) {
this.settings = {
authorization_token: "",
count: 25,
offset: 1
};
Those values will get the first 25 activities, but that’s only a start. Our users will probably want to see all of their runs, not just the first 25. To get the additional activities, we’ll have to make more requests to the server. Once we get the first 25 activities, we can request the next 25. And once those arrive, we can ask for 25 more. We’ll keep at this until either we reach some reasonable limit or the server runs out of activities.
First we define a reasonable limit as another settings value. In the following code, we’re using 10000 as that limit.
initialize: function(models, options) {
this.settings = {
authorization_token: "",
count: 25,
offset: 1,
max: 10000
};
Next we need to modify the fetch() method for our collection since the standard Backbone.js fetch() can’t handle paging. There are three steps in our implementation of the method:
1. Save a copy of whatever options Backbone.js is using for the request.
2. Extend those options by adding a callback function when the request succeeds.
3. Pass control to the normal Backbone.js fetch() method for collections.
Each of those steps is a line in the following implementation. The last one might seem a little tricky, but it makes sense if you take it one piece at a time. The expression Backbone.Collection.prototype.fetch refers to the normal fetch() method of a Backbone.js collection. We execute this method using .call() so that we can set the context for the method to be our collection. That’s the first this parameter of call(). The second parameter holds the options for fetch(), which are just the extended options we created in Step 2.
Running.Collections.Runs = Backbone.Collection.extend({
fetch: function(options) {
this.fetchoptions = options = options || {};
_(this.fetchoptions).extend({ success: this.fetchMore });
return Backbone.Collection.prototype.fetch.call(
this, this.fetchoptions
);
},
By adding a success callback to the AJAX request, we’re asking to be notified when the request completes. In fact, we’ve said that we want the this.fetchMore() function to be called. It’s time to write that function; it, too, is a method of the Runs collection. This function checks to see if there are more activities left. If so, it executes another call to Backbone.js’s regular collection fetch() just as in the preceding code.
fetchMore: function() {
if (this.settings.offset < this.settings.max) {
Backbone.Collection.prototype.fetch.call(this, this.fetchoptions);
}
}
Since fetchMore() is looking at the settings to decide when to stop, we’ll need to update those values. Because we already have a parse() method, and because Backbone calls this method with each response, that’s a convenient place for the update. Let’s add a bit of code before the return statement. If the number of activities that the server returns is less than the number we asked for, then we’ve exhausted the list of activities. We’ll set the offset to the max so fetchMore() knows to stop. Otherwise, we increment offset by the number of activities.
parse: function(response) {
if (response.data.length < this.settings.count) {
this.settings.offset = this.settings.max;
} else {
this.settings.offset += this.settings.count;
}
return response.data;
}
The code we’ve written so far is almost complete, but it has a problem. When Backbone.js fetches a collection, it assumes that it’s fetching the whole collection. By default, therefore, each fetched response replaces the models already in the collection with those in the response. That behavior is fine the first time we call fetch(), but it’s definitely not okay for fetchMore(), which is meant to add to the collection instead of replacing it. Fortunately, we can easily tweak this behavior by setting the remove option.
In our fetch() method, we set that option to true so Backbone.js will start a new collection.
fetch: function(options) {
this.fetchoptions = options = options || {};
_(this.fetchoptions).extend({
success: this.fetchMore,
remove: true
});
return Backbone.Collection.prototype.fetch.call(this,
this.fetchoptions
);
}
Now, in the fetchMore() method, we can reset this option to false, and Backbone.js will add to models instead of replacing them in the collection.
fetchMore: function() {
this.fetchoptions.remove = false;
if (this.settings.offset < this.settings.max) {
Backbone.Collection.prototype.fetch.call(this, this.fetchoptions);
}
}
There is still a small problem with the fetchMore() method. That code references properties of the collection (this.fetchoptions and this.settings), but the method will be called asynchronously when the AJAX request completes. When that occurs, the collection won’t be in context, so this won’t be set to the collection. To fix that, we can bind fetchMore() to the collection during initialization. Once again, an Underscore.js utility function comes in handy.
initialize: function(models, options) {
_.bindAll(this, "fetchMore");
For the final part of this step, we can make our collection a little friendlier to code that uses it. To keep fetching additional pages, we’ve set the success callback for the fetch() options. What happens if the code that uses our collection has its own callback? Unfortunately, we’ve erased that callback to substitute our own. It would be better to simply set aside an existing callback function and then restore it once we’ve finished fetching the entire collection. We’ll do that first in our fetch() method. Here’s the full code for the method:
fetch: function(options) {
this.fetchoptions = options = options || {};
this.fetchsuccess = options.success;
_(this.fetchoptions).extend({
success: this.fetchMore,
remove: true
});
return Backbone.Collection.prototype.fetch.call(this,
this.fetchoptions
);
}
And here’s the code for fetchMore():
fetchMore: function() {
this.fetchoptions.remove = false;
if (this.settings.offset < this.settings.max) {
Backbone.Collection.prototype.fetch.call(this, this.fetchoptions);
} else if (this.fetchsuccess) {
this.fetchsuccess();
}
}
Now we can execute that callback in fetchMore() when we’ve exhausted the server’s list.
Step 4: Dynamically Update the View
By fetching the collection of runs in pages, we’ve made our application much more responsive. We can start displaying summary data for the first 25 runs even while we’re waiting to retrieve the rest of the user’s runs from the server. To do that effectively, though, we need to make a small change to our Summary view. As it stands now, our view is listening for any changes to the collection. When a change occurs, it renders the view from scratch.
initialize: function () {
this.listenTo(this.collection, "change", this.render);
return this;
}
Every time we fetch a new page of runs, the collection will change and our code will re-render the entire view. That’s almost certainly going to be annoying to our users, as each fetched page will cause the browser to temporarily blank out the page and then refill it. Instead, we’d like to render only views for the newly added models, leaving existing model views alone. To do that, we can listen for an "add" event instead of a "change" event. And when this event triggers, we can just render the view for that model. We’ve already implemented the code to create and render a view for a single Run model: the renderRun() method. Our Summary view, therefore, can be modified as shown here:
initialize: function () {
this.listenTo(this.collection, "add", this.renderRun);
return this;
}
Now as our collection fetches new Run models from the server, they’ll be added to the collection, triggering an "add" event, which our view captures. The view then renders each run on the page.
Step 5: Filter the Collection
Although our app is interested only in running, the Nike+ service supports a variety of athletic activities. When our collection fetches from the service, the response will include those other activities as well. To avoid including them in our app, we can filter them from the response.
We could filter the response manually, checking every activity and removing those that aren’t runs. That’s a lot of work, however, and Backbone.js gives us an easier approach. To take advantage of Backbone.js, we’ll first add a validate() method to our Run model. This method takes as parameters the attributes of a potential model as well as any options used when it was created or modified. In our case, we care only about the attributes. We’ll check to make sure the activityType equals "RUN".
Running.Models.Run = Backbone.Model.extend({
validate: function(attributes, options) {
if (attributes.activityType.toUpperCase() !== "RUN") {
return "Not a run";
}
},
You can see from this code how validate() functions should behave. If there is an error in the model, then validate() returns a value. The specifics of the value don’t matter as long as JavaScript considers it true. If there is no error, then validate() doesn’t need to return anything at all.
Now that our model has a validate() method, we need to make sure Backbone.js calls it. Backbone.js automatically checks with validate() whenever a model is created or modified by the code, but it doesn’t normally validate responses from the server. In our case, however, we do want to validate the server responses. That requires that we set the validate() property in the fetch() options for our Runs collection. Here’s the full fetch() method with this change included.
Running.Collections.Runs = Backbone.Collection.extend({
fetch: function(options) {
this.fetchoptions = options = options || {};
this.fetchsuccess = options.success;
_(this.fetchoptions).extend({
success: this.fetchMore,
remove: true,
validate: true
});
return Backbone.Collection.prototype.fetch.call(this,
this.fetchoptions
);
},
Now when Backbone.js receives server responses, it passes all of the models in those responses through the model’s validate() method. Any model that fails validation is removed from the collection, and our app never has to bother with activities that aren’t runs.
Step 6: Parse the Response
As long as we’re adding code to the Run model, there’s another change that will make Backbone.js happy. Backbone.js requires models to have an attribute that makes each object unique; it can use this identifier to distinguish one run from any other. By default, Backbone.js expects this attribute to be id, as that’s a common convention. Nike+, however, doesn’t have an id attribute for its runs. Instead, the service uses the activityId attribute. We can tell Backbone.js about this with an extra property in the model.
Running.Models.Run = Backbone.Model.extend({
idAttribute: "activityId",
This property lets Backbone.js know that for our runs, the activityId property is the unique identifier.
Step 7: Retrieve Details
So far we’ve relied on the collection’s fetch() method to get running data. That method retrieves a list of runs from the server. When Nike+ returns a list of activities, however, it doesn’t include the full details of each activity. It returns summary information, but it omits the detailed metrics arrays and any GPS data. Getting those details requires additional requests, so we need to make one more change to our Backbone.js app.
We’ll first request the detailed metrics that are the basis for the Charts view. When the Runs collection fetches its list of runs from the server, each Run model will initially have an empty metrics array. To get the details for this array, we must make another request to the server with the activity identifier included in the request URL. For example, if the URL to get a list of runs is https://api.nike.com/v1/me/sport/activities/, then the URL to get the details for a specific run, including its metrics, is https://api.nike.com/v1/me/sport/activities/2126456911/. The number 2126456911 at the end of that URL is the run’s activityId.
Thanks to the steps we’ve taken earlier in this section, it’s easy to get these details in Backbone.js. All we have to do is fetch() the model.
run.fetch();
Backbone.js knows the root of the URL because we set that in the Runs collection (and our model is a member of that collection). Backbone.js also knows that the unique identifier for each run is the activityId because we set that property in the previous step. And, fortunately for us, Backbone.js is smart enough to combine those bits of information and make the request.
We will have to help Backbone.js in one respect, though. The Nike+ app requires an authorization token for all requests, and so far we’ve added code for that token only to the collection. We have to add the same code to the model. This code is almost identical to the code from Step 1 in this section:
Running.Models.Run = Backbone.Model.extend({
sync: function(method, model, options) {
options = options || {};
_(options).extend({
data: {
authorization_token:
➊ this.collection.settings.authorization_token
}
});
Backbone.sync(method, model, options);
},
We first make sure that the options object exists, then extend it by adding the authorization token. Finally, we defer to the regular Backbone.js sync() method. At ➊, we get the value for the token directly from the collection. We can use this.collection here because Backbone.js sets the collection property of the model to reference the collection to which it belongs.
Now we have to decide when and where to call a model’s fetch() method. We don’t actually need the metrics details for the Summary view on the main page of our app; we should bother getting that data only when we’re creating a Details view. We can conveniently do that in the view’s initialize() method.
Running.Views.Details = Backbone.View.extend({
initialize: function () {
if (!this.model.get("metrics") ||
this.model.get("metrics").length === 0) {
this.model.fetch();
}
},
You might think that the asynchronous nature of the request could cause problems for our view. After all, we’re trying to draw the charts when we render the newly created view. Won’t it draw the charts before the server has responded (that is, before we have any data for the charts)? In fact, it’s almost guaranteed that our view will be trying to draw its charts before the data is available. Nonetheless, because of the way we’ve structured our views, there is no problem.
The magic is in a single statement in the initialize() method of our Charts view.
Running.Views.Charts = Backbone.View.extend({
initialize: function () {
this.listenTo(this.model,
"change:metrics change:gps", this.render);
// Code continues...
That statement tells Backbone.js that our view wants to know whenever the metrics (or gps) property of the associated model changes. When the server responds to a fetch() and updates that property, Backbone.js calls the view’s render() method and will try (again) to draw the charts.
There’s quite a lot going on in this process, so it may help to look at it one step at a time.
1. The application calls the fetch() method of a Runs collection.
2. Backbone.js sends a request to the server for a list of activities.
3. The server’s response includes summary information for each activity, which Backbone.js uses to create the initial Run models.
4. The application creates a Details view for a specific Run model.
5. The initialize() method of this view calls the fetch() method of the particular model.
6. Backbone.js sends a request to the server for that activity’s details.
7. Meanwhile, the application renders the Details view it just created.
8. The Details view creates a Charts view and renders that view.
9. Because there is no data for any charts, the Charts view doesn’t actually add anything to the page, but it is waiting to hear of any relevant changes to the model.
10.Eventually the server responds to the request in Step 6 with details for the activity.
11.Backbone.js updates the model with the new details and notices that, as a result, the metrics property has changed.
12.Backbone.js triggers the change event for which the Charts view has been listening.
13.The Charts view receives the event trigger and again renders itself.
14.Because chart data is now available, the render() method is able to create the charts and add them to the page.
Whew! It’s a good thing that Backbone.js takes care of all that complexity.
At this point we’ve managed to retrieve the detailed metrics for a run, but we haven’t yet added any GPS data. Nike+ requires an additional request for that data, so we’ll use a similar process. In this case, though, we can’t rely on Backbone .js because the URL for the GPS request is unique to Nike+. That URL is formed by taking the individual activity’s URL and appending /gps—for example, https://api.nike.com/v1/me/sport/activities/2126456911/gps/.
To make the additional request, we can add some code to the regular fetch() method. We’ll request the GPS data at the same time Backbone.js asks for the metrics details. The basic approach, which the following code fragment illustrates, is simple. We’ll first see if the activity even has any GPS data. We can do that by checking the isGpsActivity property, which the server provides on activity summaries. If it does, then we can request it. In either case, we also want to execute the normal fetch() process for the model. We do that by getting a reference to the standard fetch() method for the model (Backbone.Model.prototype.fetch) and then calling that method. We pass it the same options passed to us.
Running.Models.Run = Backbone.Model.extend({
fetch: function(options) {
if (this.get("isGpsActivity")) {
// Request GPS details from the server
}
return Backbone.Model.prototype.fetch.call(this, options);
},
Next, to make the request to Nike+, we can use jQuery’s AJAX function. Since we’re asking for JavaScript objects (JSON data), the $.getJSON() function is the most appropriate. First we set aside a reference to the run by assigning this to the local variable model. We’ll need that variable because this won’t reference the model when jQuery executes our callback. Then we call $.getJSON() with three parameters. First is the URL for the request. We get that from Backbone.js by calling the url() method for the model and appending the trailing /gps. The second parameter is the data values to be included with the request. As always, we need to include an authorization token. Just as we did before, we can get that token’s value from the collection. The final parameter is a callback function that JQuery executes when it receives the server’s response. In our case, the function simply sets the gps property of the model to the response data.
if (this.get("isGpsActivity")) {
var model = this;
$.getJSON(
this.url() + "/gps",
{ authorization_token:
this.collection.settings.authorization_token },
function(data) { model.set("gps", data); }
);
}
Not surprisingly, the process of retrieving GPS data works the same way as retrieving the detailed metrics. Initially our Map view won’t have the data it needs to create a map for the run. Because it’s listening for changes to the gps property of the model, however, it will be notified when that data is available. At that point it can complete the render function and the user will be able to view a nice map of the run.
Putting It All Together
At this point in the chapter, we have all the pieces for a simple data-driven web application. Now we’ll take those pieces and assemble them into the app. At the end of this section, we’ll have a complete application. Users start the app by visiting a web page, and our JavaScript code takes it from there. The result is a single-page application, or SPA. SPAs have become popular because JavaScript code can respond to user interaction immediately in the browser, which is much quicker than traditional websites communicating with a server located halfway across the Internet. Users are often pleased with the snappy and responsive result.
Even though our app is executing in a single web page, our users still expect certain behaviors from their web browsers. They expect to be able to bookmark a page, share it with friends, or navigate using the browser’s forward and back buttons. Traditional websites can rely on the browser to support all of those behaviors, but a single-page application can’t. As we’ll see in the steps that follow, we have to write some additional code to give our users the behavior they expect.
Step 1: Create a Backbone.js Router
So far we’ve looked at three Backbone.js components—models, collections, and views—all of which may be helpful in any JavaScript application. The fourth component, the router, is especially helpful for single-page applications. You won’t be surprised to learn that we can use Yeoman to create the scaffolding for a router.
$ yo backbone:router app
create app/scripts/routes/app.js
invoke backbone-mocha:router
create test/routers/app.spec.js
Notice that we’ve named our router app. As you might expect from this name, we’re using this router as the main controller for our application. That approach has pros and cons. Some developers feel that a router should be limited strictly to routing, while others view the router as the natural place to coordinate the overall application. For a simple example such as ours, there isn’t really any harm in adding a bit of extra code to the router to control the app. In complex applications, however, it might be better to separate routing from application control. One of the nice things about Backbone.js is that it’s happy to support either approach.
With the scaffolding in place, we can start adding our router code to the app.js file. The first property we’ll define is the routes. This property is an object whose keys are URL fragments and whose values are methods of the router. Here’s our starting point.
Running.Routers.App = Backbone.Router.extend({
routes: {
"": "summary",
"runs/:id": "details"
},
});
The first route has an empty URL fragment (""). When a user visits our page without specifying a path, the router will call its summary() method. If, for example, we were hosting our app using the greatrunningapp.com domain name, then users entering http://greatrunningapp.com/ in their browsers would trigger that route. Before we look at the second route, let’s see what the summary() method does.
The code is exactly what we’ve seen before. The summary() method creates a new Runs collection, fetches that collection, creates a Summary view of the collection, and renders that view onto the page. Users visiting the home page for our app will see a summary of their runs.
summary: function() {
this.runs = new Running.Collections.Runs([],
{authorizationToken: "authorize me"});
this.runs.fetch();
this.summaryView = new Running.Views.Summary({collection: this.runs});
$("body").html(this.summaryView.render().el);
},
Now we can consider our second route. It has a URL fragment of runs/:id. The runs/ part is a standard URL path, while :id is how Backbone.js identifies an arbitrary variable. With this route, we’re telling Backbone.js to look for a URL that starts out as http://greatrunningapp.com/runs/ and to consider whatever follows as the value for the id parameter. We’ll use that parameter in the router’s details() method. Here’s how we’ll start developing that method:
details: function(id) {
this.run = new Running.Models.Run();
this.run.id = id;
this.run.fetch();
this.detailsView = new Running.Views.Details({model: this.run});
$("body").html(this.detailsView.render().el);
},
As you can see, the code is almost the same as the summary() method, except we’re showing only a single run instead of the whole collection. We create a new Run model, set its id to the value in the URL, fetch the model from the server, create a Details view, and render that view on the page.
The router lets users go straight to an individual run by using the appropriate URL. A URL of http://greatrunningapp.com/runs/2126456911, for example, will fetch and display the details for the run that has an activityId equal to 2126456911. Notice that the router doesn’t have to worry about what specific attribute defines the model’s unique identifier. It uses the generic id property. Only the model itself needs to know the actual property name that the server uses.
With the router in place, our single-page application can support multiple URLs. One shows a summary of all runs, while others show the details of a specific run. Because the URLs are distinct, our users can treat them just like different web pages. They can bookmark them, email them, or share them on social networks. And whenever they or their friends return to a URL, it will show the same contents as before. That’s exactly how users expect the Web to behave.
There is another behavior that users expect, though, that we haven’t yet supported. Users expect to use their browser’s back and forward buttons to navigate through their browsing histories. Fortunately, Backbone.js has a utility that takes care of that functionality. It’s the history feature, and we can enable it during the app router’s initialization.
Running.Routers.App = Backbone.Router.extend({
initialize: function() {
Backbone.history.start({pushState: true});
},
For our simple app, that’s all we have to do to handle browsing histories. Backbone.js takes care of everything else.
NOTE
Support for multiple URLs will probably require some configuration of your web server. More specifically, you’ll want the server to map all URLs to the same index.html file. The details of this configuration depend on the web server technology. With open source Apache servers, the .htaccess file can define the mapping.
Step 2: Support Run Models Outside of Any Collection
Unfortunately, if we try to use the preceding code with our existing Run model, we’ll encounter some problems. First among them is the fact that our Run model relies on its parent collection. It finds the authorization token, for example, using this.collection.settings.authorization_token. When the browser goes directly to the URL for a specific run, however, there won’t be a collection. In the following code, we make some tweaks to address this:
Running.Routers.App = Backbone.Router.extend({
routes: {
"": "summary",
"runs/:id": "details"
},
initialize: function(options) {
this.options = options;
Backbone.history.start({pushState: true});
},
summary: function() {
this.runs = new Running.Collections.Runs([],
➊ {authorizationToken: this.options.token});
this.runs.fetch();
this.summaryView = new Running.Views.Summary({
collection: this.runs});
$("body").html(this.summaryView.render().el);
},
details: function(id) {
this.run = new Running.Models.Run({},
➋ {authorizationToken: this.options.token});
this.run.id = id;
this.run.fetch();
this.detailsView = new Running.Views.Details({
model: this.run});
$("body").html(this.detailsView.render().el);
});
Now we provide the token to the Run model when we create it at ➋. We also make its value an option passed to the collection on creation at ➊.
Next we need to modify the Run model to use this new parameter. We’ll handle the token the same way we do in the Runs collection.
Running.Models.Run = Backbone.Model.extend({
initialize: function(attrs, options) {
this.settings = { authorization_token: "" };
options = options || {};
if (this.collection) {
_(this.settings).extend(_(this.collection.settings)
.pick(_(this.settings).keys()));
}
_(this.settings).extend(_(options)
.pick(_(this.settings).keys()));
},
We start by defining default values for all the settings. Unlike with the collection, the only setting our model needs is the authorization_token. Next we make sure that we have an options object. If none was provided, we create an empty one. For the third step, we check to see if the model is part of a collection by looking at this.collection. If that property exists, then we grab any settings from the collection and override our defaults. The final step overrides the result with any settings passed to our constructor as options. When, as in the preceding code, our router provides an authorization_token value, that’s the value our model will use. When the model is part of a collection, there is no specific token associated with the model. In that case, we fall back to the collection’s token.
Now that we have an authorization token, we can add it to the model’s AJAX requests. The code is again pretty much the same as our code in the Runs collection. We’ll need a property that specifies the URL for the REST service, and we’ll need to override the regular sync() method to add the token to any requests.
urlRoot: "https://api.nike.com/v1/me/sport/activities",
sync: function(method, model, options) {
options = options || {};
_(options).extend({
data: { authorization_token: this.settings.authorization_token }
});
Backbone.sync(method, model, options);
},
This extra code takes care of the authorization, but there’s still a problem with our model. In the previous section, Run models existed only as part of a Runs collection, and the act of fetching that collection populated each of its models with summary attributes, including, for example, isGpsActivity. The model could safely check that property whenever we tried to fetch the model details and, if appropriate, simultaneously initiate a request for the GPS data. Now, however, we’re creating a Run model on its own without the benefit of a collection. When we fetch the model, the only property we’ll know is the unique identifier. We can’t decide whether or not to request GPS data, therefore, until after the server responds to the fetch.
To separate the request for GPS data from the general fetch, we can move that request to its own method. The code is the same as before (except, of course, we get the authorization token from local settings).
fetchGps: function() {
if (this.get("isGpsActivity") && !this.get("gps")) {
var model = this;
$.getJSON(
this.url() + "/gps",
{ authorization_token: this.settings.authorization_token },
function(data) { model.set("gps", data); }
);
}
}
To trigger this method, we’ll tell Backbone.js that whenever the model changes, it should call the fetchGps() method.
initialize: function(attrs, options) {
this.on("change", this.fetchGps, this);
Backbone.js will detect just such a change when the fetch() response arrives and populates the model, at which time our code can safely check isGpsActivity() and make the additional request.
Step 3: Let Users Change Views
Now that our app can correctly display two different views, it’s time to let our users in on the fun. For this step, we’ll give them an easy way to change back and forth between the views. Let’s first consider the Summary view. It would be nice if a user could click on any run that appears in the table and be instantly taken to the detailed view for that run.
Our first decision is where to put the code that listens for clicks. At first, it might seem like the SummaryRow view is a natural place for that code. That view is responsible for rendering the row, so it seems logical to let that view handle events related to the row. If we wanted to do that, Backbone.js makes it very simple; all we need is an extra property and an extra method in the view. They might look like the following:
Running.Views.SummaryRow = Backbone.View.extend({
events: {
"click": "clicked"
},
clicked: function() {
// Do something to show the Details view for this.model
},
The events property is an object that lists the events of interest to our view. In this case there’s only one: the click event. The value—in this case, clicked—identifies the method that Backbone.js should call when the event occurs. We’ve skipped the details of that method for now.
There is nothing technically wrong with this approach, and if we were to continue the implementation, it would probably work just fine. It is, however, very inefficient. Consider a user who has hundreds of runs stored on Nike+. The summary table would have hundreds of rows, and each row would have its own function listening for click events. Those event handlers can use up a lot of memory and other resources in the browser and make our app sluggish. Fortunately, there’s a different approach that’s far less stressful to the browser.
Instead of having potentially hundreds of event handlers, each listening for clicks on a single row, we’d be better off with one event handler listening for clicks on all of the table rows. Since the Summary view is responsible for all of those rows, it’s the natural place to add that handler. We can still take advantage of Backbone .js to make its implementation easy by adding an events object to our view. In this case, we can do even better, though. We don’t care about click events on the table header; only the rows in the table body matter. By adding a jQuery-style selector after the event name, we can restrict our handler to elements that match that selector.
Running.Views.Summary = Backbone.View.extend({
events: {
"click tbody": "clicked"
},
The preceding code asks Backbone.js to watch for click events within the <tbody> element of our view. When an event occurs, Backbone.js will call the clicked() method of our view.
Before we develop any code for that clicked() method, we need a way for it to figure out which specific run model the user has selected. The event handler will be able to tell which row the user clicked, but how will it know which model that row represents? To make the answer easy for the handler, we can embed the necessary information directly in the markup for the row. That requires a few small adjustments to the renderRun() method we created earlier.
The revised method still creates a SummaryRow view for each model, renders that view, and appends the result to the table body. Now, though, we’ll add one extra step just before the row is added to the page. We add a special attribute, data-id, to the row and set its value equal to the model’s unique identifier. We use data-id because the HTML5 standard allows any attribute with a name that begins with data-. Custom attributes in this form won’t violate the standard and won’t cause browser errors.
renderRun: function (run) {
var row = new Running.Views.SummaryRow({ model: run });
row.render();
row.$el.attr("data-id", run.id);
this.$("tbody").append(row.$el);
},
The resulting markup for a run with an identifier of 2126456911 would look like the following example:
<tr data-id="2126456911">
<td>04/09/2013</td>
<td>0:22:39</td>
<td>2.33 Miles</td>
<td>240</td>
<td>9:43</td>
</tr>
Once we’ve made sure that the markup in the page has a reference back to the Run models, we can take advantage of that markup in our clicked event handler. When Backbone.js calls the handler, it passes it an event object. From that object, we can find the target of the event. In the case of a click event, the target is the HTML element on which the user clicked.
clicked: function (ev) {
var $target = $(ev.target)
From the preceding markup, it’s clear that most of the table row is made up of table cells (<td> elements), so a table cell will be the likely target of the click event. We can use the jQuery parents() function to find the table row that is the parent of the click target.
clicked: function (ev) {
var $target = $(ev.target)
var id = $target.attr("data-id") ||
$target.parents("[data-id]").attr("data-id");
Once we’ve found that parent row, we extract the data-id attribute value. To be on the safe side, we also handle the case in which the user somehow manages to click on the table row itself rather than an individual table cell.
After retrieving the attribute value, our view knows which run the user selected; now it has to do something with the information. It might be tempting to have the Summary view directly render the Details view for the run, but that action would not be appropriate. A Backbone.js view should take responsibility only for itself and any child views that it contains. That approach allows the view to be safely reused in a variety of contexts. Our Summary view, for example, might well be used in a context in which the Details view isn’t even available. In that case, trying to switch directly to the Details view would, at best, generate an error.
Because the Summary view cannot itself respond to the user clicking on a table row, it should instead follow the hierarchy of the application and, in effect, pass the information “up the chain of command.” Backbone.js provides a convenient mechanism for this type of communication: custom events. Instead of responding directly to the user click, the Summary view triggers a custom event. Other parts can listen for this event and respond appropriately. If no other code is listening for the event, then nothing happens, but at least the Summary view can say that it’s done its job.
Here’s how we can generate a custom event in our view:
clicked: function (ev) {
var $target = $(ev.target)
var id = $target.attr("data-id") ||
$target.parents("[data-id]").attr("data-id");
this.trigger("select", id);
}
We call the event select to indicate that the user has selected a specific run, and we pass the identifier of that run as a parameter associated with the event. At this point, the Summary view is complete.
The component that should respond to this custom event is the same component that created the Summary view in the first place: our app router. We’ll first need to listen for the event. We can do that right after we create it in the summary() method.
Running.Routers.App = Backbone.Router.extend({
summary: function() {
this.runs = new Running.Collections.Runs([],
{authorizationToken: this.options.token});
this.runs.fetch();
this.summaryView = new Running.Views.Summary({
collection: this.runs});
$("body").html(this.summaryView.render().el);
this.summaryView.on("select", this.selected, this);
},
When the user selects a specific run from the Summary view, Backbone.js calls our router’s selected() method, which will receive any event data as parameters. In our case, the event data is the unique identifier, so that becomes the method’s parameter.
Running.Routers.App = Backbone.Router.extend({
selected: function(id) {
this.navigate("runs/" + id, { trigger: true });
}
As you can see, the event handler code is quite simple. It constructs a URL that corresponds to the Details view ("runs/" + id) and passes that URL to the router’s own navigate() method. That method updates the browser’s navigation history. The second parameter ({ trigger: true }) tells Backbone.js to also act as if the user had actually navigated to the URL. Because we’ve set up the details() method to respond to URLs of the form runs/:id, Backbone.js will call details(), and our router will show the details for the selected run.
When users are looking at a Details view, we’d also like to provide a button to let them easily navigate to the Summary view. As with the Summary view, we can add an event handler for the button and trigger a custom event when a user clicks it.
Running.Views.Details = Backbone.View.extend({
events: {
"click button": "clicked"
},
clicked: function () {
this.trigger("summarize");
}
And, of course, we need to listen for that custom event in our router.
Running.Routers.App = Backbone.Router.extend({
details: function(id) {
// Set up the Details view
// Code continues...
this.detailsView.on("summarize", this.summarize, this);
},
summarize: function() {
this.navigate("", { trigger: true });
},
Once again we respond to the user by constructing an appropriate URL and triggering a navigation to it.
You might be wondering why we have to explicitly trigger the navigation change. Shouldn’t that be the default behavior? Although that may seem reasonable, in most cases it wouldn’t be appropriate. Our application is simple enough that triggering the route works fine. More complex applications, however, probably want to take different actions depending on whether the user performs an action within the app or navigates directly to a particular URL. It’s better to have different code handling each of those cases. In the first case the app would still want to update the browser’s history, but it wouldn’t want to trigger a full navigation action.
Step 4: Fine-Tuning the Application
At this point our app is completely functional. Our users can view their summaries, bookmark and share details of specific runs, and navigate the app using the browser’s back and forward buttons. Before we can call it complete, however, there’s one last bit of housekeeping for us. The app’s performance isn’t optimal, and, even more critically, it leaks memory, using small amounts of the browser’s memory without ever releasing them.
The most obvious problem is in the router’s summary() method, reproduced here:
Running.Routers.App = Backbone.Router.extend({
summary: function() {
this.runs = new Running.Collections.Runs([],
{authorizationToken: this.options.token});
this.runs.fetch();
this.summaryView = new Running.Views.Summary({
collection: this.runs});
$("body").html(this.summaryView.render().el);
this.summaryView.on("select", this.selected, this);
},
Every time this method executes, it creates a new collection, fetches that collection, and renders a Summary view for the collection. Clearly we have to go through those steps the first time the method executes, but there is no need to repeat them later. Neither the collection nor its view will change if the user selects a specific run and then returns to the summary. Let’s add a check to the method so that we take those steps only if the view doesn’t already exist.
summary: function() {
if (!this.summaryView) {
this.runs = new Running.Collections.Runs([],
{authorizationToken: this.options.token});
this.runs.fetch();
this.summaryView = new Running.Views.Summary({
collection: this.runs});
this.summaryView.render();
this.summaryView.on("select", this.selected, this);
}
$("body").html(this.summaryView.el);
},
We can also add a check in the details() method. When that method executes and a Summary view is present, we can “set aside” the Summary view’s markup using jQuery’s detach() function. That will keep the markup and its event handlers ready for a quick reinsertion onto the page should the user return to the summary.
details: function(id) {
if (this.summaryView) {
this.summaryView.$el.detach();
}
this.run = new Running.Models.Run({},
{authorizationToken: this.options.token});
this.run.id = id;
this.run.fetch();
$("body").html(this.detailsView.render().el);
this.detailsView.on("summarize", this.summarize, this);
},
Those changes make switching to and from the Summary view more efficient. We can also make some similar changes for the Details view. In the details() method we don’t have to fetch the run if it’s already present in the collection. We can add a check, and if the data for the run is already available, we won’t bother with the fetch.
details: function(id) {
if (!this.runs || !(this.run = this.runs.get(id))) {
this.run = new Running.Models.Run({},
{authorizationToken: this.options.token});
this.run.id = id;
this.run.fetch();
}
if (this.summaryView) {
this.summaryView.$el.detach();
}
this.detailsView = new Running.Views.Details({model: this.run});
$("body").html(this.detailsView.render().el);
this.detailsView.on("summarize", this.summarize, this);
},
In the summary() method, we don’t want to simply set aside the Details view as we did for the Summary view. That’s because there may be hundreds of Details views hanging around if a user starts looking at all of the runs available. Instead, we want to cleanly delete the Details view. That lets the browser know that it can release any memory that the view is consuming.
As you can see from the following code, we’ll do that in three steps.
1. Remove the event handler we added to the Details view to catch summarize events.
2. Call the view’s remove() method so it releases any memory it’s holding internally.
3. Set this.detailsView to null to indicate that the view no longer exists.
summary: function() {
if (this.detailsView) {
this.detailsView.off("summarize");
this.detailsView.remove();
this.detailsView = null;
}
if (!this.summaryView) {
this.runs = new Running.Collections.Runs([],
{authorizationToken: this.options.token});
this.runs.fetch();
this.summaryView = new Running.Views.Summary({
collection: this.runs});
this.summaryView.render();
this.summaryView.on("select", this.selected, this);
}
$("body").html(this.summaryView.el);
},
And with that change, our application is complete! You can take a look at the final result in the book’s source code (http://jsDataV.is/source/).
Summing Up
In this chapter, we completed a data-driven web application. First, we saw how Backbone.js gives us the flexibility to interact with REST APIs that don’t quite follow the normal conventions. Then we worked with a Backbone.js router to make sure our single-page application behaves like a full website so that our users can interact with it just as they would expect.