Reimagining Single-Page Applications With Progressive Enhancement
What is the difference between a web page and a web application? Though we tend to identify documents with reading and applications with interaction, most web-based applications are of the blended variety: Users can consume information and perform tasks in the same place. Regardless, the way we approach building web applications usually dispenses with some of the simple virtues of the readable web.
Single-page applications tend to take the form of runtimes, JavaScript executables deployed like popup shops into vacant <body>
elements. They’re temporary, makeshift and not cURL-able: Their content is not really there without a script being executed. They’re also brittle and underperforming because, in service of architectural uniformity and convenience, they make all of their navigation, data handling and even the basic display of content the responsibility of one thing: client-side JavaScript.
Recently, there’s been a move towards “isomorphic” (or “universal”) applications — applications that can run the same code on the client and the server, sending prerendered HTML from the server before delegating to client-side code. This approach (possible using Express as the server and React as the rendering engine, for instance) is a huge step towards a more performant and robust web application architecture.
But isomorphism is surely not the only way to go about progressive enhancement for single-page applications. I’m looking into something more flexible and with less configuration, a new philosophy that capitalizes on standard browser behavior and that can blend indexable, static prose with JavaScript-embellished interactivity, rather than just “handing off” to JavaScript.
This little exposition amounts to no more than the notion of doing things The Web Way™ with a few loosely confederated concepts and techniques, but I think you could take it and make it something special.
Writing Views
In your typical single-page app, rendering views (i.e. individual screens) and routing between them is made the concern of JavaScript. That is, locations are defined, evaluated and brought into existence entirely by what was, until recent years, a technology considered supplementary to this kind of behavior. Call me a Luddite, but I’m not going to use JavaScript for this at all. Heretically, I’m going to let HTML and the browser take care of it instead.
I’ll start by creating an HTML page and making the <main>
part of that page my views container:
<main role="main">
/* Views go here. */
</main>
Then, I’ll begin constructing individual views, placing each as a child element of <main>
. Each view must bear an id
. This will be used as part of our “routing solution.” It should also have a first-level heading: Views will be displayed one at a time, as the only perceivable content of the page, so this is preferable for screen-reader accessibility.
<div id="some-view">
<h1>Some view</h1>
<!-- the static view content, enhanceable with JavaScript -->
</div>
For brevity and to underline the importance of working directly in HTML, I’m hand-coding my views. You may prefer to compile your views from data using, say, Handlebars and a Node.js script, in which case each view within your {{#each}}
block might look like the following. Notice that I’m using a Handlebars helper to dynamically create the id
by slugifying the view’s title
property.
<div id="{{slugify title}}">
<h1>{{title}}</h1>
{{{content}}}
</div>
Maybe using PHP to generate the content from a MySQL database is more your thing? It’s really not important how you compile your views so long as content is served precompiled to the client. Some content and functionality should be available in the absence of client-side scripting. Then, we can progressively enhance it, only in cases where we actually want to progressively enhance it. As I shall explain, my method will preserve static content within the app as just that: static content.
Navigation
Not being interested in breaking with convention, I think my single-page app would benefit from a navigation block, allowing users to traverse between the views. Above the <main>
view area, I might provide something like this:
<nav role="navigation">
<ul>
<li><a href="#the-default-view">the default view</a></li>
<li><a href="#some-view">some view</a></li>
<li><a href="#another-view">another view</a></li>
</ul>
</nav>
My views are document fragments, identified by their id
s, and can be navigated to using links bearing this identifier, or “hash.” So, when users click on the first link, pointing at #the-default-view
, they’ll be transported to that view. If it is not currently visible in the viewport, the browser will scroll it into visibility. Simultaneously, the URL will update to reflect the new location. To determine where you are in the application, you need only query the URL:
https://my-app-thing.com#the-default-view
As you might imagine, leveraging standard browser behavior to traverse static content is really rather performant. It can be expected to work unencumbered by JavaScript and will even succeed where JavaScript errs. Although my “app” is more akin to a Wikipedia page than the kind of thing you’re familiar seeing built with AngularJS, the navigation part of my routing is now complete.
Note: Because conforming browsers send focus to page fragments, keyboard accessibility is already taken care of here. I can enhance keyboard accessibility when JavaScript is, eventually, employed. More on that later.
One View At A Time
Being an accessibility consultant, a lot of my work revolves around reconciling state and behavior with the appearance of these things. At this point, the behavior of changing routes within our app is already supported, but the app does not look or feel like a single-page application because each view is ever present, rather than mutually exclusive. We should only ever show the view to which the user has navigated.
Is this the turning point at which I begin to progressively enhance with JavaScript? No, not yet. In this case, I’ll be harnessing CSS’ :target
pseudo-class. Progressive enhancement does not just mean “adding JavaScript”: Our web page should work OK without JavaScript or CSS.
main > * {
display: none;
}
main > *:target {
display: block;
}
The :target
pseudo-class relates to the element matching the fragment identifier in the URL. In other words, if the URL is https://my-app-thing.com#some-view
, then only the element with the id
of some-view
will have display: block
applied. To “load” that view (and hide the other views), all one has to do is click a link with the corresponding href
. Believe it or not, I’m using links as links, not hijacking them and suppressing their default functionality, as most single-page apps (including client-rendered isomorphic apps) would do.
<a href="#some-view">some view</a>
This now feels more like a single-page application (which, in turn, is designed to feel like you’re navigating between separate web pages). Should I so desire, I could take this a step further by adding some animation.
main > * {
display: none;
}
@keyframes pulse {
0% { transform: scale(1) }
50% { transform: scale(1.05) }
100% { transform: scale(1) }
}
main > *:target {
display: block;
animation: pulse 0.5s linear 1;
}
Fancy! And, admittedly, somewhat pointless, but there is something to be said for a visual indication that the context has changed — especially when switching views is instantaneous. I’ve set up a Codepen for you to see the effect. Note that the browser’s “back” button works as expected, because no JavaScript has hijacked or otherwise run roughshod over it. Pleasingly, the animation triggers either via an in-page link or with the “back” and “forward” buttons.
Everything works great so far, except that no view is displayed upon https://my-app-thing.com
being hit for the first time. We can fix this! No, not with JavaScript, but with a CSS enhancement again. If we used JavaScript here, it would make our whole routing system dependent on it and all would be lost.
The Default View
Because I can’t rely on users navigating to https://my-app-thing.com#the-default-view
according to my saying so, and because :target
needs the fragment identifier #the-default-view
to work, I’ll need to try something else to display that default view.
As it turns out, this is achievable by controlling the source order and being a bit of a monster with CSS selectors. First, I’ll make my default view the last of the sibling view elements in the markup. This is perfectly acceptable accessibility-wise because views are “loaded” one at a time, with the others hidden from assistive technology using display: none
. Order is not pertinent.
<main role="main">
<div id="some-view">
<h1>some view</h1>
<!-- … -->
</div>
<div id="another-view">
<h1>another view</h1>
<!-- … -->
</div>
<div id="the-default-view">
<h1>the default view</h1>
<!-- … -->
</div>
</main>
Putting the default view last feels right to me. It’s like a fallback. Now, we can adapt the CSS:
main > * {
display: none;
}
main > *:last-child {
display: block;
}
@keyframes pulse {
0% { transform: scale(1) }
50% { transform: scale(1.05) }
100% { transform: scale(1) }
}
main > *:target {
display: block;
animation: pulse 0.5s linear 1;
}
main > *:target ~ * {
display: none;
}
There are two new declaration blocks: the second and final. The second overrules the first to show our default > *:last-child
view. This will now be visible when the user hits https://my-app-thing.com
. The final block, using the general sibling combinator, applies display: none
to any element following the :target
element. Because our default view comes last, this rule will always apply to it, but only if a :target
element exists. (Because CSS doesn’t work backwards, a :first-child
default element would not be targetable from a sibling :target
element that appears after it.)
Try reloading the Codepen with just the root URL (no hash in the address bar) to see this in practice.
It’s Time
We’ve come a long way without using JavaScript. The trick now is to add JavaScript behavior judiciously, enhancing what’s been achieved so far without replacing it. We should be able to react to view changes with JavaScript without causing those view changes to fall in the realm of JavaScript. Anything short of this would be overengineering, thereby diminishing performance and reliability.
I’m going to use a modicum of plain, well-supported JavaScript, not jQuery or any other helper library: The skeleton of the app should remain small but extensible.
The hashchange
Event
As stated, popular web application frameworks tend to render views with JavaScript. They then allow callback hooks, like Meteor’s Template.my-template.rendered
, for augmenting the view at the point it’s made available. Even isomorphic apps like to use script-driven routing and rendering if they get the chance. My little app does not render views so much as reveal them. However, it’s entirely likely that, in some cases, I’ll want to act upon a newly revealed view with JavaScript, upon its arrival.
Fortuitously, the Web API affords us the extremely well-supported (from Internet Explorer 8 and up) hashchange
event type, which fires when the URL’s fragment identifier changes. This has a similar effect but, crucially, does not rely on JavaScript rendering the view (from which it would emit a custom event) to provide us with a hook.
In the following script (demoed in another Codepen), I use the hashchange
event to log the identity of the current view, which doubles as the id
of that view’s parent element. As you might imagine, it works no matter how you change that URL, including by using the “back” button.
window.addEventListener('hashchange', function() {
console.log('this view\'s id is ', location.hash.substr(1));
});
We can scope DOM operations to our view by setting a variable inside this event handler, such as viewElem
, to signify the view’s root element. Then, we can target view-specific elements with expressions such as viewElem.getElementsByClassName('button')[0]
and so on.
window.addEventListener('hashchange', function() {
var viewID = location.hash.slice(1);
var viewElem = document.getElementById(viewID);
viewElem.innerHTML = '<p>View loaded!</p>';
});
Abstraction
I’m wary of abstraction because it can become its own end, making program logic opaque in the process. But things are going to quickly turn into a mess of ugly if
statements if I carry on in this vein and begin to support different functionality for individual views. I should also be addressing the issue of filling the global scope. So, I’m going to borrow a common singleton pattern: defining an object with our functionality inside of a self-executing function that then attaches itself to the window
. This is where I’ll define my routes and application-scope methods.
In the following example, my app
object contains four properties: routes
for defining each route by name, default
for defining the default (first-shown) root, routeChange
for handling a change of route (a hash change), and init
to be fired once to start the app (when JavaScript is available) using app.init()
.
(function() {
var app = {
// routes (i.e. views and their functionality) defined here
'routes': {
'some-view': {
'rendered': function() {
console.log('this view is "some-view"');
}
},
'another-view': {
'rendered': function() {
console.log('this view is "another-view"');
app.routeElem.innerHTML = '<p>This JavaScript content overrides the static content for this view.</p>';
}
}
},
// The default view is recorded here. A more advanced implementation
// might query the DOM to define it on the fly.
'default': 'the-default-view',
'routeChange': function() {
app.routeID = location.hash.slice(1);
app.route = app.routes[app.routeID];
app.routeElem = document.getElementById(app.routeID);
app.route.rendered();
},
// The function to start the app
'init': function() {
window.addEventListener('hashchange', function() {
app.routeChange();
});
// If there is no hash in the URL, change the URL to
// include the default view's hash.
if (!window.location.hash) {
window.location.hash = app.default;
} else {
// Execute routeChange() for the first time
app.routeChange();
}
}
};
window.app = app;
})();
app.init();
Notes
- The context for the current route is set within
app.routeChange
, using the syntaxapp.routes[app.routeID]
, whereapp.routeID
is equal towindow.location.hash.substr(1)
. - Each named route has its own
rendered
function, which is executed withinapp.routeChange
withapp.route.rendered()
. - The
hashchange
listener is attached to thewindow
duringinit
. - So that any JavaScript that should run on the default view when loading
https://my-app-thing.com
is run, I force that URL withwindow.location.hash = app.default
, thereby triggeringhashchange
to executeapp.routeChange()
, including the default route’srendered()
function. - If the user first hits the app at a specific hashed URL (like
https://my-app-thing.com#a-certain-view
), then this view’srendered
function will execute if one is associated with it. - If I comment out
app.init()
, my views will still “render,” will still be navigable, styled and animated, and will contain my static content.
One thing you could use the rendered
function for would be to improve keyboard and screen-reader accessibility by focusing the <h1>
. When the <h1>
is focused, it announces in screen readers which view the user is in and puts keyboard focus in a convenient position at the top of that view’s content.
'rendered': function() {
app.routeElem.querySelector('h1').setAttribute('tabindex', '-1');
app.routeElem.querySelector('h1').focus();
}
Another Codepen is available using this tiny app “framework.” There are probably neater and even terser(!) ways to write this, but all of the fundamentals are there to explore and rearrange. I’d welcome any suggestions for enhancement, too. Perhaps something could be achieved with hashchange
’s oldURL
property, which (for our purposes) references the previous route.
app.prevRoute = app.routes[e.oldURL.split("#")[1]];
Then, each route, in place of the singular rendered
function, could have entered
and exited
functions. Among other things, both adding and removing event listeners would then be possible.
app.prevRoute.exited();
Completely Static Views
The eagle-eyed among you will have noticed that the default view, identified in app.default
as the-default-view
, is not, in this case, listed in the app.routes
object. This means that our app will throw an error when it tries to execute its nonexistent rendered
function. The view will still appear just fine, but we can remove the error anyway by checking for the existence of the route first:
if (app.route) {
app.route.rendered();
}
The implication is that completely static “views” can exist, error-free, side by side with views that are (potentially) highly augmented by JavaScript. This breaks from single-page app normality, wherein you would forfeit the ability to serve static prerendered content by generating all of the content from scratch in the client — well, unless JavaScript fails and you render just a blank page. A lot of examples of that unfortunate behavior can be found on Sigh, JavaScript.
(Note: Because I actually have static content to share, I’ll want to add my app
script after the content at the bottom of the page, so that it doesn’t block its rendering… But you knew that already.)
Static Views With Enhanced Functionality
You could, of course, mix static and JavaScript-delivered content within the same view, too. As part of the rendered
function of a particular view, you could insert new DOM nodes and attach new event handlers, for instance. Maybe throw in some AJAX to fetch some fresh data before compiling a template in place of the server-rendered HTML. You could include a form that runs a PHP script on the server when JavaScript is unavailable and that returns the user to the form’s specific view with header('Location: https://my-app-thing.com#submission-form')
. You could also handle query parameters, using URLs like https://my-app-thing.com/?foo=bar#some-view
.
It’s entirely flexible, allowing you to combine any build tasks, server technologies, HTML structures and JavaScript libraries you wish. All that this approach does “out of the box” is to keep things on one web page in a responsible, progressive way.
Whatever you want to achieve, you have the option of attaching functions, data and other properties on either the global app scope (app.custom()
) or on specific views (app.routes['route-name'].custom()
), just like in a “real” single-page application. Your responsibility, then, is to blend static content and enhanced functionality as seamlessly as possible, and to avoid relegating your static content to being just a perfunctory fallback.
Conclusion
In this article, I’ve introduced a solution for architecting progressive single-page applications using little more than a couple of CSS tricks, less than 0.5 KB of JavaScript and, importantly, some static HTML. It is not a perfect or complete solution, just a modest skeleton, but it testifies to the notion that performant, robust and indexable single-page applications are achievable: You can embrace web standards while reaping the benefits of sharing data and functionality between different interface screens on a single web page. That is all that makes a single-page app a single-page app, really. Everything else is an add-on.
If you have any suggestions for improvements or want to raise any questions or concerns, please leave a comment. I’m not interested in building a “mature” (read: overengineered) framework, but I am interested in solving important problems in the simplest possible ways. Above all, I want us to help each other to make applications that are not just on the web, but of the web, too.
If you’re not sure what I mean by that or you’re wondering why it excites me so much, I recommend reading Aaron Gustafson’s Adaptive Web Design. If that’s too much for the moment, do yourself a favour and read the short article, “Where to Start” by Jeremy Keith.
Further Reading
- Perceived Performance
- Perception Management
- Preload: What is it good for?
- Getting Ready For HTTP/2
- Everything You Need To Know About AMP
- Improving Smashing Magazine’s Performance