Building Single Page Applications with CanJS and RequireJS

By January 7, 2013JavaScript, MVC

The web users of today expect a modern experience that only a single page application can deliver. Page refreshes are a thing of the past. Waiting for anything longer than 2 seconds without a “cool effect” is not acceptable. Responsive design for tablets and smartphones should just work.

These demands of the new era have spawned new ways to develop JavaScript applications. For modern, scalable, and extensible JavaScript applications, two techniques are on the forefront: MVC patterns and AMD design. For this demo, we will be using RequireJS for AMD modular design and CanJS for MVC.

By the way, I have tried Backbone in production and let me tell you that CanJS is the underdog. It gives you more bang for your buck while still staying out of your way. Also, having no controllers in Backbone is very awkward. Let’s see how to build a modern website in an HTML5 world!

Setup

What would a single page app be without an index.html? Since there are no reliable async loaders or techniques for stylesheets at the moment, link them all in the index.html page head. Minify and combine them in production. The next important thing is to initialize your JavaScript app. You will do this by loading require.js and your main.js to the page head as well. These are the only scripts that will not be asynchronously loaded. Every script after this is fair game… even jQuery!

Also a note on CanJS’s AMD support. CanJS released an AMD version of their library starting from 1.1, but I purposely did not use it. The implementation is still young and I have encountered some subtle issues with it. In addition, I don’t like how the implementation ruins the folder structure by forcing you to place the CanJS folder with main.js (instead of in the libs folder). It seems there isn’t a RequireJS setting to fix this either. The library is so small though and I use many parts of the framework anyways, I figure it’s fine to load can.jquery.min.js via shim until CanJS’s AMD support matures a bit more.

For illustration, the complete single page app structure should look something like below. Notice I am storing all 3rd party libraries and frameworks in the /libs folder. This does not necessarily mean I will be loading them all, but just on-demand via AMD.

Routing

Now comes the fun part – setting up the MVC router! This is the centerpiece for any MVC application. The router will get initialized in main.js. Let’s take at main.js, which is essentially used to configure require.js and start your app:

~/scripts/main.js:

//DETERMINE BASE URL FROM CURRENT SCRIPT PATH
var scripts = document.getElementsByTagName('script');
var src = scripts[scripts.length - 1].src;
var baseUrl = src.substring(src.indexOf(document.location.pathname), src.lastIndexOf('/'));
 
// Require.js allows us to configure shortcut alias
require.config({
baseUrl: baseUrl,
    paths: {
        jquery: [
            '//ajax.googleapis.com/ajax/libs/jquery/1.8.3/jquery.min',
            //If the CDN location fails, load from this location
            'libs/jquery/jquery.min'
        ],
        underscore: 'libs/underscore/underscore-min',
        can: 'libs/canjs/can.jquery.min',
        supercan: 'libs/canjs/can.construct.super',
        canmustache: 'libs/canjs/can.view.mustache',
        bootstrap: 'libs/bootstrap/js/bootstrap.min',
        kendoweb: 'libs/kendoui/js/kendo.web.min',
        underscorestring: 'libs/underscore/underscore.string.min',
        moment: 'libs/moment/moment.min',
toastr: 'libs/toastr/toastr'
    },
 
    // The shim config allows us to configure dependencies for
    // scripts that do not call define() to register a module
    shim: {
        underscore: {
            exports: '_'
        },
        can: {
            deps: [
'jquery'
            ],
            exports: 'can'
        },
        supercan: ['can'],
        canmustache: ['can'],
        kendoweb: {
            deps: ['jquery'],
            exports: 'kendo'
        },
        bootstrap: ['jquery'],
        moment: {
            deps: ['jquery'],
            exports: 'moment'
        },
        underscorestring: ['underscore'],
toastr: ['jquery']
    }
});
 
require([
    'router'
], function (Router) {
//INITIALIZE ROUTER
Router.init();
});

~/scripts/router.js:

