(Ab)using ngRoute to build a hybrid app

I love AngularJS. It makes building rich internet applications easy and does so in a structured manner. These days, whenever I need to fallback to jQuery (due to client requirements, for instance) it feels like going back to the Dark Ages. But enough praise.

I also love server side programming. Single page apps are fine and dandy and all, but there’s also a lot to be said for having a URI map to a specific blob of HTML consistently. It makes search engines happy (yes there are ways around that, but they’re clunky) and screen readers too. Besides, a simple wget should get the page I request, not some placeholder which is supposed to run heaps of Javascript before it renders in a usable way.

So for a while now I’ve been taking the following approach: web applications are written “old skool” in a server side language (PHP in my case, usually). The emitted HTML should return a usable page. Then, for all the interactive bits I use Angular directives. In short, I get the goodies from Angular but can still use “normal” routing via the server and it gracefully degrades if the Javascript barfs for whatever reason.

But, I was wondering, what if we can combine the two? What if the server would respond to a full page when a URI gets requested, but let Angular take it from there? Without, of course, having to duplicate our routing table? Then we can have the best of both worlds: a fluid experience for the user due to HTML5 push state URL handling, and HTML rendering logic on the server.

Turns out we can! And it’s actually a lot simpler than I thought.

Setting it up

First we need to include ngRoute so we can leverage Angular’s built-in URL handling. We could handle this manually via window.history.pushState, but why bother if the hard work has already been done.

let app = angular.module('foo', ['ngRoute']);
app.config(['$locationProvider', $locationProvider => {
    if (!!(window.history && window.history.pushState)) {
        $locationProvider.html5Mode(true);
    }
}])
app.config(['$routeProvider', $routeProvider => {
    // We don't actually _have_ routes, but just define these dummy routes
    // so the ngRoute logic will kick in and make our site HTML5 history
    // compatible.
    $routeProvider.when('/', {});
    $routeProvider.otherwise({});
}]);

The first config block conditionally sets Html5Mode for the $locationProvider as explained here. This is so that older browsers just fallback to “normal” routing, period. The post explains this further.

Next, we tell Angular that yes, / is handled by the $routeProvider, and we have no other routes. It’s imperative that you add both of these statements! If either is left out, ngRoute seems to give up. For this particular project, / will redirect to a language (/en/ for instance) anyway so it was perfectly safe. YMMV though – you might have to pick some dummy URL not otherwise used.

When we now reload the page all anchors will be “handled” by ngRoute, but won’t do anything yet. On to the next part!

Handling the route changes

ngRoute helpfully fires Angular events when it does something, so we’re going to tap into those:

let initial = true;
app.run(['$http', '$rootScope', '$cacheFactory', ($http, $rootScope, $cacheFactory) => {
    let cache = $cacheFactory('fooTemplate');
    $rootScope.$on('$routeChangeSuccess', (...args) => {
        if (initial) {
            initial = false;
            return;
        }
        let target = location.href.replace(location.origin, '');
        $http.get(target, cache).success(html => {
            $rootScope.$broadcast('fooTemplate', angular.element(html));
        });
    });
}]);

The exact naming doesn’t matter, but what happens here is:
1. When the $routeChangeSuccess event fires, we use the $http service to get the requested page from the server “under water”.
2. Once it returns, we broadcast an event with the HTML wrapped in a jqLite element as a parameter.
3. The calls are cached for efficiency.

Note that we also have an initial variable. This is because the route events also fire on page load, and since we already have our HTML we want to ignore the initial call.

Now, for each click you’ll see the page being requested in your browser console.

Changing the HTML

Define a directive (e.g. fooTemplate) that we’re going to apply to all elements that expect such a dynamic update:


app.directive('fooTemplate', ['$rootScope', '$compile', ($rootScope, $compile) => ({
    restrict: 'A',
    link: (scope, elem, attrs) => {
        $rootScope.$on('$routeChangeStart', () => {
            if (!initial) {
                elem.addClass('loading');
            }
        });
        $rootScope.$on('fooTemplate', (event, parsed) => {
            elem.removeClass('loading');
            angular.forEach(parsed, el => {
                if (!el.tagName) {
                   return;
                }
                // Replace & recompile the content if:
                // - the tagname matches
                // - the classnames match (if specified)
                // - the id matches (if specified)
                if (el.tagName.toLowerCase() != elem[0].tagName.toLowerCase()) {
                    return;
                }
                if (el.id && elem.attr('id') && el.id != elem.attr('id')) {
                    return;
                }
                let c1 = el.className.split(' ').sort((a, b) => a < b ? -1 : 1);
                let c2 = elem[0].className.split(' ').sort((a, b) => a < b ? -1 : 1);
                if (!angular.equals(c1, c2)) {
                    return;
                }
                elem.html(el.innerHTML);
                if (elem[0].tagName.toLowerCase() != 'title') {
                    $compile(elem.contents())(scope);
                }
            });
        });
    }
})]);

First we add a “loading” class to the element in question on $routeChangeStart. This is just for visual feedback to the user that something is loading under water, e.g. show a spinner. Again we only do that if we’re not in the intial run.

We then listen for our “template event” and traverse the returned HTML. If an element seems to match the element we put our foo-template directive on, we replace the HTML and – and this is important – run it through the $compile service. This allows any returned Angular components to also work after the page is updated.

Usage in HTML

Just apply the directive to any elements that contain “page specific content”:

<article id="content" foo-template>This is for a specific page and will be replaced when the URL changes.</article>

Todos/excercises

I’m going to tinker with this some more and might turn it into an actual package. In particular, it would need to:
– gracefully handle errors the $http call returns (e.g. a redirect to /login/ due to session expiration)
– do some intelligent pre-parsing on the returned HTML to it only contains elements actually tagged as templates (for efficiency)
– also handle form submissions in a likewise manner (unless of course Angular should handle them – probably check for the action attribute)
– clear the cache when needed, since POSTing stuff might invalidate certain HTML pages outright

Caveats

The HTML snippet the directive is applied to should be uniquely identifiable, so either give it an ID or make sure the combination of class/tagName is unique in the document.

Update

This is now an actual Angular module y’all can use: documentation GitHub Obviously directives have been renamed, but check out the documentation. It’s actually really easy to use (I’ve used it in a number of live projects already).

Leave a Reply

Your email address will not be published. Required fields are marked *