Page loads suck. They interrupt the user experience and they're slow, yet somehow it seems they aren't a problem that developers have devoted much time to solving. When I decided to make this site, I decided that I wanted to eliminate page loads as much as possible and went out and had a look around for solutions, but didn't find a lot, so I decided to create my own solution.
I didn't want to resort to creating a full single-page app or using a full JS routing module – The SPA route threw up too many issues for me with SEO and it conflicted pretty heavily with my preference for progressive enhancement. Using a routing engine on top of a static site and rendering out the entire site as JSON which could be parsed and loaded from this engine was a realistic option for a while, but the burden of loading all the html for the site in one go (wrapped up in that JSON object) would grow as the site grew. Should I ever get up to the point where I have hundreds of blog posts, the solution would quickly become untenable.
I needed something else. Something that would allow me to stick to my static website guns, that would just "smooth out the creases" in the default page loading process, without interfering too much with anything else. It had to be performant, and it had to allow the site to grow infinitely without presenting any significant performance issues.
Eventually, I settled on using a javascript worker to load internal links within pages in the background, and cache them in js. This approach has a number of benefits:
- It can be used on any relatively static website (meaning it could reasonably be used on a Wordpress blog, for example, or anywhere there isn't a lot of dynamic content)
- SEO is completely unaffected – The pages are all there just as normal, they are just being loaded in a different way
- It is fully progressively enhanced – For the same reason as above, the site still functions completely normally should you disable javascript
Downsides?
You might think that doing something like this would be massively inefficient in terms of data usage and, in a way, you would be correct. But the worker is only fetching the html of the pages, not the additional page load of media which goes to make our standard page weights so high nowadays, so in reality it is only fetching a few kb of data for each page it finds. It also only fetches links as it finds them within pages as a user browses, so it will load the site progressively rather than all in one go.
Here's the clincher, though: The AJAX calls fully respect usual HTTP caching rules, which means that once pages have been fetched on one visit to the site, subsequent visits will cause the pages to load from the browser cache rather than being loaded from the server again.
Cygnus was born
I showed what I was working on to a couple of people and they seemed pretty enthusiastic about it. Jeff Escalante (Thanks, Jeff) at Carrot, who is the guy behind Roots, the static site generator I use for this site among others, was particularly enthusiastic and offered to help me get it ready to release as an NPM module. So, after converting the script from the original coffeescript into ES6, and making a few changes, Cygnus was released to the public.
Requirements?
The requirements and compatibility are pretty decent - The only requirements for Cygnus to be able to run in a browser are promises
, workers
and the history
api. Oh, and Object.assign()
. And, as I said previously, Cygnus is fully progressively enhanced, so if any of these aren't there (if you're still using IE, for example... Shame on you!) then your site will just fall back to default page loading behaviour.
How does it work?
Using Cygnus on your website is easy. First of all, you need to install it:
npm i --save cygnus
and then once you have installed it for your project, you need to require
it and call the init()
method:
const cygnus = require('cygnus');
cygnus.init();
That's about it, at the most basic level. Cygnus will start looking for local links in the pages of your site and caching them in the background.
Because of the way Cygnus works, we don't actually replace the whole page when a link is clicked – We use a wrapper element to target where to replace content. This means that we don't have to replace everything. If there are parts of your site that are always the same, such as your header and footer, you can target just a wrapper element around your main content section.
By default, Cygnus will look for a container in each of your pages with a class of wrap
. You can override this should you want to by passing an options object in to the init()
method like so:
cygnus.init({ contentWrapper : '.your-selector' });
Making it fancy
As you can see from this site, it is also possible to use page transitions with Cygnus. This will take a little more work, but not much on the Cygnus side.
Cygnus allows for transitions, but doesn't attempt to manage them itself (because it's better for a module to do one thing and do it well). Instead, you are free to use any animation library you like to build your transitions. I've chosen to use Anime.js, but you could equally use GSAP or Velocity for example. The only requirements are as follows:
- Your transitions should be in the form of functions accessible from the global scope. Namespace them, absolutely, but we're calling them from outside of their own context, so we need to have them publicly available.
- Your transition functions must return a promise that resolves when then animation completes, as in the example below. This is so that Cygnus knows when each stage of the transition process has completed and is ready for the next.
Animations can be specified both for "intro" (page load) and "outro" (page unload) states, and can be individually specified on a per-page basis. Obviously, if your transition functions create elements (again, as in the example below), you should obviously make sure to remove those elements when you no longer need them.
Let's take a look at an example – The animations for this site:
module.exports = {
intros: {
"default": function() {
return new Promise(function(resolve, reject) {
var animation, shim;
shim = document.querySelector(".shim");
shim.setAttribute("style", "position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: #E45353; line-height: 0; z-index: 5;");
return animation = anime({
targets: shim,
height: "0",
duration: 500,
delay: 200,
easing: "easeInQuint",
complete: function() {
shim.parentNode.removeChild(shim);
return resolve(true);
}
});
});
}
},
outros: {
"default": function() {
return new Promise(function(resolve, reject) {
var animation, shim;
shim = document.createElement("div");
shim.setAttribute("class", "shim");
shim.setAttribute("style", "position: fixed; bottom: 0; left: 0; width: 100%; height: 0; background-color: #E45353; line-height: 0; z-index: 5;");
document.body.appendChild(shim);
return animation = anime({
targets: shim,
height: "100%",
duration: 500,
easing: "easeInQuint",
complete: function() {
return resolve(true);
}
});
});
}
}
};
As you can see, this code is structured in a module pattern. In my main.js
file, I require
this module and then expose it to the global scope, attaching it to window
. That means that, for example, the "intro" transition can be accessed via anims.intros.default()
.
To actually use these transitions, all you have to do is add a couple of data attributes to your body
tags:
<body data-intro="anims.intros.default" data-outro="anims.outros.default">
Cygnus will look for these data attributes and use the animation functions referenced as transitions between pages.
Custom page stylesheets
Because it is something that I personally wanted to be able to do, I added the ability for cygnus to pick up both custom, per-page stylesheets and body classes and inject them as it loads each page. This allows sites like this one to have a more "editorial" feel to them with only a minimum of effort.
Classes on the <body>
tag are picked up automatically, but to use per-page css files you will need to add a data attribute to them for Cygnus to pick them up. This may change in the future, but in my initial experiments it seemed more performant to use a selector to find only the specific tags we want to include, rather than fetching all of them and comparing with what we already have.
So, to add per-page css files, you can simply include your stylesheet as normal, but with an added data attribute:
<link rel="stylesheet" data-rel="page-css" href="path/to/your/page/stylesheet.css">
Cygnus theoretically supports any number of these stylesheets in a page, but obviously you probably only want one, really.
Javascript
Javascript with these kinds of modules is difficult. Because Cygnus hijacks the default page load behaviour, script doesn't load and execute on each page in the same way as usual. This means you have to slightly alter the way you would normally do things.
Cygnus doesn't support per-page JS loading (yet), so it is best to bundle your javascript up into a single file. Whilst the normal ready()
events won't fire on page load because of the different behaviour, Cygnus does fire off its own custom event to allow you to attach javascript that runs on page load like this:
const initPage = () => {
doSomeStuff();
}
window.addEventListener("cygnusPageLoaded", (e) => {
initPage();
console.log(e.detail.page) // Logs out location.pathname of loaded page
});
You will notice that Cygnus exposes a property in detail
of page
, containing the path of the loaded page. This is here to allow you to attach certain JS only to certain pages should you need to.
I realise that this isn't the perfect way to deal with JS, and I plan to spend some time looking in to how this can be achieved better in the future. You never know, maybe someone in the community might be able to suggest a better way? In the mean time, this gets the job done.
Wrapping up
So, that's about it. Cygnus is available now on NPM, and the code is available on GitHub if you want to fork it yourself, suggest changes or raise issues. There are a few things I want to change: I think it would be a good idea to switch out the xmlHttpRequest
that I'm using at the moment for the more modern fetch
, and the worker script is currently built right in the src version of the main script. I would rather this was pulled in from a separate file at build time, but haven't been able to sort that yet, as it needs to be included as an ES6 template literal.
The other big thing that I haven't dealt with properly yet is javascript. I am planning to add the ability for Cygnus to load and add per-page javascript files in the same way it already does for CSS files, but I just haven't gotten around to doing it yet. It can already (kindof) handle js that runs on each page load, so this would kind of complete that particular puzzle.
I'm generally pretty proud of the work, though, and I think that Cygnus could potentially be useful for a lot of people. Either way, it's out there now, so we'll see.