Introduction
If you've ever built a single page AJAX site, you've probably ran into some problems with the navigation. For one, the standard browser history is broken. Next to that, having no experience, I started off with URLs that looked like:
<a href='javascript:void(0);' onclick='somefunction(this);'>SomeSubPage</a>
That's terrible, even though crawlers don't yet fully get AJAX sites, this will surely break them. Next to that, the click event in mobile browsers like the Android browser will be fired after the touchEnd
, resulting in an annoying 300 ms delay.
We need to find a way to have href
properties that are valid, but do not reload the page. Enter the #(hash) part of the URL. Changing the hash of a URL will not reload the page. But the history will affected. So we could obviously just bind to the onhashchanged
event and parse the URL, run some AJAX, and update the page. Even though this would be fine for a small site, if things get more complicated, that won't fly. We'll need some sort of URL router.
There are a few good ones out there. crossroads, director, and backbone.js also contain an excellent router. But slapping out some ready made library is no fun at all. I encourage the use of frameworks, but before you do, always try to make your own. It will give you a better understanding if something goes wrong.
What Will Our Links Look Like?
<a href="#home">home</a>
<a href="#home/about">about</a>
<a href="#products/">products</a>
So if home is clicked, we'd like to run a route that is exactly home. This route should get /home from the backend server.
The onhashchanged Event
First, we'll need to detect that the URL hash has changed. This is done by binding to the onhashchanged
event.
if ("onhashchange" in window) {
window.onhashchange = function () {
console.log(window.location.hash.split('#')[1]);
}
else {
console.log("hashchanging not supported!")
}
Please do note that writing to console.log on IE gives an exception when the log isn't open. Whoever came up with that! So let's write our little log function to prevent this from happening.
function log(message) {
try {
console.log(message)
}
catch(err) {
}
}
The Route
We'll stick the whole thing in router.js. In that, we'll start off with an anonymous function. First, we'll focus on the Route prototype. This is the part which will do the actual matching of the incoming route. A route has three primary arguments.
route
: The route against which we'll match incoming routes fn
: The callback function we'll call when the route matches scope
: The scope in which we'll fire the callback function
It will need at least one function, called matches
.
(function() {}
var Route=function() {
this.route=arguments[0].route;
this.fn=arguments[0].fn;
this.scope=arguments[0].scope;
}
Route.prototype.matches=function(route) {
if(route==this.route) {
return true;
}
else {
return false;
}
}
window["Route"]=Route;
)();
The Router
Our route prototype is a bit simple. It can only match exact routes. But we'll first setup the skeleton before we make this more useful. In order to finish our skeleton, we'll need the Router prototype itself. This needs at least two functions:
registerRoute
: adds a new route to match incoming routes against applyRoute
: matches all registered routes against an incoming route and fires if callbackfunction
is true
(function() {
var Router=function() {
this.routes=new Array();
}
var Router.prototype={
registerRoute: function(route, fn, paramObject) {
var scope=paramObject?paramObject.scope?paramObject.scope:{}:{};
return this.routes[this.routes.length]=new Route({
route: route,
fn: fn,
scope: scope
});
},
applyRoute: function(route) {
for(var i=0, j=this.routes.length;i <j; i++) {
var sRoute=this.routes[i];
if(sRoute.matches(route)) {
sRoute.fn.apply(sRoute.scope);
}
}
}
}
window["Router"]=Router;
window["router"]=new Router();
)();
Now we've got a router. A very, very simple router at that. It can only match exact routes. But we can drive some test through it.
router.registerRoute("home", function() {
log("call home");
});
router.registerRoute("about", function() {
log("call about");
});
router.applyRoute("home");
router.applyRoute("about");
router.applyRoute("products");
Now we need to change our onhashchange
event handler.
if ("onhashchange" in window) {
window.onhashchange = function () {
router.applyRoute(window.location.hash.split('#')[1]);
}
else {
console.log("hashchanging not supported!")
}
Now we can put links in our pages that will use these routes.
<a href="#home" >Home </a>
<a href="#about" >About</a>
The Matches Function
We might have a router now. But it's kind of useless. We'll need some more complex matching procedures. We'd like to create routes like:
- products/{productid}
- products/:productid:
- home/{subpage}
In the case of products/{productsid}, we'll want product IDs to come in as a variable of our function. :productsid:
should also call the product's callback if it's empty. The home route may only be followed by about or contact.
So let's make our matches in the Route
object a little smarter.
First, we need to examine the route that has been given in the constructor of the Route
class.
var Route=function() {
this.route=arguments[0].route;
this.fn=arguments[0].fn;
this.scope=arguments[0].scope ? arguments[0].scope : null;
this.rules=arguments[0].rules ? arguments[0].rules: {};
this.routeArguments=Array();
this.optionalRouteArguments=Array();
this.routeParts=this.route.split("/");
for(var i=0, j=this.routeParts.length; i<j; i++) {
var rPart=this.routeParts[i]
if(rPart.substr(0,1)=="{" && rPart.substr(rPart.length-1, 1) == "}") {
var rKey=rPart.substr(1,rPart.length-2);
this.routeArguments.push(rKey);
}
if(rPart.substr(0,1)==":" && rPart.substr(rPart.length-1, 1) == ":") {
var rKey=rPart.substr(1,rPart.length-2);
this.optionalRouteArguments.push(rKey);
}
}
}
Now we have split every part of the route into individual parts to examine.
Route.prototype.matches=function(route) {
var incomingRouteParts=route.split("/");
var routeMatches=true;
if(incomingRouteParts.length < this.routeParts.length-this.optionalRouteArguments.length) {
routeMatches false;
}
else {
for(var i=0, j=incomingRouteParts.length; i<j && routeMatches; i++) {
var iRp=incomingRouteParts[i];
var rP=this.routeParts[i];
if(typeof rP=='undefined') {
routeMatches=false;
}
else {
var cP0=rP.substr(0,1);
var cPe=rP.substr(rP.length-1, 1);
if((cP0!="{" && cP0!=":") && (cPe != "}" && cPe != ":")) {
if(iRp != rP) {
routeMatches=false;
}
}
else {
routeMatches=true;
}
}
}
}
}
return routeMatches;
}
Testing What We've Got So Far
We can create routes and we can test if they match. So let's do some tests:
Let's stick a few lines of code in our html body:
<script>
router.registerRoute("home/:section:", function() {
console.log("home/:section: route true");
});
router.registerRoute("products/{productid}", function() {
console.log("products/{productid} route true");
});
</script>
<a href="#home">Home</a>
<a href="#home/about">About</a>
<a href="#home/contact">Contact</a>
<a href="#products">Products</a>
<a href="#products/5">Product 5</a>
This is great. Route 0,1,2, and 4 are correct. Route 3 isn't, because productid
was mandatory. But what is the product id? Or what is the section in the home route? It's created so that we can now determine if a route is correct, but then what? Obviously, we'll need functionality that can give us these values as input values of our return function.
Getting the Values of Pseudo Macros in the Route
We'll have to introduce a new function in our Route
prototype: getArgumentsValues
. This should give us an array with the values in it. These we'll send in the order of appearance to our callback function. But first the function itself.
Route.prototype.getArgumentsValues=function(route) {
var rRouteParts=route.split("/");
var rArray=new Array();
for(var i=0, j=this.routeParts.length; i < j; i++) {
var rP=this.routeParts[i];
var cP0=rP.substr(0,1);
var cPe=rP.substr(rP.length-1, 1);
if((cP0=="{" || cP0==":" ) && (cPe == "}" || cPe == ":")) {
rArray[rArray.length]=rRouteParts[i];
}
}
return rArray;
}
Now the router is the one starting our function. So let's change the part where it does that.
if(sRoute.matches(route)) {
sRoute.fn.apply(sRoute.scope);
}
if(sRoute.matches(route)) {
sRoute.fn.apply(sRoute.scope, sRoute.getArgumentsValues(route));
}
Now we can change our test code to this:
router.registerRoute("home/:section:", function(section) {
console.log("home/:section: route true, section=" + section);
});
router.registerRoute("products/{productid}", function(productid) {
console.log("products/{productid} route true, productid=" + productid);
});
One Last Sharpening of the Knife
I'm still not 100% happy. The reason is that I'd like to check if the values of the pseudo-macros are valid before I send them to the callback function. Of course I could check if product ID is in fact a number, but I'd rather have the route fail if it's not. So far we've been coming up with the functional need by ourselves, but I did have a couple of routers in the back of my head. One of them is crossroads. So let's see what they have for this problem. Let's take a look: http://millermedeiros.github.com/crossroads.js/#route-rules.
It says: Validation rules can be an Array, a RegExp, or a Function:
- If rule is an Array, crossroads will try to match a request segment against the items of the Array; if an item is found the parameter is valid.
- If rule is a RegExp, crossroads will try to match a request segment against it.
- If rule is a Function, crossroads will base validation on value returned by Function (should return a Boolean).
That shouldn't be too hard.
Rules
Let's start at the router:
registerRoute: function(route, fn, paramObject) {
var scope=paramObject?paramObject.scope?paramObject.scope:{}:{};
var rules=paramObject?paramObject.rules?paramObject.rules:null:null;
return this.routes[this.routes.length]=new Route({
route: route,
fn: fn,
scope: scope,
rules: rules
})
},
Now we'll change the constructor of our Route
to consume the rules object.
var Route=function() {
this.route=arguments[0].route;
this.fn=arguments[0].fn;
this.scope=arguments[0].scope ? arguments[0].scope : null;
this.rules=arguments[0].rules ? arguments[0].rules: {};
Lastly, we'll change our matches
function.
else {
routeMatches=true;
}
else {
if(this.rules!=null) {
var rKey=rP.substr(1,rP.length-2);
if(this.rules[rKey] instanceof RegExp) {
routeMatches=this.rules[rKey].test(iRp);
}
if(this.rules[rKey] instanceof Array) {
if(this.rules[rKey].indexOf(iRp) < 0) {
routeMatches=false;
}
}
if(this.rules[rKey] instanceof Function) {
routeMatches=this.rules[rKey](iRp, this.getArgumentsObject(route), this.scope);
}
}
}
So now, we can add a rules
object to make productid
a number.
router.registerRoute("home/:section:", function(section) {
console.log("home/:section: route true, section=" + section);
});
router.registerRoute("products/{productid}", function(productid) {
console.log("products/{productid} route true, productid=" + productid);
},
{
rules: {
productid: new RegExp('^[0-9]+$');
}
});
Conclusion
Routers are complex creatures, but with some common sense we can make one ourselves. Maybe it's better to use a readymade product, but I'm a believer in DIY. Very often, I will DIY some set off functionality, just to throw it away and include a library. But the difference will be that from then on, the library doesn't seem to be a magic box anymore. And next to that, sometimes I'll need just 10% of what a library will give me. Why should I include the other 90% as ballast?
The source zip file contains the router.js which you can use to build a simple AJAX site. Or maybe just to look at and learn and then just include crossroads.js.
I hope you all enjoyed this article. Happy hacking!
History
- 13-02-2013: Fixed a few typos in the article. The download hasn't changed.