Click here to Skip to main content
Click here to Skip to main content

Roll your own: A JavaScript router

, 28 Mar 2013 CPOL
Rate this:
Please Sign up or sign in to vote.
Build your own JavaScript router.

Introduction

If you've ever build 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 carefull 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 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() {
        //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 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={
        //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's longer than the route to check against
                routeMatches=false;   
            }
            else    {
                var cP0=rP.substr(0,1); //char at postion 0
                var cPe=rP.substr(rP.length-1, 1);//char at last postion                   
                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 postion 0
        var cPe=rP.substr(rP.length-1, 1);//char at last postion
        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 our 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 Wink

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.

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)

Share

About the Author

Sebastiaan Meijerink
Software Developer (Senior) none
Netherlands Netherlands
I'm a developer with 12+ years of experience. Starting of on a MVS mainframe, moving to building big multi-tier ERP systems with unix backends, to building web-based BI-Portals. I've seen a lot of different languages, environments and paradigmes.
 
At this point my main interest is webdevelopment. Mainly javascript and ASP.NET. But I also like getting my hands dirty on some PHP.
 
I've been a member of CodeProject for many years, but only recently started writing some articles. I hope someone will enjoy them.

Comments and Discussions

 
GeneralMy vote of 5 PinmemberTridip Bhattacharjee14-Jul-14 2:50 
Questionjs errors PinmemberMember 1029290923-Sep-13 13:32 
GeneralMy vote of 5 Pinmembercyberkrul22-Feb-13 5:42 
GeneralMy vote of 5 Pinmemberaman.tur13-Feb-13 3:56 
GeneralRe: My vote of 5 PinmemberSebastiaan Meijerink13-Feb-13 5:36 
GeneralMy vote of 5 Pinmemberonurag1913-Feb-13 3:26 
GeneralMy vote of 5 PinmemberSpiff Dog19-Nov-12 9:49 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.

| Advertise | Privacy | Terms of Use | Mobile
Web02 | 2.8.141216.1 | Last Updated 28 Mar 2013
Article Copyright 2012 by Sebastiaan Meijerink
Everything else Copyright © CodeProject, 1999-2014
Layout: fixed | fluid