Pilot: a multifunctional JavaScript router

With every passing day websites are becoming increasingly complex and dynamic and simply making the interface live is usually not enough – we often need to create a full-fledged one-page application. A great example of such application is any webmail (take Mail.Ru. for example), where clicking on a link doesn’t reload the page, but rather changes representation. This means that the task of data retrieval and display, depending on the route, which has always been the prerogative of the server, is now the responsibility of the client. This problem is usually solved with a simple router, based on regular expressions, and isn’t developed any further, while at the back-end this topic is paid a lot more attention. In this article I’ll make an attempt to fill this gap.


What is routing?
This is probably the most underrated part of a JavaScript application:]

Server routing  is a process of determining the route within the application, depending on the request. Simply put, it’s a search for the controller based on the requested URL and the implementation of appropriate actions.

Let’s consider the following task: we need to create a one-page “Gallery" application that will consist of three screens:
  • Home – choosing the type of painting
  • Gallery view – displaying paintings with by-page navigation and the possibility to change the number of items per page
  • Detailed view of the selected work

Schematically, the application will look like this:


<div id="app"> <div id="app-index" style="display: none">...</div> <div id="app-gallery" style="display: none">...</div> <div id="app-artwork" style="display: none">...</div> </div>

Each screen will have a corresponding URL and a router describing them might look like this:    



var Router = { routes: { "/": "indexPage", "/gallery/:tag/": "galleryPage", "/gallery/:tag/:perPage/": "galleryPage", "/gallery/:tag/:perPage/page/:page/": "galleryPage", "/artwork/:id/": "artworkPage", } };

The following routes are defined directly in the `routes` object: key - path template, value - the name of the controller function.

Next we need to convert the `Router.routes` object keys into regular expressions. To do this we define the `Router.init` method:


var Router = { routes: { /* ... */ }, init: function (){ this._routes = []; for( var route in this.routes ){ var methodName = this.routes[route]; this._routes.push({ pattern: new RegExp('^'+route.replace(/:\w+/, '(\\w+)')+'$'), callback: this[methodName] }); } } };

What’s left is to describe the navigation method that will search for the route and call the controller:

var Router = { routes: { /* … */ }, init: function (){ /* … */ }, nav: function (path){ var i = this._routes.length; while( i-- ){ var args = path.match(this._routes[i].pattern); if( args ){ this._routes[i].callback.apply(this, args.slice(1)); } } } };

When everything is ready, initialize the router and set the starting navigation point. It is important to remember to intercept the `click` event from all links and redirect it to the router.

Router.init(); Router.nav("/"); // Intercepts clicks $("body").on("click", "a", function (evt){ Router.nav(evt.currentTarget.href); evt.preventDefault(); });

As you see, this is a no-brainer and I think many of you are familiar with this approach. Usually, all the differences in the implementation come down to the format of the route record and its transformation into a regular expression.



Let's go back to our example. The only thing it’s missing is the implementation of functions responsible for route processing. Typically, they perform data collection and rendering, for example:

var Router = { routes: { /*...*/ }, init: function (){ /*...*/ }, nav: function (url){ /*...*/ }, indexPage: function (){ ManagerView.set("index"); }, galleryPage: function (tag, perPage, page){ var query = { tag: tag, page: page, perPage: perPage }; api.find(query, function (items){ ManagerView.set("gallery", items); }); }, artworkPage: function (id){ api.findById(id, function (item){ ManagerView.set("artwork", item); }); } };


It seemingly looks good but naturally has its  pitfalls. Data fetching is asynchronous, and if you quickly switch between the routes the results can be quite different from what was expected initially. For example, consider this: a user clicks on the link of the second gallery page, but while it’s being loaded, he got interested in the painting on the first page and decided to view it in detail. As a result the user sent two requests. They can work in a random order, and instead of the painting the user will see the second gallery page.

