This post is also available in: Russian
Development of interactive Web services involves extensive use of JavaScript. If 10 years ago the language had but a meager functionality, today JS has substantially transformed. Therefore, more of service logics is moved to the client side. In this post, we will share with you our hands-on experience in designing and implementing large-scale Web applications.
Design Approach
Evidently, coding of large-scale projects has its challenges. If the code size exceeds a certain threshold, it becomes sort of a tangled skein. In this case, the development speed drops drastically: a lot of time has to be spent on unraveling the skein. That’s why the code should be classified by its logic. Using design patterns, such as MVC, MVVP and the like, you can substantially structurize you code, make it more flexible and scalable, and systematize development of your application. The choice of a particular pattern might be conditioned by service architecture requirements or by the developer’s preferences.
So as not to reinvent the wheel, in 99% of the cases it is better to use frameworks that have already implemented the above patterns. The most common frameworks are Backbone.js, Ember.js and Knockout.js.
Development of a Model Application
Suppose that out task is to develop a single-page RESTful application. (Let’s dub it a “Sample App”.) The key driver to scalable and flexible applications is the capability to disable some functionality or add it without affecting the overall performance. To do this, we distribute all entities of our app across modules.
To ensure modularity of our scripts, let’s use a namespace. In JavaScript however, there is no dedicated tool to declare namespaces as in C development, for example. So to do this, we create a global object and use its fields to declare system components.
(function() { window.sample = {}; // Main application object sample.routers = {}; // Object containing controllers // to enable switching between the application modules. sample.models = {}; // Object storing data models sample.ui = {}; // Object containing controllers to // define application interfaces and their behavior. sample.core = {state: sample.core = {state: null}; // The core of our application. It contains a // "sandbox" object. sample.modules = {}; // The object containing controllers of other // system modules })();
All the entities that we have created, we will distribute between these objects. The sample in based on backbone.js (never mind that we are extending the Backbone.View object). In fact, this object is not implementing a view but a controller responsible for the interface logics):
sample.ui.MainPage = Backbone.View.extend({ anotherField: ‘’, // public field initialize: function() { // Enter the object initialization code. var someField = ‘’; // private field }, render: function() { // rendering of templates, etc. this.renderWidjet1(); this.renderWidjet2(); this.renderWidjet3(); }, renderWidjet1: function() { }, renderWidjet2: function() { }, renderWidjet3: function() { } }); sample.ui.mainPage = new sample.ui.mainPage(); sample.ui.mainPage.render();
Architecture
The modules should be agnostic of each other. They should be linked via the "sandbox." The sandbox is an object that contains application statuses and methods to enable communication between modules.
// The "sandbox" object sample.core.AppState = Backbone.Model.extend({ message: null, receiver: null, // Receiver definition method setReceiver: function(receiver) { this.set('receiver', receiver); }, // Message forwarding method proceedMessage: function(message, success, error) { var receiver = this.get('receiver'); if (receiver) { this.set('message', message) ; receiver.receiveMessage(message); if (success) { success.call(); } } else if (error) { error.call(); } } }); // Sender object sample.modules.Sender = Backbone.View.extend({ message: 'text to send', initialize: function() { this.model = sample.core.state; }, sendMessage: function() { // Send messages to the "sandbox" this.model.proceedMessage(this.message, this.successMessage, this.errorMessage); }, successMessage: function() { alert('OK!'); }, errorMessage: function() { alert('THERE IS NO RECEIVER!'); } }); // Message Receiver object sample.modules.Receiver = Backbone.View.extend({ initialize: function() { this.model = sample.core.state; // Telling the sandbox to forward messages to us this.model.setReceiver(this); }, receiveMessage: function(message) { alert(message); } }); // The object listening to the event sample.modules.Listener = Backbone.View.extend({ initialize: function() { var self = this; this.model = sample.core.state; // Enable listening this.model.bind(‘change:message’: function() { self.listenMessage(self.model.get(‘message’)); }); }, listenMessage: function(message) { console.log(message); } }); sample.core.state = new sample.core.AppState(); sample.modules.receiver = new sample.modules.Receiver(); sample.modules.sender = new sample.modules.Sender(); sample.modules.listener = new sample.modules.Listener();
In our sample, we have three modules: Sender, Receiver, and Listener, and the "Sandbox" to enable communication between them. As you can see, each object follows the Singletone design pattern. At the same time, the "sandbox" is based on the Mediator template, as it mediates between the sender and receiver. The Listener Template is the Observer that tracks changes in the mediator field. For this purpose, the event-based mechanism is used. This tool is a key feature of JavaScript. When developing a complex application, it is advisable to resort to this tool as often as possible. The standard events are limited to user actions and DOM manipulation, but various libraries and frameworks can extend the tool, allowing you to listen to data changes within models, etc.
Building of a scalable JS application implies extensive use of the callback methods. Callback functions are among the most important tools for building asynchronous code. In the above example, by using callbacks we can bind message sending error handlers to the sender object.
The Backend and Data Model
In the RESTful applications, a connection to the backend is enabled via an API implementing output of the required data in a preset format. As a rule, it is JSON. The idea behind enabling frontend-to-backend communication is binding of a client model to an applicable server method.
sample.models.SomeModel = Backbone.Model.extend({ initialize: function() { }, url: function() { return ‘some/url’; } }); sample.models.someModel = new sample.models.SomeModel; sample.models.someModel.fetch(); // fetch data from the backend sample.models.someModel.save(); //send data to the backend
Request Routing
Transition between sections of a single-page application is implemented through a dedicated router object. Such objects are also often called State Machines. In Backbone.js, such an object also implements HTM5 History API.
sample.routers.App = Backbone.Router.extend({ routes: { "": "initPage", "!": "initPage", "!/": "initPage", "!/about": "aboutPage", "!/chat": "chatPage", "!/player": "playerPage" }, initialize: function() { // Create module objects }, initPage: function() { }, aboutPage: function() { }, chatPage: function() { }, playerPage: function() { } }); // Launch routing $(document).ready(function() { sample.routers.app = new sample.routers.App(); Backbone.history.start(); });
Other Features
The application sections can contain elements common to all of them. Such objects do not have to be reloaded on switching from one page to another (e.g., a video player). To do this, for each widget on the page we have to create a separate container. All items are displayed by manipulating the DOM tree. With it, we will be able to switch only the section blocks we need. But, please keep in mind that DOM manipulations are very resource intensive. So try to optimize such activities.
If your project contains scripts running heavy calculations, use workers for this. This mechanism allows you to delegate your complex script logics to an external script running in the background. As text messages are used to exchange data between the workers and the main code, so if we have to pass objects, we have to serialize them first. Serialization can be quite a resource-consuming process. So please always keep in mind: Workers are justified only when the time needed for serialization and deserialization is less than time of subsequent calculations.
Structure of Code Distribution
Key to developing of large-scale applications is the ability to clearly structurize the developed code, as supporting of a project storing everything in a single file, is extremely difficult. So the following approach has to be adopted: each entity (object, model, controller, etc.) = a separate file. Combine the entities by their purpose and put them into a directory. For example, you may put all models into the ‘models’ directory, views – to ‘templates’, etc.
To build all the files together, you can use a simple bash script or special libraries (e.g., require.js).
Ready Sample Application
To demonstrate the above capabilities, we have created a sample application including four sections: 2 textual sections, HTML5 video player and a chat section linked to the current player content. The player would not close at jumping between sections , so you can always open the player without having to reload video. To present a section to the user, a dedicated system module is used, enabling smooth transitions between the "pages". Selecting of video on the homepage will open another chat room, replace the player and change the page background.
Conclusion
In this post, we have dwelled on the specifics of building flexible and scalable JS applications using Backbone.js. You can use a different framework, however the above principles will also apply to it.