One of my colleagues is working on a Single Page Application, he asked me for help on a solid design for routing and specifically about sub-routing. Sub-routing in a SPA is complex, as an example when you click a link in a list you want that item to be presented in a modal window. If you close the modal window and press back in the history you want it to show again, and if you press forward you want it to close. I choose to attack this problem with an Event Aggregation approach, where changes to the route resulted in a change event being fired to any listener.
First we need a JavaScript Event aggregator, you can either roll your own or use an existing, for example I have one in my SignalR.EventAggregatorProxy library. Next we need to utilize SammyJS to listen to route changes.
var onNav = this.onNavigation.bind(this); Sammy(function (sammy) { sammy.before(null, function(route) { startRoute = startRoute || route; if(this.modelChanged(route.params) && this.view() && this.view().destroy) { this.view().destroy(); } }.bind(this)); sammy.get("#:model", onNav); sammy.get("", function(route) { if(route.path === startRoute.path) { //We are still on the single page app onNav(route); } else { //We are leaving the single page app for an external link location.assign(route.path); } }.bind(this)); }.bind(this)).run();
We use the sammy.get to listen to any changes to the route, first we use it to listen to any route starting with a hash like #MyView. We use this to resolve the model name of the view. We also need to listen to empty routes, this will fire when you navigate to the Home view. Sadly this will also trigger for any other link including external links. We use a little hack to determine if we should stay on the page or leave it.
Most of the magic happens in the onNavigation function
onNavigation: function(route) { var params = this.cloneParams(route.params); var onLoaded = function() { var changes = this.compareParams(params); Demo.eventAggregator.publish(changes); this.lastParams = params; }.bind(this); if(this.modelChanged(params)) { this.loadModel(params.model, onLoaded); } else { onLoaded(); } }
First we clone the sammy parameters because they contain stuff we dont want. We creata a onLoaded callback and finally we check if the main model have changed since last routing. We need to do this because sub routes and main routes looks the same to Sammyjs. If its a main route we load the model which in turn load call the onLoaded. If its a sub route we only call the onLoaded callback which will get the changes to the params objects since the last route. And then publish it on the event aggregator bus. The compareParams function.
compareParams: function(params) { var changed = {}; var removed = {}; var curr = params; var old = this.lastParams; for(var i in curr) { if(curr[i] !== old[i]) { changed[i] = curr[i]; } } for(var i in old) { if(curr[i] === undefined) { removed[i] = old[i]; } } return new Demo.Events.Routing(changed, removed); }
The result will be a object containing changed and removed sub routes.
Any listener will receive the event and can act on it, this decoupled way of notifying sub route changes makes it easy for components like tab menus and modal popups to show and hide.
Here is a simple Demo of the technique, it shows details about a entity if you click on a item in a list. Navigate the main menu to the Test page and then click on some of the items. Use the browsers history buttons to navigate forward and backward. You can also direct link to a page and the correct item will show, try it here.
The Demo is missing one important component, helper methods to generate routing links.