Tuesday, February 18, 2014

RequireJS Paths: An Off-the-Shelf Plugin Engine

The RequireJS documentation doesn't do justice to the power of the paths configuration option; the sample configuration they include hints at its potential, but it’s never fully spelled out.

Code sample from the RequireJS Configuration Options API documentation

Paths as Mapping

The documentation describes the paths option primarily as a way of resolving the name of a module to an unconventional path from where it must be loaded. Though simple, this use case can be very useful.

One example would be CDNs:

paths: {
'jquery': '//code.jquery.com/jquery-1.10.2.min',
'jqueryui': '//code.jquery.com/ui/1.10.4/jquery-ui',
'webfont': '//ajax.googleapis.com/ajax/libs/webfont/1.5.0/webfont'
}
Paths to jQuery, jQuery UI and Web Font CDNs

Another common use case would be Bower modules:

paths: {
'bootstrap': 'bower_components/bootstrap/dist/js/bootstrap.min',
'jquery-rescope': 'bower_components/jquery-rescope/src/jquery-rescope'
}
Paths to Bootstrap and jQuery-Rescope Bower modules

Paths as Tokens

However what is actually illustrated in the documentation isn’t a one-to-one mapping -- unless they’re loading the v1.0.js module from the some folder -- it’s much more powerful!

By mapping the path “some” to “some/v1.0,” they are creating a replacement token which will interpret requiring “some/module” as needing to actually load “some/v1.0/module.”

The specific example in the documentation demonstrates how RequireJS frees you from explicitly referencing versions in your modules while allowing you to maintain that information on the file system. Which is interesting, but it doesn't exactly solve a critical problem.

What this example demonstrates generally, is an ability to swap compatible modules -- not just across versions, but with completely independent implementations -- via configuration without needing to write any special support for it in our applications. Add to this the fact that RequireJS is configured in JavaScript (as opposed to through a static file of some kind), and we can generate a path configuration based on whatever criteria we can imagine.

Some possible applications of this include:
  • browser feature detection
    • HTML5: map a path for “storage” to a folder of modules using localStorage
    • legacy: map the “storage” path to folder of modules using a database-backed web service
  • layering security
    • anonymous users: map to modules which only display messages that authentication is required
    • authenticated users: map to modules which actually attempt privileged operations
  • sharing a common code base
    • mobile: map to modules which implement functionality using Cordova APIs
    • Web: map to modules which implement functionality using browser and server APIs

Example

As a concrete example, I’ve written a simple demonstration which has two different modes of interacting with the user, and it switches between them without alteration to the code (or even any awareness of there being different modes in the app code) outside of the RequireJS configuration.

Setup

The entry point is my main.js file. This is where I bootstrap the environment before launching my application.

/*globals requirejs*/
(function (require) {
 'use strict';
 require(['paths'], function (paths) {
         require.config({
             paths: paths
         });

         ...
 });
})(require);
Configuring require using paths module

I’ve written the main module to load the paths as a separate module, so that it can be solely concerned with start-up operations.

The paths module interpolates the app's configuration into a RequireJS-compatible paths object.

define([
    'config'
], 
function (config) {
    'use strict';
    
    return {
                'jquery': '//code.jquery.com/jquery-1.10.2.min',
                'output': 'output/' + config.outputMode
            };
});
Mapping output path token based on configuration

The config module is "where the magic happens." For this demo, I'm determining the environment state based on the query string and sets it as the app's output mode.

define(function () {
    'use strict';
    
 function getOutputMode () {
        var outputModes = 
        [
         'obnoxious',
         'polite'
        ],
        currentOutputMode = 0,
        requestedOutputMode;
  
        // Dynamically set mode from query string
        requestedOutputMode = parseInt(
            (window.location.search.match(/[?&]mode=(\d+)/) || [])
            [1]);

        if(requestedOutputMode > 0 && requestedOutputMode < outputModes.length) {
            currentOutputMode = requestedOutputMode;
        }

        return outputModes[currentOutputMode];
 }
    
    var exports = {};
 
    exports.outputMode = getOutputMode();
    
    return exports;
});
Setting output mode in configuration based on query string

Now that RequireJS is configured to load modules from the correct path, we can return to the main module and start our demo app.

/*globals requirejs*/
(function (require) {
 'use strict';
 require(['paths'], function (paths) {
         ...

  // Launch our app
  require(['app'], function (DemoApp) {
   var demo = new DemoApp();

   demo.run();
  });
 });
})(require);
Launching demo app


The Demo App

Within the demo app, I require in the three output modules and assign the returned classes to local names.

define(
[
 'output/prompt',
 'output/confirm',
 'output/message'
],
function (Prompt, Confirm, Message) {
 ...
});
App module requiring and accepting prompt, confirm and message output modules

Then, within the body of the demo app, I can use the required-in modules, according to their individual contracts, in complete ignorance of how they are implemented.

 function EchoDemoApp () {
  var me = this;
 }
 
 EchoDemoApp.prototype.prompt = function () {
  var me = this,
   promptMessage = new Prompt('What do you want to say?');
   
  promptMessage.display(function (response) {
   var input = response || 'nothing',
    confirmation = new Confirm('Are you sure you want to say "' + input + '"?');
   
   confirmation.display(function (confirmed) {
    var output;
    
    if(confirmed) {
     output = new Message(input);
     output.display();
    } else {
     me.prompt();
    }
   });
  });
 };

 EchoDemoApp.prototype.run = function () {
  var me = this;
  
  me.prompt();
 }
Paths to jQuery, jQuery UI and Web Font CDNs
View a running version of the demo
Browse, download or fork the source from this article on GitHub

No comments:

Post a Comment

Note: Only a member of this blog may post a comment.