define([
'jquery',
'can',
    'underscore',
    'app',
    'controllers/pages',
    'underscorestring'
], function ($, can, _, App, Pages) {
    //MERGE STRING PLUGIN TO UNDERSCORE NAMESPACE
    _.mixin(_.str.exports());
 
    var Router = can.Control({
 
        init: function () {
 
        },
 
        //ROUTES
        'route': 'index',
        'contact route': 'contact',
        'videos route': 'videos',
        'pages/:id&:selector route': 'pages',
        'pages/:id route': 'pages',
        ':controller/:action/:id route': 'dispatch',
        ':controller/:action route': 'dispatch',
        ':controller route': 'dispatch',
 
        //ACTIONS
        index: function () {
            Pages.index({ fade: false });
        },
 
contact: function () {
Pages.contact();
},
 
videos: function () {
Pages.videos();
},
 
        pages: function (data) {
            Pages.load(data);
        },
 
//ROUTES TO CONTROLER / ACTION
        dispatch: function (data) {
            var me = this;
 
            //SCRUB URL PARAMS IF APPLICABLE
            var controllerName = _.capitalize(data.controller);
            //CONVERT URL PARAM TO ACTION NAMING CONVENTION
            var actionName = data.action
                ? data.action.charAt(0).toLowerCase() + _.camelize(data.action.slice(1))
                : 'index'; //DEFAULT TO INDEX ACTION
 
            //DYNAMICALLY REQUEST CONTROLLER AND PERFORM ACTION
            App.loadController(controllerName, function (controller) {
            //CALL ACTION WITH PARAMETERS IF APPLICABLE
if (controller && controller[actionName])
controller[actionName](data);
//TODO: FIX BUG, ONLY WORKS ON FIRST HIT
//DUE TO HOW REUIREJS ERROR EVENT WORKS
else App.navigate('pages/404');
            });
        }
    });
 
    return {
        init: function () {
    //ROUTE ON DOCUMENT READY
   $(function () {
            //PAUSE ROUTING UNTIL INSTANTIATED
           //OTHERWISE ROUTER MUST BE INSTANTIATED BEFORE DOCUMENT READY
           //https://forum.javascriptmvc.com/#Topic/32525000001070159
           can.route.ready(false);
                
           //INITIALIZE ROUTER FOR LISTENING
           new Router(document);
                
           //EVENTS
           can.route.bind('change', function(ev, attr, how, newVal, oldVal) {
               //HANDLE ACTIVE MENU SELECTION BASED ON ROUTE SET ONLY
               if (how === 'set') Pages.initMenu();
           });
 
           //ACTIVATE ROUTING
           can.route.ready(true);
   });
        }
    };
});

A lot of interesting things are happening in the router. First is the route patterns. There is one for the home page, a couple static pages, and some dynamic ones. The last one with controllers and actions is a powerful one. This will “dispatch” the request to the right controller and action simply by interpreting the current route. This is how a real MVC application should work. A dynamic route that makes dynamic requests to your application! For example, going to #products/detail/7 will call your “Products” controller and then execute the “detail” function with “7” as the parameter passed to it. See? Single page applications can be much more than jQuery scrolls between page sections 🙂

Controllers

This is where all the good stuff happens. We will be caching our controllers in App.controllers and will be using a CanJS Control to instantiate them. It has a section for the initialization, events, and actions. This, of course, is served as a nice, bite-sized JavaScript module via AMD.

~/scripts/controllers/pages.js:

