hazzens scribe

links

Stupid JS Tricks - Script Hoisting

15 Nov 2013

I found an esoteric and questionably useful method for injecting external scripts into a page without polluting the global scope. Why would you want to do this? Meh. The usual way for including an external script, from within JS, is something like:

var script = document.createElement('script');
script.type = 'text/javascript';
script.src = 'path/to/your/script.js';
script.onload = onloadCallback;
someElement.appendChild(script);

But, if you don’t want the script to pollute the global namespace, you have a bit of a problem. You can include the script from within an iframe which will only pollute the iframe’s scope. If your script was at //example.com/script.js and defined the symbol MY_GLOBAL, you could hoist it like so:

var iframe = document.createElement('iframe');
var src = '//example.com/script.js';
var content = [
  '<!DOCTYPE html>',
  '<html><head><script type="text/javascript" src="',
  src,
  '"></script></head></html>'];
iframe.src = 'javascript:void(0)';
document.body.appendChild(iframe);

// Now the iframe is on the page, set up our onload.
iframe.onload = function() {
  var iframeWindow = iframe.contentWindow;
  var global = iframeWindow.MY_GLOBAL;
  doSomethingWith(global);
};

// And write the script tag.
var doc = iframe.contentDocument;
doc.open();
doc.write(content.join(''));
doc.close();

This works in almost every browser/setup except a corner case with IE that I’ll cover at the end. Its main failing, however, is that it doesn’t support async scripts. To make things concrete, assume you wanted to hoist Google Analytics into a page (perhaps the host page already included analytics, but you want a second set). The analytics code is:

var _gaq = _gaq || [];
_gaq.push(['_setAccount', 'UA-XXXXX-X']);
_gaq.push(['_trackPageview']);

(function() {
  var ga = document.createElement('script'); ga.type = 'text/javascript'; ga.async = true;
  ga.src = ('https:' == document.location.protocol ? 'https://ssl' : 'http://www') + '.google-analytics.com/ga.js';
  var s = document.getElementsByTagName('script')[0]; s.parentNode.insertBefore(ga, s);
})();

This sets up a global variable on the page named _gaq as a placeholder for analytics. When the async script loads, it replaces the _gaq placeholder with the actual object analytics object. To hoist this, we’ll need to both hoist the placeholder _gaq as well as the real object, whenever it may load. The simplest way to accomplish this would be with a setInterval like:

var iframe = document.createElement('iframe');
var content = [
  '<!DOCTYPE html>',
  '<html><head><script type="text/javascript">',
  // Put the analytics code here as a raw string.
  '</script></head></html>'];
iframe.src = 'javascript:void(0)';
document.body.appendChild(iframe);

iframe.onload = function() {
  window._alternateGaq = iframe.contentWindow._gaq;
  var placeholderGaq = window._alternateGaq;
  // Set an interval to check for the real _gaq object.
  var intervalId = window.setInterval(function() {
    if (iframe.contentWindow._gaq != placeholderGaq) {
      window._alternateGaq = iframe.contentWindow._gaq;
      window.clearInterval(intervalId);
    }
  }, 1000);
};

// And write the script tag.
var doc = iframe.contentDocument;
doc.open();
doc.write(content.join(''));
doc.close();

However, there is a potentially fatal flaw with this method - there will be a period of time between analytics loading and the setInterval function detecting it, and any events logged during that period will go nowhere. What we really want is to be notified of when the async script has loaded, at which point we can pull the real _gaq out of the iframe. And so begins a horrible hack.

First, you need to change the script tag inserted by Google Analytics to have an onload event:

  ...
  ga.src = ('https:' == document.location.protocol ? 'https://ssl' : 'http://www') + '.google-analytics.com/ga.js';
  ga.onload = _asyncLoaded;
  ...

Then, you must make sure the iframe’s window has a definition of _asyncLoaded:

...
iframe.onload = function() {
  window._alternateGaq = iframe.contentWindow._gaq;
};
iframe.contentWindow._asyncLoaded = function() {
  window._alternateGaq = iframe.contentWindow._gaq;
};
...

And voila! Your pile of hacks is complete, and you can now include Google Analytics twice on the same page. The complete code, for the curious (or copy’n’paster):

var iframe = document.createElement('iframe');
var content = [
  '<!DOCTYPE html>',
  '<html><head><script type="text/javascript">',
  // BEGIN ANALYTICS
  'var _gaq = _gaq || [];',
  '_gaq.push(["_setAccount", "UA-XXXXX-X"]);',
  '_gaq.push(["_trackPageview"]);',

  '(function() {',
  '  var ga = document.createElement("script"); ga.type = "text/javascript"; ga.async = true;',
  '  ga.src = ("https:" == document.location.protocol ? "https://ssl" : "http://www") + ".google-analytics.com/ga.js";',
  '  ga.onload = _asyncLoaded;',
  '  var s = document.getElementsByTagName("script")[0]; s.parentNode.insertBefore(ga, s);',
  '})();',
  // END ANALYTICS
  '</script></head></html>'];
iframe.src = 'javascript:void(0)';
document.body.appendChild(iframe);

iframe.onload = function() {
  window._alternateGaq = iframe.contentWindow._gaq;
};
iframe.contentWindow._asyncLoaded = function() {
  window._alternateGaq = iframe.contentWindow._gaq;
};

// And write the script tag.
var doc = iframe.contentDocument;
doc.open();
doc.write(content.join(''));
doc.close();

Oh, right, remember how I said this might break in a corner case for IE? Yeah, that is a fun one that I picked up at a previous job. For IE8 and below, if the host page has manually set document.domain - perhaps to share cookies - you cannot re-open the iframe document using document.open() as doing so blows away the custom document.domain. At which point the iframe is now on a different domain, and IE won’t let you touch it. Any attempt will throw a Permission Denied error. Oh, you also have to make sure the iframe’s domain is the same as the host page.

The workaround for this isn’t as bad as you might think, but it is unpleasant. So if you can’t write the iframe content using document.{open,write,close}, what can you do? Abuse src='javascript:...'! That’s right, you have to inline your entire iframe content in the src attribute. It ends up looking like:

var hostPageDomain = document.domain;
var iframe = document.createElement('iframe');
var jsContent = [
  'document.domain ="', hostPageDomain, '";',
  'document.write(\'',
  '<script type="text/javascript">',
    'var _gaq = _gaq || [];',
    '_gaq.push(["_setAccount", "UA-XXXXX-X"]);',
    '_gaq.push(["_trackPageview"]);',

    '(function() {',
    '  var ga = document.createElement("script"); ga.type = "text/javascript"; ga.async = true;',
    '  ga.src = ("https:" == document.location.protocol ? "https://ssl" : "http://www") + ".google-analytics.com/ga.js";',
    '  ga.onload = _asyncLoaded;',
    '  var s = document.getElementsByTagName("script")[0]; s.parentNode.insertBefore(ga, s);',
    '})();',
  '</script>\');'];
iframe.src = 'javascript:' + jsContent.join('');

// We need to set up our "_asyncLoaded" handler _after_
// the iframe loads - only then do we have a contentWindow.
iframe.onload = function() {
  window._alternateGaq = iframe.contentWindow._gaq;
  iframe.contentWindow._asyncLoaded = function() {
    window._alternateGaq = iframe.contentWindow._gaq;
  };
};

document.body.appendChild(iframe);