This problem can be solved in different ways, which are totally up to the developer. For example, we might `abort` the previous request, or transfer the logic to `ManagerView.set`.

What does the `ManagerView` do? The `set (name, data)` method accepts two parameters: the name of the “screen” and “data” to build it. In our case, the task has been greatly simplified, and the `set` method displays the necessary item by id. It uses the type name as an “app-“+ name` suffix and the data – for html coding. Also `ManagerView` must remember the name of the previous screen and determine when the route started/changed/ended to correctly manipulate the representation.

This way we have created a one-page application, with its `Router` and `ManagerView`, but in time we’ll need to add new features. Take the “Articles” section, for example, with “work” descriptions and links to them. The “work” view page should contain a “Back to the article,” or “Back to the gallery” link depending on where the user came from. But how to create it? Neither `ManagerView`, nor `Router` have such data.

Another important aspect is the links. Page navigation, links to sections, etc, how are they set up? By building into the code directly? By creating a function that will return the URL by mnemonics and parameters? The first option is bad, the second is better, but not perfect. From my point of view, the best option is a possibility to define the route `id` and the method allowing to retrieve the URL by ID and parameters. The advantage here is that the route and the rule for URL generation are the same thing, besides this option does not lead to duplication of the URL retrieval logic.

As you can see, this router doesn’t solve the tasks at hand, so to avoid re-inventing a bicycle, I sought to use a search engine, coming up with a list of requirements to a router that would perfectly suit my needs:

  • maximum flexibility of the route description syntax (like that of Express)
  • working with the request, not just individual parameters (as in the example)
  • route “start,” “change” and “end” events (/gallery/cubism/ -> /gallery/cubism/12/page/2 -> /artwork/123/)
  • assigning multiple processors to a single route
  • appointing IDs to routes and the possibility to navigate by them
  • another way of `data ←→ view` interaction (if possible)


As you may have guessed, I didn’t find what I was looking for, though I came across some very worthy solutions like:
  • Crossroads.js – a very powerful tool for working with routes
  • Path.js – has the implementation of the rout “start” and “end” events, 1KB (Closure compiler + gzipped)
  • Router.js is simple, functional and weighs only 443 bytes (Closure compiler + gzipped)



Pilot
And now it's time to do the same thing, but using Pilot. It consists of three parts:
  • Pilot – the router itself
  • Pilot.Route – the route controller
  • Pilot.View – advanced route controller that inherits Pilot.Route

Let’s define the controllers responsible for routes. The application’s HTML structure remains the same as in the example in the beginning of the article.


// Object where the controllers will be stored var page = {};

// Controller for the home page pages.index = Pilot.View.extend({ el: "#app-index" }); // Gallery view pages.gallery = Pilot.View.extend({ el: "#app-gallery", template: function (data/**Object*/){ /* template generation based on this.getData() */ return html; }, loadData: function (req/**Pilot.Request*/){ // app.find — returns $.Deferred(); return app.find(req.params, this.bound(function (items){ this.setData(items); })); }, onRoute: function (evt/**$.Event*/, req/**Pilot.Request*/){ // Method is called at routestart and routechange this.render(); } }); // Work view pages.artwork = Pilot.View.extend({ el: "#app-artwork", template: function (data/**Object*/){ /* template generation based on this.getData() */ return html; }, loadData: function (req/**Pilot.Request*/){ return api.findById(req.params.id, this.bound(function (data){ this.setData(data); })); }, onRoute: function (evt/**$.Event*/, req/**Pilot.Request*/){ this.render(); } });

Switching between routes entails changing the screens, so in this example I’m using Pilot.View. In addition to working with DOM elements, an instance of its class is initially subscribed to the routestart and routeend events. With the help of these events Pilot.View controls the display of an associated DOM element, assigning `display: none` or eliminating it. The node itself is assigned through the `el` parameter.

There are three types of events: routestart, routechange and routeend. They are called by the router on the controller(s). Schematically it looks like this:

There are three routes and their controllers:
"/" -- pages.index "/gallery/:page?" -- pages.gallery "/artwork/:id/" -- pages.artwork

Each route can have multiple URLs corresponding to it. If the new URL corresponds to the current route, the router generates a routechage event. If the route has changed, its controller gets the routeend event, and the controller of the new one – the routestart event.


"/" -- pages.index.routestart "/gallery/" -- pages.index.routeend, pages.gallery.routestart "/gallery/2/" -- pages.gallery.routechange "/gallery/3/" -- pages.gallery.routechange "/artwork/123/" -- pages.artwork.routestart, pages.gallery.routeend

In addition to changing the visibility of the container (`this.el`), as a rule, you will need to update its contents. Pilot.View allows doing this with the following methods that should be re-defined depending on the task:

template(data) – a method of template generation, inside of which the HTML is generated. The example uses the data from the loadData.

loadData(req) - perhaps the most important the controller method. It is called every time the URL gets changed and receives the request object as a parameter.  It has one special feature: if $. Deferredis returned, the router will not switch to this URL, until the data is collected.
req – request:
{ url: "http://domain.ru/gallery/cubism/20/page/3", path: "/gallery/cubism/20/page/123", search: "", query: {}, params: { tag: "cubism", perPage: 20, page: 123 }, referrer: "http://domain.ru/gallery/cubism/20/page/2" }

onRoute(evt, req) – an auxiliary event. Called after routestart or routechange. Is used in the example to update the contents of the container by calling the render method.

render() – a method for updating the container HTML (`this.el`). Calls this.template (this.getData ()).

All we have to do now is assemble the application. To do this we need a router:

var GuideRouter = Pilot.extend({ init: function (){ // Set routes and their controllers: this .route("index", "/", pages.index) .route("gallery", "/gallery/:tag/:prePage?(/page/:page/)?", pages.gallery) .route("artwork", "/artwork/:id/", pages.artwork) ; } }); var Guide = new GuideRouter({ // Specify the element inside of which we intercepts clicks on links el: "#app", // Use HistoryAPI useHistory: true }); // Run the router Guide.start();

First we create a router and define routes in the `init` method. The route is defined by the `route` request. It accepts three parameters: route id, pattern and controller.

I’ll admit that the syntax of the route was borrowed from Express. It was suitable in every way, and it will be easier for those who have already worked with Express. I added groups though, they increase the flexibility of route pattern configuration and help when navigating by id.

Let’s look closer at the route responsible for the gallery:

// The expression in brackets is the group this.route("gallery", "/gallery/:tag/:prePage?(/page/:page/)?", …) // Brackets allow to select the part of the pattern that is associated with the `page` variable. // If it hasn’t been defined, the entire block is not taken into account. Guide.getUrl("gallery", { tag: "cubism" }); // "/gallery/cubism/"; Guide.getUrl("gallery", { tag: "cubism", page: 2 }); // "/gallery/cubism/page/2/"; Guide.getUrl("gallery", { tag: "cubism", page: 2, perPage: 20 }); // "/gallery/cubism/20/page/2/";


The result is very convenient: the route and the URL is the same thing. This allows avoiding the explicit URLs in the code and the necessity to create additional methods for URL generation. Guide.go (id, params) is used to navigate to the necessary route.


The final step is creating the GuideRouter instance with options of link interception and use of the History API. By default, Pilot is working with location.hash, but it is possible to use history.pushState. To do this, you need to set Pilot.pushState = true. But if the browser does not support location.hash or history.pushState, then the complete support of History API can be achieved by using a polyfill or any other suitable library. During the implementation you will need to redefine two methods - Pilot.getLocation () and Pilot.setLocation (req).

That's about it. Other features can be found in the documentation.
Looking forward to your questions, issues and any other feedback:


Useful links
jquery.leaks.js (a utility for monitoring jQuery.cache)

Комментариев нет:

Отправить комментарий