A Professional JavaScript Build System: Part II – Bootstrapping
In my last post I described the parts that can make up a robust build system for a complex JavaScript library that would allow for simplifying development, promotion to QA, and deployment to live systems. I outlined in some detail the importance of abstracting any use of external systems (via XHR, stores, or any other resource access that can change from environment to environment), and using a service container to request those services as needed. In this post, I will describe how I bootstrap my JavaScript library (based on Dojo).
The goal of a bootstrapping script is to allow page developers (i.e. consumers of widgets) to have to know a minimal amount (if any) JavaScript to utilize the library. While I know that in most circumstance, the page author is also the widget author, from the standpoint of testing and separation of concerns (making pages robust as requirements change), it is also good practice to keep your pages as much about layout, content, and style as possible. Your JavaScript widgets are there to bring in the functionality and interactivity of the page.
In my ideal scenario, using my JavaScript library will require exactly one line of markup to include the library, bootstrap it, and parse the page rendering any widgets as needed. Widgets will be ridiculously simple to use, and will only expose attributes (or only require attributes) that have a real impact on their function or style. Configuration should be done elsewhere.So here is my ideal page:
<html>
<head>
<script type="text/javascript" src="path/to/bootstrap.js"></script>
</head>
<body>
<div dojoType="medryx.widgets.HeadshotGrid" columns="4" style="width:400px;></div>
</body>
</html>
Notice how clean this is from an HTML perspective. Even though I love Dojo’s idea of namespacing CSS with html and body class names, I think that in a standard page, the page author shouldn’t have set the body class. The page author shouldn’t have to think about if they want “xd” version of the toolkit or same domain (especially considering that during development they probably want local, and during deployment they will likely want xd). The page author shouldn’t have to do any dojo.require calls. They simply call the widgets they need, and the rest is magic. If they want to do some JavaScript, they can, if they want to override the body class they can. But in the simplest (and most common) case, they don’t need to do anything. And indeed, in the simplest case, no matter what environment the page is executed in, it should just work. No changes to the HTML required.
So how do we get to this end? Its actually easier than it may seem. It takes a little from the school of thought that favors convention to configuration. It does not bar configuration, but if you don’t do any configuration, and simply adhere to convention, things should just work.
Lets think about what we need our bootstrap to do:
- Load the right version of dojo
- Load the right version of the environment
- Require any necessary scripts
- Parse the page
Loading the right version of dojo
In all of my projects, I put my library on the same server as the one serving Dojo. This may or may not be the same as the HTML page that is including the libraries (so we still have to deal with XD issues), but it does simplify a few things. If I know the path to my bootstrap script, then I can derive my paths to everything else, regardless of where the bootstrap lives.
So the first thing the bootstrap does, is some introspection. What is the path to the bootstrap file, relative to the calling page?
This is actually a cinch to do with Dojo. But at the time of loading, we don’t yet have Dojo loaded. So it requires some old fashioned DOM scripting:
var script = document.getElementsByTagName("script");
for (var i = 0; i < script.length; i++) {
var src = script[i].getAttribute("src");
if (src) {
//if the src of this script element is "bootstrap.js"
//then everything before the text bootstap.js
//is the path that got us here
var match = src.match(/(.*)\/bootstrap.js/);
if (match && match.length) {
djConfig.baseUrl = match[1] + "/dojo/dojo/";
}
}
}
So in this snippet I am simply loading all the “script” tags in the already loaded DOM. Since this script is executing, then by definition, at the very least the script tag that called this script is loaded in the DOM. Once I find the script tag that loaded this script, grab the path to this script, and map the path to dojo relative to this script. Not too bad, but a little but of a funky concept if you don’t get your head around it.
So now we can create the script element to load dojo onto the page. But remember, the DOM is probably not fully loaded yet. So the safest thing to do will be to us a document.write to load dojo. But using a document.write to “inject” dojo into a page is not the normally expected use of dojo. And you don’t necessarily know that the moment after you perform your write statement that your script will have access to dojo.addOnLoad. As such, you must define a djConfig variable (for many reasons) before you do your document.write, and that djConfig should have an addOnLoad method defined. This is your first method that “knows” that it has access to Dojo. So the next bit of code looks something like:
djConfig = {
isDebug: true,
modulePaths: {
dojo: "../dojo",
dijit: "../dijit",
dojox: "../dojox",
pages: "../../pages",
medryx: "../../medryx"
},
addOnLoad: BOOTSTRAP,
useXDomain: false
}
//now include the dojo.js -- if cross domain, then use xd.js
var extension = djConfig.useXDomain ? ".xd.js" : ".js";
//load dojo!
document.write("<scr" + "ipt type='text/javascript' src='" + djConfig.baseUrl + "dojo" + extension + "'></scr" + "ipt>");
So djConfig declares the appropriate modulePaths (note that the dojo/dijit/dojox are included in case this is a cross-domain load). If defines the addOnLoad method. Then we put together our path to dojo, and decide which version of dojo to use based on the useXDomain flag in djConfig.
Loading the right environment
Dojo will call BOOTSTRAP as soon as it is loaded. Which is where we will start to load our application. The first thing our BOOTSTRAP method does is figure out which environment to load. The first place it looks is in a custom configuration in djConfig called “environment”. If that value is set to “page”, then it will look for a custom attribute on the script tag that loaded the bootstrap to see if any environment was described there. This is useful in development or testing, if for example, you want to temporarily force a page to use a particular environment. So the modified djConfig and bootstrap looks like this:
// Below this line are configurations that are usually swapped in and out at build time
//==============================================================================================================
///BEGIN CONFIG
djConfig = {
isDebug: true,
modulePaths: {
dojo: "../dojo",
dijit: "../dijit",
dojox: "../dojox",
medryx: "../../medryx",
pages: "../../pages"
},
addOnLoad: BOOTSTRAP,
optimize: false, //for loading the compiled js.
medryxConfig: {
environment: "page" // use 'page' to read the 'environment' attribute on the iforms <script> tag
}
//may need to put this in a common location
// dojoBlankHtmlUrl: "blank.html", //required for xdomain
// dojoCallbackUrl: "blank.html" //required for xdomain
// useXDomain: false
};
///END CONFIG
//===================================================================================================================
medryx = {
_initializers: [],
addOnLoad: function(/* Function */f){
// summary:
// this implementation is to be sure pages have access to addOnLoad immediately.
// a more formal/robust implementation is loaded later
// that handles the more complex use cases (like calling
// addOnLoad after page is loaded)
iforms._initializers.push(f);
},
config: djConfig.medryxConfig,
ready: function(){
// summary:
// event called back when form is ready to display
}
}
function BOOTSTRAP() {
var environment = medryx.config.environment;
if (environment == "page") { //then this is a development environment
environment = dojo.query("script[environment]").attr("environment")[0] || "Static"; //default is static
}
}
(function(){ //scope protection
//we don't have dojo yet, and we need the path to this file.
var script = document.getElementsByTagName("script");
for (var i = 0; i < script.length; i++) {
var src = script[i].getAttribute("src");
if (src) {
var match = src.match(/(.*)\/bootstrap.js/);
if (match && match.length) {
djConfig.baseUrl = match[1] + "/dojo/dojo/";
}
}
}
if (!djConfig.baseUrl) {
throw new Error("could not find path to JS. this file must be name bootstrap.js for everything to work");
}
//now include the dojo.js -- if cross domain, then use xd.js
var extension = djConfig.useXDomain ? ".xd.js" : ".js";
//load dojo!
document.write("<scr" + "ipt type='text/javascript' src='" + djConfig.baseUrl + "dojo" + extension + "'></scr" + "ipt>");
})();
Okay, so its getting a little more complicated, but still not overwhelmingly so. This script did everything I described above, and added just a bit more. First, it declared the global namespace of “medryx” and it created a config, as well as its own addOnLoad method. Don’t worry too much about that right now except to say, its used to be sure certain scripts are executed after all the dojo.addOnLoad methods are executed. Again in a local build the order is pretty reliable, so its not a big deal, but when using a cross-domain “compiled” script, getting the order of things right can be a bit tricky, so this helps solve that. More on that in another post in the future. I also check if djConfig.baseUrl gets set, if it doesn’t, we get a meaningful error message.
So now I know what environment I want. I’ve loaded dojo. Next step dojo.require() the environment. And once I have the environment, I am ready to load any scripts needed to run the page.
This is where convention over configuration comes back to play. You may have noticed my “pages” namespace. This is actually a special namespace. Each page has a script in the pages namespace. And in fact, you can figure out the pages.* script you need by looking at the calling html page’s pathname. Pick whatever convention you want, but just be consistent. Then you can deduce what js needs to be loaded next and you can just do it, without the page have to do any configuration. Of course, if you choose, you could create another “custom attribute” on the bootstrap script tag with the name of the page module to load, or you could simply require() it into the HTML page on the HTML page itself. But following the convention gives you the benefit of zero configuration. Less code, less configuration = fewer silly bugs.
So here is a snippet in my BOOTSTRAP method that first checks if any of the script tags has the special “module” attribute. If they don’t it tries to figure out the module attribute based on a path name. This version assumes all HTML pages will be under a folder called “pages”.
var module = dojo.query("script[module]").attr("module")[0];
if (!module) {
var page = window.location.pathname.match(/pages\/(.*)\..*/);
if (!page) {
console.warn("Pages that use this bootstrapper must be in a subdirectory of 'pages'");
console.warn("Using mock module name: MOCK");
page = "MOCK";
} else {
page = page[1].replace(/\//g, '.').toLowerCase();
}
module = "pages." + page;
}
So its time do some dojo.require()’ing. But before we do, lets consider the case of the “optimized” flag I have in my djConfig. Note that this is NOT a standard djConfig attribute. I simply use this to decide if I want to include a script on the page which is my compiled library. This is the version of the library that takes my hundreds of files and folders and compresses them into a single js file. With that in mind:
if (djConfig.optimize) {
//load the compiled js: a single large file that
//encompasses the code needed for each environment
var head = dojo.query("head")[0];
var extension = djConfig.useXDomain ? ".xd.js" : ".js";
dojo.create("script", {
src: djConfig.baseUrl + "/medryx-" + environment + extension
}, head, "last");
}
//this contains all of the most common scripts a page
//uses -- like form, layout, etc.. if optimized, then
//this does nothing, since its already loaded
dojo.require("medryx.environment." + environment);
//in case the module forgets... or if there is no module page (and makes this "first")
dojo.require("pages.common");
try {
dojo.require(module);
} catch (e) {
console.warn("Unable to load module for this page (Did you remember to create a script for this?): ", module);
}
I’ll describe the optimize flag and the location/name of the “built” js file later (when I discuss my profile layers). You can see I do a simple dojo.require for the environment. Also note that you must build your compiled scripts as one-per-environment, since you can only have one environment included on a page (or the “last one loaded wins”). The module dojo.require is in a try/catch block. I chose this instead of other ways to load the module that don’t throw an error when not found because I wanted to create the appropriate development warning to remind people to create their “module” file in the pages namespace.
Loading the style sheets
One more step, and we are done with our bootstrap. I wanted to be sure that our appropriate CSS file is loaded on the page. This is the CSS that is needed for widgets to work at all. Its a silly error when everything seems broken, and its just because the CSS didn’t import and failed silently. I also add the class name to the body (though I should probably check if none exists first).
//import the style to the page
dojo.create("link", {
href: dojo.moduleUrl("medryx", "../../themes/tundra.css").toString(),
type: "text/css",
rel: "stylesheet"
}, dojo.query("head")[0], "first"); //use "first" so that any locally declared style/css takes precedence
//add the css namespace so that the calling page doesn't have to remember to
dojo.query("body").addClass("tundra");
Okay, so what have we done here? We now have a single script that gets dojo into your page, loads you environment, decides if you need a cross-domain build, and handles incorporating your widget stylesheets. Not too shabby. And now with the use of a single <script> tag, your HTML page just works.
But aside from the niceness of everything just working, how else does this help us? Did you notice my comments strings above and below the djConfig variable? While I will describe in detail in my next post how I replace this depending on the environment desired, suffice it to say for now, that my build system can swap out the djConfig section depending on the kind of build I would like done. So a simple call to:
build.bat environment=DynamicLocal
…builds the entire library, and makes all the pages use the pretty optimized JS if needed and use all the right resources. Voila.
Next post: how to modify the dojo build system to handle environment swaps (warning – some mild dojo hacking is required for now until I can get them to fix some issues in the build system…)