I have written a solution for managing dynamic loading of dependent JavaScript files (or executing JS functions) coupled with automatic versioning to refresh browser cache.
The goal was to be able to specify a single my.js
file and have all dependent js files dynamically loaded with a filename which has been appended with its timestamp (.htaccess filters out the timestamp).
My hope is to get feedback on my methodology and code. Is this a methodology others would find useful?
UPDATE - I have posted a fully working version of this concept, updated with suggestions as a project on GitHub
To accomplish the goal:
Within each .js file, I specify the list of dependent files with the associated created class object (ns) so I know when the dependent file has been loaded.
For example,
my.js
may have the following declarations:var dependants = [ {file: "custom-dialog.min.js", ns: "CustomDialog"}, {file: function miscFn1(){doSomethingOnLoad()}, dependencies: [{file: function onload(){}}, {file: "dragdrop.min.js", ns: "DragDrop"}]} ] sourceFiles.add(dependencies); sourceFiles.load();
Note: All declarations listed in a .js file are for files
dependent
on this file. Declarations can includedependencies
which must be loaded or executed first. In this example, the filedragdrop.min.js
must be loaded and the window'sload
event must have fired before the functiondoSomethingOnLoad
is run. But the filecustom-dialog.min.js
will be loaded immediately.Within
include.php
, I load the single filemy.js
(the PHPversion()
function adds the file timestamp for this file).<script src="<?php echo version(STATIC_JS_COMMON, 'js-common/my.js') ?>"></script>
Within
include.php
, I specify the directories to poll on the server. ThedoVersionChecking()
function makes an HTTP request to the server which returns all file timestamps for the specified directories (I'm happy to post this code if anyone would find this helpful).<script> sourceFiles.doVersionChecking([ // specify url of directories to read file times for $ms.STATIC_JS_COMMON, $ms.STATIC_JS_COMMON + "/subdir" ]); </script>
.htaccess code - to remove the timestamp from the filename
RewriteEngine On #Rules for Versioned Static Files RewriteRule ^(js|js-common|css|css-common|img|img-common)/(.+)\.([0-9])+\.(js|css|php|jpg|gif|png)(.*)$ $1/$2.$4$5 [L]
Below is the JavaScript code. The only code missing is the PHP code for returning the files with their associated timestamps.
Note: all STATIC_
(PHP) and $ms.STATIC_
(JS) variables contain URL paths to the files on the server. The server translates the URL paths to absolute paths in order to find the files and read the timestamps.
// manage dynamic loading of source files (js, css, img) and js functions
var sourceFiles = {
queued: [],
loading: [],
source: [],
fileInfo: [],
versionCheck: [], // array of version check requests
versionCheckPaths: [], // list of paths checked with version checking
staticRoot: function() {
if (typeof $ms.STATIC_TOP_ROOT !== "undefined") {
return $ms.STATIC_TOP_ROOT;
} else {
return window.location.origin ? window.location.origin + '/' : window.location.protocol + '/' + window.location.host;
}
},
staticJsCommon: function() {
if (typeof $ms.STATIC_JS_COMMON !== "undefined") {
return $ms.STATIC_JS_COMMON;
} else {
return window.location.origin ? window.location.origin + '/' : window.location.protocol + '/' + window.location.host + "/js-common";
}
},
staticImgCommon: function() {
if (typeof $ms.STATIC_IMG_COMMON !== "undefined") {
return $ms.STATIC_IMG_COMMON;
} else {
return window.location.origin ? window.location.origin + '/' : window.location.protocol + '/' + window.location.host + "/img-common";
}
},
doVersionChecking: function(path) {
// set function sourceFiles.versionChecking as a dependency
// file times will be retrieved from server before any dependent js files
// each js file will have file time added to the name to mangage file versions
// .htaccess removes the file version from the file name
var dependencies = [
// all functions must have a unique name - create one on the fly
{
file: new Function("return function versionChecking" + sourceFiles.versionCheck.length + "(){$ms.sourceFiles.versionChecking(" + JSON.stringify(path) + ")}")()
}
];
sourceFiles.add(dependencies);
sourceFiles.load();
},
add: function(source) {
if (!Array.isArray(source)) {
source = [source];
}
for (var i = 0; i < source.length; i++) {
// test if namespace specified and if already exists
if (sourceFiles.alreadyLoadedNs(source[i].ns)) continue;
if (typeof source[i].file == "function") {
var funcName = /function ([^\(]+)?/.exec(source[i].file.toString())[0];
source[i].baseFile = funcName;
source[i].loaded = false;
source[i].type = "function";
if (funcName == "function onload") {
if (document.readyState === "complete" || document.readyState === "interactive") {
// condition already satisfied
sourceFiles.onload({
target: {
src: sourceFiles.source[i].baseFile
}
});
} else {
window.addEventListener("load", sourceFiles.load);
}
}
} else {
var baseFile = source[i].file.substr(source[i].file.lastIndexOf("/") + 1);
var split = baseFile.split("?");
baseFile = split[0];
source[i].baseFile = baseFile
var ext = baseFile.substr(baseFile.lastIndexOf(".") + 1);
if (ext == "js") {
source[i].type = "js";
} else if (ext == "css") {
source[i].type = "css";
} else if (ext == "php") {
if (baseFile.indexOf("css.php") !== -1) {
source[i].type = "css";
} else {
source[i].type = "php";
}
} else if (["jpg", "png", "gif"].indexOf(ext) !== -1) {
source[i].type = "img";
} else {
source[i].type = "unknown";
console.log("Source File unknown type for: " + source[i].file);
}
var dir = "";
if (typeof source[i].dir == "undefined") {
// default = js-common
dir = sourceFiles.staticJsCommon();
} else if (typeof source[i].dir == "js-common") {
dir = sourceFiles.staticJsCommon();
} else if (typeof source[i].dir == "css-common") {
dir = sourceFiles.staticCssCommon();
} else if (typeof source[i].dir == "img-common") {
dir = sourceFiles.staticImgCommon();
}
var subDir = "";
if (source[i].file.indexOf("/") !== -1) {
// full directory explicitly set
} else if (typeof source[i].subDir !== "undefined") {
// relative to specified or default subDir
subDir = "/" + source[i].subDir;
}
source[i].file = dir + subDir + "/" + source[i].file;
source[i].loaded = false;
}
// test if file already added to load queue
if (sourceFiles.queued.indexOf(source[i].baseFile) !== -1) continue;
if (source[i].dependencies) {
for (var j = 0; j < source[i].dependencies.length; j++) {
// queue the dependencies
sourceFiles.add(source[i].dependencies[j]);
}
} else {
source[i].dependencies = [];
}
// add to queue to be loaded
// flag file is queued for loading
sourceFiles.queued.push(source[i].baseFile);
sourceFiles.source.push(source[i]);
}
},
load: function() {
var versionCheckLength = sourceFiles.versionCheck.length;
for (var i = 0; i < sourceFiles.versionCheck.length; i++) {
if (sourceFiles.versionCheck[i].timeStamp > 0 && !sourceFiles.versionCheck[i].complete) {
// if the response from the server not yet received - set interval to wait for it
if (typeof sourceFiles.versionCheck[i].interval !== "undefined") {
// interval already running
return;
}
sourceFiles.versionCheck[i].interval = setInterval(function() {
if (sourceFiles.versionCheck[i].complete || Date.now() - sourceFiles.versionCheck[i].timeStamp >= sourceFiles.versionCheckingTimeout) {
clearInterval(sourceFiles.versionCheck[i].interval);
if (!sourceFiles.versionCheck[i].complete) {
console.log("Timeout checking js version (" + i + ")");
}
sourceFiles.versionCheck[i].complete = true;
sourceFiles.load();
}
}, 10);
return;
}
}
for (var i = 0; i < sourceFiles.source.length; i++) {
if (versionCheckLength !== sourceFiles.versionCheck.length) {
// if version checking has been added - start over
sourceFiles.load();
return;
}
// remove dependencies that are already loaded
sourceFiles.removeDependencies(sourceFiles.source[i]);
// load all files with no dependencies
if (sourceFiles.source[i].dependencies.length == 0) {
if (sourceFiles.source[i].loaded) {
// file already loaded
continue;
//} else if (sourceFiles.loading.indexOf(sourceFiles.source[i].baseFile) !== -1){
// files already added to load queue
//continue;
} else if (typeof sourceFiles.source[i].file == "function") {
// function - execute the function
var funcName = /function ([^\(]+)?/.exec(sourceFiles.source[i].file.toString())[0];
if (funcName == "function onload") {
// special function that has no body
var result = (document.readyState === "complete" || document.readyState === "interactive");
} else {
//v("executing " + sourceFiles.source[i].file.toString());
var result = sourceFiles.source[i].file();
}
if (result !== false) {
sourceFiles.source[i].loaded = true;
sourceFiles.onLoad({
target: {
src: sourceFiles.source[i].baseFile
}
});
}
continue;
}
// test if namespace specified and if already exists
if (sourceFiles.alreadyLoadedNs(sourceFiles.source[i].ns)) continue;
if (sourceFiles.loading.indexOf(sourceFiles.source[i].baseFile) !== -1) continue;
var version = "";
if (sourceFiles.fileInfo.find(function(fileInfo) {
if (fileInfo.baseFile == sourceFiles.source[i].baseFile) {
version = '.' + fileInfo.time + '.';
return true;
}
})) {
// keep baseFile the same - change the full filename with version
// replaces my.file.js with my.file.123456.js where 123456 is the file timestamp
sourceFiles.source[i].file = sourceFiles.source[i].file.replace(/\.(?!.*?\.)/, version);
}
// flag loading file
sourceFiles.loading.push(sourceFiles.source[i].baseFile);
// file - load the source file
loadSourceFile(sourceFiles.source[i].file, sourceFiles.source[i].type, sourceFiles.onLoad)
}
}
},
alreadyLoadedNs: function(ns) {
// test if namespace specified and if already exists
if (typeof ns == "undefined") return false;
var exists = true;
var path = ns.split(".");
for (var j = 0; j < path.length; j++) {
if (typeof $msRoot[path[j]] == "undefined") {
// namespace not yet created
return false;
}
}
// namespace exists
return true;
},
onLoad: function(e) {
// flag file as loaded
var baseFile = e.target.src.substr(e.target.src.lastIndexOf("/") + 1);
var split = baseFile.split("?");
baseFile = split[0];
// remove the version timestamp from the filename
baseFile = baseFile.replace(/(.+)\.([0-9])+\.(js|css|php|jpg|gif|png)$/, "$1.$3");
for (var i = 0; i < sourceFiles.source.length; i++) {
if (sourceFiles.source[i].baseFile == baseFile) {
sourceFiles.source[i].loaded = true;
if (sourceFiles.source[i].onLoad) {
// custom onLoad
sourceFiles.source[i].onLoad();
}
break;
}
}
sourceFiles.load();
},
removeDependencies: function(source) {
if (!source.loaded) {
for (var j = source.dependencies.length - 1; j >= 0; j--) {
// test if namespace specified and if already exists
if (typeof source.dependencies[j].ns !== "undefined" && sourceFiles.alreadyLoadedNs(source.dependencies[j].ns)) {
// loaded - remove the dependencey
source.dependencies.splice(j, 1);
continue;
}
for (var k = 0; k < sourceFiles.source.length; k++) {
var source2 = sourceFiles.source[k];
if (source2.baseFile == source.dependencies[j].baseFile) {
// found the dependency
if (source2.loaded) {
// loaded - remove the dependencey
source.dependencies.splice(j, 1);
}
break;
}
}
}
}
},
versionChecking: function(path) {
// poll server for file times for specified directories
if (!Array.isArray(path)) {
path = [path];
}
sourceFiles.versionCheck.push({});
var versionCheck = sourceFiles.versionCheck[sourceFiles.versionCheck.length - 1]
versionCheck.id = sourceFiles.versionCheck.length;
versionCheck.timeStamp = Date.now();
versionCheck.complete = false
versionCheck.path = path;
var url = $ms.LINK_SITE_ROOT + "/moddate.php";
var data = {
path: path
};
data = JSON.stringify(data);
var http = new XMLHttpRequest();
var params = "id=moddate-js&url=" + url + "&otherData=" + data;
http.open("POST", url, true);
//Send the proper header information along with the request
http.setRequestHeader("Content-type", "application/x-www-form-urlencoded");
http.onreadystatechange = function() { //Call a function when the state changes.
if (http.readyState == 4 && http.status == 200) {
//alert(http.responseText);
var response = http.responseText;
var data = JSON.parse(response);
var error = false;
if (typeof data !== "object" || !data.status) {
console.log("Invalid version checking response: " + response);
error = true;
} else if (data.status.toLowerCase().indexOf("error") !== -1) {
console.log(data.status);
error = true;
} else if (data.status.toLowerCase().indexOf("success") == -1) {
console.log("Unknown response (missing success): " + response);
error = true;
}
if (!error) {
console.log("Version information loaded");
sourceFiles.fileInfo = sourceFiles.fileInfo.concat(data.result);
}
// if there was an error, will load files without version info
versionCheck.complete = true;
sourceFiles.load();
}
}
http.send(params);
}
}
// dynamically load a js or css file
function loadSourceFile(filename, filetype, onloadFn) {
if (typeof filetype == "undefined") {
filetype = filename.substr(filename.lastIndexOf('.') + 1)
}
if (filetype == "js") {
// load js file
var item = document.createElement('script');
item.type = "text/javascript";
item.src = filename;
} else if (filetype == "css") {
//load CSS file
var item = document.createElement("link");
item.rel = "stylesheet";
item.type = "text/css";
item.href = filename;
} else if (filetype == "img") {
// preloading images
var item = document.createElement("img");
item.style.display = "none";
item.src = filename;
}
if (typeof onloadFn !== "undefined") {
item.onload = onloadFn;
//item.onreadystatechange = runFn;
}
if (typeof item != "undefined") {
if (filetype == "img") {
document.body.appendChild(item);
} else {
document.head.appendChild(item);
}
}
}
RewriteRule ^(js|js-common|css|css-common|img|img-common)
could beRewriteRule ^(js|css|img)(-common)?
\$\endgroup\$file: function miscFn1(){doSomethingOnLoad()}
could befile: doSomethingOnLoad
. (unless you really need to aliasdoSomethingOnLoad
asmiscFn1
) \$\endgroup\$