/**
* Controller to manage static pages
*/
define([
'jquery',
'underscore',
'can',
'kendoweb',
    'app',
    'api',
    'utils/basecontrol',
'utils/form',
    'utils/alert',
    'utils/menu',
'relatedtweets',
'jqyoutubeplayer'
], function ($, _, can, kendo, App, Api, Base, Form, Alert, Menu) {
 
    //CACHE TO PREVENT POSSIBLE MEMORY LEAKS AND REBINDS
    return App.controllers.Pages || (App.controllers.Pages = new (Base({
defaults: {
fade: 'slow'
}
},{
        //INITIALIZE
        init: function (element, options) {
var me = this;
 
            //BASE INITIALIZE
            this._super(element, options);
 
    //DOM DEPENDENTS ON DOC READY
   $(function () {
            //HANDLE NAV AREAS
me.initMenu();
});
        },
 
        initMenu: function () {
            Menu.activate('header ul.nav li');
            Menu.activate('section.sidebar ul.nav li');
        },
 
        //EVENTS
        'header .signup click': function (sender, e) {
            e.preventDefault();
            this.modal({
                url: 'views/pages/signup.html',
                title: 'Create an account',
                submit: 'Sign up',
                submitCss: 'submit-signup'
            });
        },
 
        '.modal .submit-signup click': function (sender, e) {
var me = this;
            e.preventDefault();
 
Api.signup({
form: sender.parent().parent().find('form'),
fnSuccess: function (data) {
Alert.notifySuccess('Thank you for signing up!');
me.hideModal();
}
});
        },
 
//ACTIONS
        index: function (options) {
            this.view({
                url: 'views/pages/index.html',
selector: '#main_container',
                fade: false,
fnLoad: function (el) {
      $('.tweets', el).relatedTweets({
query: '#javascript',
n: 50
});
}
            });
        },
 
        contact: function (options) {
            this.view({
                url: 'views/pages/contact.html',
selector: '#main_container',
fnLoad: function (el) {
//DECLARE VARIABLES
var form = $('form', el);
 
//HANDLE FORM SUBMISSION
form.find('input[type=submit]', el).click(function (e) {
e.preventDefault();
Api.sendForm({
mailto: 'info@falafel.com',
cc: 'basem@falafel.com',
subject: 'Contact form',
storage: Api.contactsTable,
form: form,
success: 'Thank you for feedback!'
});
});
 
}
            });
        },
 
        videos: function (options) {
            this.view({
                url: 'views/pages/videos.html',
selector: '#main_container',
fnLoad: function (el) {
      $('.videos', el).youTubeChannel({
       user: 'falafelsoftware'
   });
}
            });
        },
 
        load: function (options) {
        this.view({
           url: options.url
               ? options.url
               : 'views/pages/' + options.id + '.html',
           selector: options.selector || '#main_container'
       });
        }
    }))(document)); //ROOT ELEMENT FOR CONTROLLER INSTANCE
});

Most of the heavy lifting is happening the in the base class in “utils/basecontrol“. All of our controllers will be inheriting from this so they automatically inherit functionalities like launching a modal window or loading different pages. Notice in the init, the base class init is being called with the snazzy Construct.super plugin for CanJS. Next, events are being subscribed to, such as button clicks. The last part of the control are all the actions that are usually called by the router or from other controller actions.

Models

The models are best handled in a separate post discussing CRUD. I tried stubbing it out as much as possible for use Parse.com. That way, the JavaScript application is completely decoupled from a server-side technology. Just REST calls to Parse.com… very cool! For now, let’s skip the models and go to the views section. Our single page app will not need this right now anyways (we are dealing with pages not an e-commerce site for example).

Views

Keep all your templates in the views folder. These are HTML files that are asynchronously called from your controller actions. Views can be static or can use a tempting engine like Mustache to bind data to. CanJS has recently added support for Mustache and works beautifully. After the view has been loaded asynchronously in the action, the callback is available for you to perform extra work on the template elements, such as creating a Kendo grid or initializing a jQuery plugin on an element.

See it in action!

You can view the demo in a separate page here. There are no page refreshes since all the page templates are being asynchronously loaded. All the links on my page are hash URL’s. So instead of going to “/contact.html“, you would go to “#contact” and the router would listen to this. Then the user will be dispatched to the right controller, action, and view! The code is hosted at GitHub so please feel free to fork and contribute.

Conclusion

Single page applications are a reality and not just for hobbyists anymore. Full-fledged enterprise applications are being run on these architectures as we speak. With Silverlight and Flash dying a slow death, HTML5 apps are leading the way to the promise land!

References

The following two tabs change content below.