Asynchronous Resource Loading Part 4

20 Oct 2011 | By Alex Young | Tags frameworks tutorials lmaf network

Let’s Make a Framework is an ongoing series about building a JavaScript framework from the ground up.

These articles are tagged with lmaf. The project we’re creating is called Turing. Documentation is available at turingjs.com.

Previous parts:

Loading with XMLHttpRequest

In last week’s tutorial I hinted at a technique script loading libraries use to preload local scripts using XMLHttpRequest. By requesting scripts this way, the contents can be placed into a queue then executed through a script element’s .text property when required. This allows libraries like LABjs to schedule execution based on the user’s requirements.

Now we’re starting to go beyond script insertion and into the realms of preloading and scheduling. With that in mind, I’ve redesigned the code for this module to be easier to test.

Designing for Testability

Production-ready script loaders will dynamically decide on the best strategy for preloading a given script. That makes them easy to use, but potentially makes them hard to test. I want to write tests like this:

$t.require('/load-me.js?test0=0', { transport: 'scriptInsertion' }, function() {
  assert.equal(window.test0, 0);
});

$t.require('/load-me.js?test3=3', { transport: 'XMLHttpRequest' }, function() {
  assert.equal(window.test3, 3);
});

Given a server-side test harness — which I’ve already written in test/functional/ajax.js — we should be able to specify which method is used to load a script, and get the expected results.

The transport option in the previous example allows us to control which loading strategy is used. The scriptInsertion transport is what we created in the previous tutorials. The new one is XMLHttpRequest, which gives us more potential for preloading and scheduling scripts.

Implementation

To build this, I’ve broken the problem up into several functions:

  • isSameOrigin determines if a given src is local or remote, so we don’t get same origin errors in the browser
  • createScript creates a new script tag and applies an Object of options
  • insertScript inserts the script tag into the document
  • requireWithScriptInsertion loads scripts using insertion
  • requireWithXMLHttpRequest loads scripts using XMLHttpRequest

A lot of this code was originally in require, but when I realised I was doing the same thing in the XMLHttpRequest loader I decided to break it up.

This method loads the script using our built-in XMLHttpRequest support:

  /**
   * Loads scripts using XMLHttpRequest.
   *
   * @param {String} The script path
   * @param {Object} A configuration object
   * @param {Function} A callback
   */
  function requireWithXMLHttpRequest(scriptSrc, options, fn) {
    if (!isSameOrigin(scriptSrc)) {
      throw('Scripts loaded with XMLHttpRequest must be from the same origin');
    }

    if (!turing.get) {
      throw('Loading scripts with XMLHttpRequest requires turing.net to be loaded');
    }

    turing
      .get(scriptSrc)
      .end(function(res) {
        // Here's where the magic happens.  This callback is what will get scheduled in future versions.
        options.text = res.responseText;
        
        var script = createScript(options);
        insertScript(script);
        appendTo.removeChild(script);
        fn();
      });
  }

Which means the public method, require, now has to decide which transport to use:

/**
 * Non-blocking script loading.
 *
 * @param {String} The script path
 * @param {Object} A configuration object.  Options: {Boolean} `defer`, {Boolean} `async`
 * @param {Function} A callback
 */
turing.require = function(scriptSrc, options, fn) {
  options = options || {};
  fn = fn || function() {};

  setTimeout(function() {
    if ('item' in appendTo) {
      if (!appendTo[0]) {
        return setTimeout(arguments.callee, 25);
      }

      appendTo = appendTo[0];
    }

    switch (options.transport) {
      case 'XMLHttpRequest':
        return requireWithXMLHttpRequest(scriptSrc, options, fn);

      case 'scriptInsertion':
        return requireWithScriptInsertion(scriptSrc, options, fn);

      default:
        return requireWithScriptInsertion(scriptSrc, options, fn);
    }
  });
};

Conclusion

I’ve tested this in IE 6, 7, Firefox, Chrome, and Safari. A future version of this module will have to decide which loading method to use by default, depending on the scheduling requirements (which we have yet to start work on).

There are actually more script loading techniques to look at before I can get to scheduling. As I’ve said before, if you’re interested in this area take a look at RequireJS and LABjs to jump ahead.

This week’s code can be found in commit 649f882.


blog comments powered by Disqus