Building a single-page app, one page at time

Just as the idea of a playlist extends a musical moment beyond the end of a song, a web application enables a rich browsing experience that isn’t bordered by page refreshes every time you interact with the screen. This idea is particularly important for music websites where audio continuity is the paramount consideration. In a choice between interrupting your music or seeing what’s on another page, you’ll almost invariably choose to keep listening. In the past we’ve protected users from unwanted pauses by buffering clicks to new pages with this helpful popup:

Screen Shot 2013-08-30 at 4.55.14 PM

Nonetheless, the value of a music discovery service is limited by being unable to explore the site while you’re listening. From the very first version of 8tracks, we’ve wanted to enable continuous playback while keeping the entire site accessible. But it’s only recently that more robust web technology has become widespread and we were able to implement our “pervasive player” in a way that wasn’t hacked together or only available on one platform. In particular with the launch of the Next Soundcloud, we saw much that we could learn from and also some ideas to improve upon in our own transition.


Unique requirements

In addition to requiring that music is continuously playable, we also must provide server-rendered versions of our public pages – this makes our content accessible to search engines and screen readers, simplifies linking and sharing, and results in faster load times through our cache system. This requirement is a large part why of we avoided earlier solutions like hashbang URLs or iframes, and waited for HTML5 history to be widely available. We also wanted a transition path that didn’t require a full rewrite or a big launch to inaugurate the new version. Building a second version of the app in parallel to maintaining the first was just not an option for our four-person dev team.

These requirements pushed us toward more adaptable technologies and newer libraries. Over the course of several months we began using Backbone.js to manage our models and view logic, Require.js to handle dependencies, and several flavors of Mustache for templating, keeping these updates in the production codebase without radically changing the application or UI. These updates readied us for futher abstraction into a single-page application.


Enter the router

When it came time build our new player, we needed a new javascript “controller” that knew about the entire application before it was rendered or used. This logic would have to handle the transitions between different pages, load data and dependencies, and manage memory usage by cleaning up unused views – all fairly new concepts to us as longtime web developers (accustomed as we are to the magical page refresh). Fortunately the Backbone Router provides a very succinct way to define the structure of our entire application and handles the URL management that we normally expect the server to do for us. Our first step is to initialize the router and send it any clicks events we want it to handle.

  App = { Collections : {}, Models : {}, Views {}, views : {}};

  App.router = new Router();
  Backbone.history.start({ pushState: true, silent: true, hashChange : true });

  $(document).ready(function(){
    $('body').delegate('a', 'click', function(event) {
      // Allow shift+click for new tabs, etc.
      if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) {
        return true;
      }

      // Open external links in new windows
      if (this.rel.match('external')) {
        this.target = '_blank';
        return;
      }

      if (this.hostname == window.location.hostname) {
        // Instruct Backbone to trigger routing events
        App.router && App.router.navigate(this.pathname + this.hash, { trigger: true });
        event.preventDefault();
        return false;
      }
   });
  });

This should be fairly familiar code for jQuery users, but all it does is capture the click events on every <a> element that isn’t already handled by a more specific function. It checks to see if the user has pressed any modifier keys, makes sure that the link points to our domain, then it propagates the url to the Router object through the navigate function.


Mapping the application

The router doesn’t need to know everything about the application, just enough to send the user to the right place. Because we allow wildcard paths for user pages, we had to name all of our reserved URLs for static pages (abridged below). We only wanted to make a few of the pages work asynchronously, so first we set up the generalized cases.

Each of the different routes maps to a function in the router, and in the beginning, most of them point to either “reload” or “html.”Reload will open the page in a new window or just trigger a full page reload, depending whether music is playing. HTML will load in static content exactly as the server renders it. Other routes have their own specific function which will invoke the appropriate dependencies and views while loading the relevant data from the server. For these, we decided to begin with the four most-used pages: Home, Explore, Profile, and Mix.

Backbone.Router.extend({
  initialize : function() { this.appView = new AppView(); },

  routes: {
    "about"                : "html",
    "apps"                 : "html",
    "iphone"               : "reload",
    "android"              : "reload",
    ...
    ""                     : "homepage",
    "explore(/:tags)"      : "explore",
    ":user_slug"           : "profile"
    ":user_slug/:mix_slug" : "mix",
  },

  homepage : function() { this.appView.loadHomepageView(); },

  explore :  function() { this.appView.loadExploreView(this.getPath()); },

  profile :  function() { this.appView.loadProfileView(this.getPath()); },

  mix :      function() { this.appView.loadMixView(this.getPath()); },

  html :     function() { this.appView.loadHTML(this.getPath()); },

  reload : function() {
    if (window.isPlaying()) {
      window.open(this.getPath());
    } else {
      window.location = this.getPath();
    }
    return false;
  },

  getPath: function(){
    return Backbone.history._hasPushState ? window.location.pathname : window.location.hash.substring(1);
  }
});

Virtual turbolinks

As you can see, the reload logic is very straightforward, just checking a window-level function to see if we’re interrupting the music by reloading the page. The HTML function on the other hand is very similar to Rails 4-style turbolinks, which simply make an AJAX request for an HTML document and then replace the open document wholesale. We’re a bit more judicious in our application, rebuilding only our main content area and allowing our header to persist with its functionality intact. (Note: these other methods could live in the Router object, but we’ve kept them in their own file for readability).

AppView = Backbone.View.extend({
  ...
  loadHTML : function(url) {
    this.loadingState(true);
    $.ajax(url, {
      success : _.bind(function(html) {
        this.loadingState(false);
        var $newPage = $(html);
        $('#content').replaceWith($newPage.find('#content'));
        document.title = html.match(/<title>(.*?)</title>/)[1];
        scrollTo(0, 0);
      }, this)
    });
  },
});

First we make a regular HTML request for the page and use jQuery to parse the new document and get at the part we want. Then we inject it rather unceremoniously using $.replaceWith(). This brutally simple logic only works for pages that don’t use any javascript whatsoever, like our About page. Note that we use _.bind() to keep the callback function in the same context as the original method, which should be familiar to Backbone users.


The real thing

Of course, loading fully-dynamic pages with their own backbone views is more complicated. While much of it is shared logic, it does get a bit more difficult if we involve require.js and have to make multiple asynchronous requests. However, in a simplified setup it will follow a fairly straightforward structure. This method retrieves a new mix, unloads the old view, instantiates a new one, and renders it to the page. Much of this can of course be generalized when you have more than one such method.

AppView = Backbone.View.extend({
  ...
  loadHtml : function(url) { ... },

  loadMixView : function(url) {
    this.loadingState(true);
    $.ajax(url, {
      data : { format : 'json', include : 'details' },
      complete : _.bind(function(json){
        if (!App.Views.MixView || !App.Collections.Mixes) return false;
        if (this.currentView) {
          this.currentView.undelegateEvents();
          this.currentView.remove();
        }
        this.loadingState(false);

        var mix = App.Collections.Mixes.load(json.mix);
        App.views.mixView = this.currentView = new App.Views.MixView({ mix : mix });
        App.views.mixView.render();
        document.title = mix.get('title');
      }, this)
    });
  }
});

That’s enough to make our application URL-aware and ready to render just one page on the client side. Of course, our view has to be smart enough to work in either scenario, either using a server-rendered HTML element or being able to render its own.

In the next post, we’ll cover how we make our backbone views .render() their templates in exactly the same way as our server, using Mustache and some special helpers. If you have and questions or suggestions, please let us know below. Thanks for reading!

Matthew Cieplak joined the team in 2008 and handles frontend web development at 8tracks.

10 thoughts on “Building a single-page app, one page at time

  1. We hadn’t looked at Angular, actually. Thanks for the tip! We did do some investigation into Knockout and Ember, but in the end went with Backbone because it offered a straightforward way to convert our existing app, based on jQuery behaviors, to a model-centric approach. Angular (and Ember, with its built-in templating) seem well suited for building a new application from scratch, where a bit of scaffolding and more structured development strategy are useful. Our primary concern was interoperability of old and new code in production.

    Like

  2. I enjoyed reading this post. I use 8tracks often and find the new re-design most sensible ____ !
    I appreciate Mathew’s extrapolation of some of the backbone.js processes to those of us who are interested in web-development.

    Like

  3. Hi Mathew, Nice Post, Thanks for sharing. You have very well highlighted the use of Single page design especially for the media industry.
    In my quest to learn more about the SPA, I have registered for a webinar on Benefits of developing Single Page Web Applications using AngularJS, it looks a promising one http://j.mp/1a9aK6t

    Like

  4. Great article, I am glad you used BackboneJS. For me, it’s the best because I am in control, not some `magic` hidden in the source code.

    Like

  5. I still have issue with external links. whatever I try there is something wrong with it. your router code works if I have `target=”_blank”` links and just click on it. it opens in new tab but the backbone tab also redirects to that link.

    also, shift click and middle click on the link does not open it in new tab either.

    I have so many issues with external links in Backbone. still looking for a solution

    Like

  6. I live in Ontario, Canada and it won’t let me access anything. Meanwhile, your app uptade says that U.S and Canada may still use their accounts and apps. I used to love this app for music but now I’m not too happy very with you guys🤷🏼‍♀️

    Like

  7. I live in Ontario, Canada and it won’t let me access anything. Meanwhile, your app uptade says that U.S and Canada may still use their accounts and apps. I used to love this app for music but now I’m not too happy with you guys🤷🏼‍♀️

    Like

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s