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!)