Roll Your Own: A JavaScript Router





5.00/5 (12 votes)
Build your own JavaScript router
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) { // event supported?
window.onhashchange = function () {
console.log(window.location.hash.split('#')[1]);
}
else {
//Support starts at IE8, however be very careful to have a correct DOCTYPE,
//because any IE going in Quircksmode will report having the event, but never fires it.
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) {
//no action. probably just IE
}
}
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 routesfn
: The callback function we'll call when the route matchesscope
: The scope in which we'll fire the callback function
It will need at least one function, called matches
.
(function() {}
var Route=function() {
//We're assuming an object which contains our configuration.
//This would be in arguments[0]
//The route against which we'll match incoming routes
this.route=arguments[0].route;
//The callback function we'll call when the route matches
this.fn=arguments[0].fn;
//The scope in which we'll fire the callback function
this.scope=arguments[0].scope;
}
Route.prototype.matches=function(route) {
//this is somewhat to simple
if(route==this.route) {
return true;
}
else {
return false;
}
}
//We'll create the alias for route in the window object
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 againstapplyRoute
: matches all registered routes against an incoming route and fires ifcallbackfunction
istrue
(function() {
var Router=function() {
this.routes=new Array();
}
var Router.prototype={
//Here we use a somewhat different style of create the prototype
//than for the Route prototype. Both ways are valid.
//I'm using them mixed here, but It's probably wise not to do that.
//And stick to a single pattern. Here I'm doing it to show both possibilities
registerRoute: function(route, fn, paramObject) {
//We'll have route and function as named parameters
//and all the future optional parameters in a single object.
//Right now we just have scope as a optional parameters
var scope=paramObject?paramObject.scope?paramObject.scope:{}:{};
return this.routes[this.routes.length]=new Route({
route: route,
fn: fn,
scope: scope
});
},
applyRoute: function(route) {
//iterate all routes
for(var i=0, j=this.routes.length;i <j; i++) {
var sRoute=this.routes[i];
//match route
if(sRoute.matches(route)) {
//if true call callback function with the proper scope
sRoute.fn.apply(sRoute.scope);
}
}
}
}
//We'll create an alias for router in the window object
window["Router"]=Router;
//We'll create an instance of router in the window object
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"); //call home
router.applyRoute("about"); //call about
router.applyRoute("products"); //no reaction
Now we need to change our onhashchange
event handler.
if ("onhashchange" in window) { // event supported?
window.onhashchange = function () {
//we cut of the actual hash
router.applyRoute(window.location.hash.split('#')[1]);
}
else {
//Support starts at IE8, however be very careful to have a correct DOCTYPE,
//because any IE going in Quircksmode will report having the event, but never fires it.
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();
//Create the route arguments if they exist
this.routeParts=this.route.split("/");
for(var i=0, j=this.routeParts.length; i<j; i++) {
var rPart=this.routeParts[i]
//See if there are pseudo macro's in the route
//will fetch all {id} parts of the route. So the manditory parts
if(rPart.substr(0,1)=="{" && rPart.substr(rPart.length-1, 1) == "}") {
var rKey=rPart.substr(1,rPart.length-2);
this.routeArguments.push(rKey);
}
//will fetch all :id: parts of the route. So the optional parts
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) {
//We'd like to examen every individual part of the incoming route
var incomingRouteParts=route.split("/");
//This might seem strange, but assuming the route is correct
//makes the logic easier, than assuming it is wrong.
var routeMatches=true;
//if the route is shorter than the route we want to check it against we can immidiatly stop.
if(incomingRouteParts.length < this.routeParts.length-this.optionalRouteArguments.length) {
routeMatches false;
}
else {
for(var i=0, j=incomingRouteParts.length; i<j && routeMatches; i++) {
//Lets cache the variables, to prevent variable lookups by the JavaScript engine
var iRp=incomingRouteParts[i];//current incoming Route Part
var rP=this.routeParts[i];//current routePart
if(typeof rP=='undefined') {
//The route almost certainly doesn't match it
//is longer than the route to check against
routeMatches=false;
}
else {
var cP0=rP.substr(0,1); //char at position 0
var cPe=rP.substr(rP.length-1, 1);//char at last position
if((cP0!="{" && cP0!=":") && (cPe != "}" && cPe != ":")) {
//This part of the route to check against is not a pseudo macro,
//so it has to match exactly
if(iRp != rP) {
routeMatches=false;
}
}
else {
//Since this is a pseudo macro and there was a value at this place.
//The route is correct.
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) {
//Split the incoming route
var rRouteParts=route.split("/");
//Create an array for the values
var rArray=new Array();
for(var i=0, j=this.routeParts.length; i < j; i++) {
var rP=this.routeParts[i];//current routePart
var cP0=rP.substr(0,1); //char at position 0
var cPe=rP.substr(rP.length-1, 1);//char at last position
if((cP0=="{" || cP0==":" ) && (cPe == "}" || cPe == ":")) {
//if this part of the route was a pseudo macro,
//either manditory or optional add this to the array
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.
//Change this part;
if(sRoute.matches(route)) {
//if true call callback function with the proper scope
sRoute.fn.apply(sRoute.scope);
}
//Into this;
if(sRoute.matches(route)) {
//if true call callback function with the proper scope and send in the variables
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:{}:{};
//Add this line
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: {};
//the rest of the constructor
Lastly, we'll change our matches
function.
//This is the part we need to change
else {
//Since this is a pseudo macro and there was a value at this place. The route is correct.
routeMatches=true;
}
//the change
else {
//Since this is a pseudo macro and there was a value at this place. The route is correct.
//But a rule might change that
if(this.rules!=null) {
var rKey=rP.substr(1,rP.length-2);
//RegExp will return as object. One more test required
if(this.rules[rKey] instanceof RegExp) {
routeMatches=this.rules[rKey].test(iRp);
}
//Array will return as object
if(this.rules[rKey] instanceof Array) {
if(this.rules[rKey].indexOf(iRp) < 0) {
routeMatches=false;
}
}
if(this.rules[rKey] instanceof Function) {
//getArgumentsObject see example package
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.