Asynchronous Resource Loading Part 6

03 Nov 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 of Asynchronous Resource Loading:

Handling Remote Scripts

In a world where XMLHttpRequest can load remote scripts, asynchronous script loading would be relatively easy. However, as CDNs are so popular loading third-party scripts is a common occurrence.

This tutorial takes the most basic technique for remote script loading, script insertion, and demonstrates how to combine it with XMLHttpRequest preloading.

Queueing and Preloading

Last week’s tutorial showed how to load remote scripts using an event-based queueing system. This must be modified to work with script insertion. Fortunately, keeping this process event-based gives rise to a simple algorithm:

  1. Preload all local scripts
  2. Once preloading is complete, emit an execute-next event
  3. If the script is preloaded, execute it. Afterwards, emit an execute-next event
  4. If the script is remote, fetch and execute it. Once execution is complete, emit an execute-next event

That gives rise to the following methods:

  • fetchExecute(item, fn): Fetch and execute a remote script, then call the callback
  • execute(item, fn): Execute a preloaded script, then call the callback
  • preloadAll: Preload every script possible. If no scripts are local, then emit execute-next

The callbacks for fetchExecute and execute should emit execute-next. In fact, it should probably just be one callback.

Testing

The tests are similar to the previous tests, but with the addition of a remote script:

'test queue loading with remote in the middle': function() {
  var assertExpect = mixinExpect(assert);
  assertExpect.expect(9);

  $t.require([
    '/load-me.js?test12=12',
    'https://ajax.googleapis.com/ajax/libs/swfobject/2.2/swfobject.js',
    '/load-me.js?test13=13'
  ]).on('complete', function() {
    assertExpect.ok(true);
    assertExpect.done();
  }).on('loaded', function(item) {
    if (item.src === 'https://ajax.googleapis.com/ajax/libs/swfobject/2.2/swfobject.js') {
      assertExpect.equal(typeof test13, 'undefined', 'test13 should not be set when remote is loaded');
      assertExpect.ok(window.swfobject, 'swfobject should be set');
    }

    if (item.src === '/load-me.js?test12=12') {
      assertExpect.ok(!window.swfobject, 'swfobject should not be set when test12 is loaded');
      assertExpect.equal(typeof test13, 'undefined', 'test13 should not be set when test12 is loaded');
      assertExpect.equal(test12, 12, 'test12 should be set when test12 is loaded');
    }

    if (item.src === '/load-me.js?test13=13') {
      assertExpect.ok(window.swfobject);
      assertExpect.equal(test13, 13);
      assertExpect.equal(test12, 12);
    }
  });
}

I’ve used the Google CDN for this test, and picked an arbitrary script to load. Execution is tested by checking a global variable that we know the remote script will set.

Implementation

A counter is required to check how many scripts require preloaded, then it gets decrement as each preload completes. I’ve called this this.preloadCount in Queue, and the preload event handler has been changed to emit preload-complete when preloading finishes.

The only edge case is preloadAll must emit something when there are no local scripts.

preloadAll: function() {
  var i, g, group, item, self = this;
  for (g = 0; g < this.groupKeys.length; g++) {
    group = this.groups[this.groupKeys[g]];
    
    for (i = 0; i < group.length; i++ ) {
      item = group[i];

      if (item.preload) {
        this.preloadCount++;
        (function(groupItem) {
          requireWithXMLHttpRequest(groupItem.src, {}, function(script) {
            self.emit('preloaded', groupItem, script);
          })
        }(item));
      }
    }
  }

  if (this.preloadCount === 0) {
    this.emit('execute-next');
  }
}

The preload event handler looks simpler than it did before:

installEventHandlers: function() {
  var self = this;

  this.on('preloaded', function(groupItem, options) {
    var group = self.groups[groupItem.group];
    groupItem.preloaded = true;
    groupItem.scriptOptions = options;
    self.preloadCount--;

    if (self.preloadCount === 0) {
      self.emit('preload-complete');
    }
  });

  this.on('preload-complete', function() {
    this.emit('execute-next');
  });

This neatly encapsulates preloading and executing code, making the distinction easier to follow. The next handler is the key one which manages execution:

this.on('execute-next', function() {
  var groupItem = self.nextItem();

  function completeCallback() {
    groupItem.loaded = true;
    self.emit('loaded', groupItem);
    self.emit('execute-next');
  }

  if (groupItem) {
    if (groupItem.preload) {
      self.execute(groupItem, completeCallback);
    } else {
      self.fetchExecute(groupItem, completeCallback);
    }
  } else {
    self.emit('complete');
  }
});

The key method here is really nextItem which finds an item waiting to be executed, or “fetch executed”:

nextItem: function() {
  var group, i, j, item;

  for (i = 0; i < this.groupKeys.length; i++) {
    group = this.groups[this.groupKeys[i]];
    for (j = 0; j < group.length; j++) {
      item = group[j];
      if (!item.loaded) {
        return item;
      }
    }
  }
}

The execute code is retained from last week, but the group completion handling code has been removed:

fetchExecute: function(item, fn) {
  var self = this;
  requireWithScriptInsertion(item.src, { async: true, defer: true }, function() {
    fn();
  });
},

execute: function(item, fn) {
  if (item && item.scriptOptions) {
    script = createScript(item.scriptOptions);
    insertScript(script);
    appendTo.removeChild(script);
  }

  fn();
}

Both methods call the callback, which was set by the execute-next handler. By using the same callback, completeCallback, we remove the possibility of creating a mistake in the completion behaviour — it would be easy to forget to set groupItem.loaded in one of the callbacks for example.

Conclusion

Although this doesn’t use any preloading techniques for remote scripts, it extends the event-based approach and better encapsulates execution and preloading. I think you’ll agree that creating remote script loaders is not at all trivial, despite support from the HTML specifications.

If you want to read more about the gruesome details of script loading, the comments on ControlJS Part 1 are extremely interesting, because it shows some of the thinking that went into LABjs. It also demonstrates an alternative HTML-oriented approach that removes some of the headaches of doing everything purely in JavaScript.

This code can be found in commit a4642e1.

References


blog comments powered by Disqus