An effective pattern for designing ambitious web applications is to load a core JavaScript binary on page load, and then lazy load pieces of content as the user requests them. This improves overall application performance, and it is a necessary approach for single-page applications that deal with a lot of content. At Jellyvision, we’re building an application framework called Launchpad that works this way. Launchpad loads a bare-bones application when the user visits the page, and it continually fetches more content as the user goes through the experience. Although we haven’t released any products that use it yet, we are already experiencing many benefits from this architecture. We’re able to achieve this sophisticated design thanks to RequireJS.

I have created a small example project on Github that demonstrates the core mechanics of Launchpad’s lazy-loading infrastructure: http://github.com/jeremyckahn/requirejs-lazy-load-example. This article will discuss how requirejs-lazy-load-example works so that you can apply similar concepts to your own projects.

Goals

requirejs-lazy-load-example aims to achieve several things:

  • Serve raw source files in the development environment for rapid iteration and easy debugging.
  • Allow lazy-loaded modules to have their own templates that are not shared with other modules or the main application binary.
  • Provide a build process that effectively copies everything from the app/ directory to dist/, but optimizes all lazy-loaded modules and their unique dependencies into individual binaries, thereby minimizing HTTP requests.
  • Not require any unnecessary workflow tooling that might obfuscate the fundamental mechanics of the build process.

The last point is important — many of the moving parts of this example project are tightly-coupled and could be automated or abstracted by high-level tools, but that is outside the scope of this article.

Prerequisites

In order to keep this example simple, requirejs-lazy-load-example utilizes NodeJS and the r.js optimizer directly, rather than via Grunt or a similar tool. You can install it globally like so:

npm install -g requirejs

The app

You can see the flow of the app in app/scripts/main.js. All it does is bind some click event handlers to the buttons defined in app/index.html. These click handlers then use RequireJS to fetch their respective modules and instantiate them when they are loaded. A key detail is that these required modules list their own dependencies — Backbone, and a module-specific template. Backbone is already loaded by the main app, but the lazy-loaded module causes its template to be fetched in addition to its main.js file.

requirejs-lazy-load-example mostly provides “Hello World”-type functionality, but it does so with a few twists. Specifically, it depends on jQuery, Backbone and Underscore. This is important, because the lazy-loaded modules utilize these libraries but do not include them in their own binaries. Instead, the modules are made aware of these external dependencies at build time and assume that they are available upon instantiation (which they are). Although this sounds simple, is takes a bit of legwork to achieve this minimal module build, as it goes against the default behavior of the r.js optimization process. Here’s an example of the build rules for a lazy-loaded module:

{
  "baseUrl": "../../../",
  "out": "../../../../dist/scripts/modules/module1/main.js",
  "name": "scripts/modules/module1/main",
  "optimize": "none",
  "stubModules": [
    "text"
  ],
  "paths": {
    "text": "bower_components/requirejs-text/text",
    "jquery": "empty:",
    "backbone": "empty:",
    "underscore": "empty:"
  },
  "exclude": [
    "text"
  ]
}

There’s a few interesting elements to this. First, take a look at the paths map. “text” is utilized at build time, so the dependency location needs to be listed explicitly. The other dependencies should be ignored, however. Our application design assumes that jQuery, Backbone and Underscore are loaded by the main application binary, so we want to explicitly ignore those dependencies here and prevent their dependency trees from being traversed by r.js. Luckily, r.js provides a construct for doing that in the form of the "empty:" path string.

One of the goals for this project is to allow lazy-loaded modules to have their own templates. At build time, we need to embed these templates as AMD modules into the compiled module binary. This is where the RequireJS text plugin comes into play, and why we needed to explicitly list its location on disk in the paths map. In addition to that, we need to list it in the stubModules and exclude arrays. Like jQuery, Backbone and Underscore, we assume that the RequireJS text plugin is provided by the main app binary, so we don’t want it to be included in a lazy-loaded module’s binary. The reason we have to treat the RequireJS text plugin differently than the other excluded modules is that it is used to process and embed the template at build time.

The build process

There are actually three build-config.json files for this project; one for the main app binary, and one for each of the two lazy-loaded modules. As far as I can tell, r.js necessitates having a build config file for each module that you want to optimize. There doesn’t seem to be a straightforward way to DRY this up and have one central build config for a project like this. In any case, we need some way of tying all these build configs together into a single process. We also need to replicate our overall application file structure into dist/, as that is what we expect to be serving in the production environment. This is done in build.sh. All this shell script really does is copy index.html and require.js into their respective locations from app/ to dist/ and call the r.js optimizer with each of the three build-config.json files.

It’s worth noting that I disabled uglification for this demonstration to make the generated code easier to read, but you could just as easily enable it to significantly cut down on file size.

The end result

What we wind up with after running build.sh is a fully optimized app that satisfies the project goal of minimizing HTTP requests in the production environment. This app makes assumptions about what resources are and are not likely to be needed by the user and optimizes for those scenarios.

There are many possibilities for the types of single page apps that can be built with this type of architecture. It allows for theoretically infinite content without ever needing to unload the page (provided that you properly manage your memory). It enables you to keep application load times short without sacrificing quality or quantity of content.

I hope to share more interesting techniques that we are learning from building Launchpad at Jellyvision (and there are quite a few!). For now, I hope that you find this to be a helpful way for speeding up your apps.




Tagged with: