Tuesday, September 11, 2007

Preloading Both Images AND Scripts in Javascript

We've lately begun developing content for the iPhone and other mobile devices, so I'm starting to get into Javascript programming more.

For an educational game I'm building, I wanted to show a loading screen while the device is downloading all the script information over the slow EDGE network. Unfortunately, while there is a lot of information on the web about preloading images for faster response time in Javascript applications, there's not much about preloading Javascript scripts. In fact, I saw several "it can't be done" type responses.

It turns out it can be done, and quite easily, by leveraging the power of Prototype's AJAX calls. Because Prototype will eval() any AJAX response that comes back with a Javascript MIME type, you can essentially use it to deliver your scripts, rather than using the typical <script> tag, and provide a pretty download bar as it goes.

Here's some code:

//   asset_loader.js
// This object handles all the assets for the page.

// First, the master list of application assets to preload.
// All images are considered to be optional unless required is set to 1.
// All scripts are considered to be required unless optional is set to 1.

var assets = {

// List images here.
// Each image is an associative array with:
// id: an identifier
// url: a url to load the image from (can be relative)
// required: (optional) if '1', calls failure callback
// if there is an error loading this image
// (Thus, all images default to being optional.)

images: [
{ id: 'an_image', url: 'images/an_image.png' },
{ id: 'another_image', url: 'images/another_image.png' },
{ id: 'required_image', url: 'images/important_image.png', required: 1 }
],

// List scripts here.
// Each script is an associative array with:
// id: an identifier
// url: a url to load the script from (can be relative)
// optional: (optional) if '1', does not call failure callback
// if there is an error loading this script
// (Thus, all scripts default to being required.)

scripts: [
{ id: 'required_javascript_thing', url: 'javascripts/important.js' },
{ id: 'optional_thing', url: 'javascripts/optional.js', optional: 1 }
]

};

// The preloader object.
// This object handles the job of preloading everything.
// Usage:
//
// preLoader.startLoading( preloadSuccess, preloadFailure, preloadStatus );
//
// Where:
// preloadSuccess: the function to call when preloading was successful
// preloadFailure: the function to call when preloading failed
// preloadStatus: the function to call to report status of preloading in progress
//
// Scripts grabbed in this manner are eval()'d by Prototype.

var preLoader = {
errors: { images: 0, scripts: 0 },
errortext: '',
progress: 0,
startLoading: function( completeCallback, errorCallback, statusCallback ) {
this.success = completeCallback;
this.failure = errorCallback;
this.status = statusCallback;
this.loadNextImage();
},
loadNextImage: function() {
if (this.progress >= assets.images.length) {
this.progress = 0;
this.loadNextScript();
return;
}
imageObject = new Image();
imageObject.onload = function() { preLoader.imageLoaded(); }
imageObject.onerror = function() { preLoader.imageError(); }
imageObject.src = assets.images[ this.progress ].url;
assets.images[ this.progress ].image = imageObject;
},
imageLoaded: function() {
var perc = Math.round((this.progress + 1) * 100 / assets.images.length);
assets.images[ this.progress ].success = 1;
this.preloaderMessage( perc, 'Loading Images', assets.images[ this.progress ].url );
this.progress++;
this.loadNextImage();
},
imageError: function() {
var perc = Math.round((this.progress + 1) * 100 / assets.images.length);
assets.images[ this.progress ].success = 0;
this.errors.images++;
this.errortext += '<div>Error loading ' + assets.images[ this.progress ].url + '</div>';
this.preloaderMessage( perc, 'Loading Images', assets.images[ this.progress ].url );
if (assets.images[ this.progress ].required == 1) return this.failure();
this.progress++;
this.loadNextImage();
},
loadNextScript: function() {
if (this.progress >= assets.scripts.length) {
this.progress = 0;
this.success();
return;
}
this.ajax = new Ajax.Request( assets.scripts[ this.progress ].url, {
method: 'get',
onSuccess: function(transport) {
preLoader.scriptLoaded();
},
onFailure: function(transport) {
preLoader.scriptError();
}
});
},
scriptLoaded: function() {
var perc = Math.round((this.progress + 1) * 100 / assets.scripts.length);
assets.scripts[ this.progress ].success = 1;
this.preloaderMessage( perc, 'Loading Scripts', assets.scripts[ this.progress ].url );
this.progress++;
this.loadNextScript();
},
scriptError: function() {
var perc = Math.round((this.progress + 1) * 100 / assets.scripts.length);
assets.scripts[ this.progress ].success = 0;
this.errors.scripts++;
this.errortext += '<div>Error loading ' + assets.scripts[ this.progress ].url + '</div>';
this.preloaderMessage( perc, 'Loading Scripts', assets.scripts[ this.progress ].url );
if (assets.scripts[ this.progress ].optional != 1) return this.failure();
this.progress++;
this.loadNextScript();
},
preloaderMessage: function( perc, header, msg ) {

this.status( perc, header, msg, this.errortext );

}

};


...and here's a sample usage which tries preloading the given elements, and displays a "Failure" or "Success" message depending on whether the items were successfully loaded. (In practice, you'd probably not burn out all that innerHTML each frame, but this works as a demo.)

<html>
<head>
<title>Javascript Preloading Demo</title>
<meta name="viewport" content="width=320, user-scalable=no" />
<link rel="stylesheet" href="css/main.css" type="text/css" media="screen, tv" />
<script src="javascripts/prototype.js" type="text/javascript"></script>
<script src="javascripts/game_loader.js" type="text/javascript"></script>
<script>
// <![CDATA[
// Sample preloading code.

function initialize() {
preLoader.startLoading( preloadSuccess, preloadFailure, preloadStatus );
}

function preloadStatus( perc, header, msg, errors ) {
$('preloader-message').innerHTML = '\
<div style="text-align: center; font-weight: bold; color: #999; margin-top: 80px;">' + header + '</div> \
<div style="margin: 0px 20px; border: 2px solid #00f; position: relative; height: 16px;"> \
<div style="position: absolute; left: 0px; top: 0px; width: ' + perc + '%; height: 16px; \
background-color: #008;"></div> \
<div style="position: relative; text-align: center; color: #999;">' + msg + '</div> \
</div>';
$('preloader-error').innerHTML = errors;
}

function preloadFailure() {
$('preloader').innerHTML = '<div style="text-align: center;"><h1>Failed to Load!</h1></div>';
}

function preloadSuccess() {
$('preloader').innerHTML = '<div style="text-align: center;"><h1>Success!</h1></div>';
}
// ]]>
</script>
</head>
<body onLoad="initialize();" style="border: 1px solid #666;">
<div id="preloader">
<div id="preloader-message"></div>
<div id="preloader-error"></div>
</div>
</body>
</html>


If you're not using Prototype, this same principle could be applied using a standard XMLHttpRequest.

Update

Some notes on the usage of the above code. As is typical with eval()'ed code, objects that you create in the global namespace using the above method will not persist. Depending on how you've architected your code, you may not be able to just "drop in" your Javascript and have it work.

However, this is easily remedied. All you need to do is create an object that will persist, and then attach your objects and methods to that. (This is generally a good idea anyway, rather than cluttering up the global namespace.)

For instance, you might add, to the loading script code above, a global object called "app". Then, if you wanted to add a "mainMenu" object, do "app.mainMenu = { whatever };" Since app persists, so too would app.mainMenu.



Update#2

See Andrew's comment below for an alternate method for preloading scripts. (Thanks, Andrew!)

6 comments:

Anonymous said...

Amazing, Love it! thank you!!!

SlashEMc2k said...

Nice :)

I preload my scripts in a hacky way.

I generate an iframe which loads the scripts and then write the script tags/links into the head of the parent window then removes the iframe.

This allows me to preload them and have a loading bar.

But I'm going to try this method.

Great work ;)

Anonymous said...

Hi
Tested ok on IE6 but has annoying bug when restarting or revisiting the preloader... Than it hangs... No problems in other browsers but then agian IE6 is a must have compability....

CC said...

Thanks for the tip. Any read on why/where it hangs?

Andrew said...

I don't have the bother at the moment to test this on another computer on IE6 but there is a method which doesn't require any AJAX or iFrames.

So far I know it does work in FF, IE7 and Safari with other browsers not having been tested yet.

The method is not unlike that of preloading an image.

You put all your javascript file paths into an array of some sort and you can use the events .onload and .onreadystatechange (IE) which trigger when the script is loaded.
Similar to this:

--------------
var s = document.createElement('script');

s.setAttribute('type','text/javascript');
s.setAttribute('src','yourscript.js');

s.onload = loadedFunc();
s.onreadystatechange = loadedFunc(); // Note: For this you need to check the .readyState flag for 'loaded' or 'complete'

var h = document.getElementsByTagName('head')[0];
h.appendChild(s);

--------------

Much simpler, no?

Excuse any formatting, the preview feature doesn't seem to be working.

Carl Sutherland said...

Nice little library, thank you.

Andrew's method is the same way that Scriptaculous (effects/widgets lib built on Prototype) loads its modules.