During Peter Farrell's cfObjective presentation on front end optimizations, one of the many tips he had involved minimizing the amount of HTTP requests your HTML makes. As a simple, and somewhat contrived example, consider the following HTML:
<script>
$(document).ready(function() {
$("body").append("<p>Loaded jQuery")
})
</script>
<link rel="stylesheet" href="/cfdocs/newton_ie.css" type="text/css" />
<link rel="stylesheet" href="/cfdocs/newton_ns.css" type="text/css" />
<link rel="stylesheet" href="/cfdocs/toc.css" type="text/css" /> </head>
<body>
Hello World.
</body>
</html>
<html>
<head>
<script src="/jquery/jquery.js"></script>
<script src="/jquery/jquery.cookie.js"></script>
<script src="/jquery/jquery.flot.js"></script>
<script src="/jquery/jquery.validate.js"></script>
<script src="/jquery/jquery.selectboxes.js"></script>
As you can see, I've got jQuery and 4 plugins being loaded. I've also got 3 different style sheets. All in all this reflects 8 additional HTTP requests the page has to make while parsing the HTML, and that's not counting the images that would normally make up an average web page.
As a graphical example of the impact of these scripts, check out the YSlow report:
As you can see, I got a C and an overall score of 70. The first thing YSlow points out to me are the number of HTTP requests. I decided to write my own CFML code to see if I can address this. My code would act as a simple service. I'd pass it a list of files and the code would return all the resources in one request. Before going any further, please note I did this as an experiment. There is a supported, and more full featured, open source project out now you should use instead of my code: combine. I just wrote this for fun - so please keep that in mind. Ok, with that out of the way, let's go through what I built step by step.
<cfsetting enablecfoutputonly="true" showdebugoutput="false">
I begin by enabling cfoutoutput only. This will reduce the whitespace generated by the request. Since I'm serving up JavaScript and CSS files, it also makes sense to disable debug output.
<cfset variables.rootfolder = ["/Library/WebServer/Documents/jquery/", "/Library/WebServer/Documents/cfdocs/"]>
Next we have a root folder setting. You must edit this line before using the script. I felt bad about this at first because it felt like it should be something I externalize - but then I remembered - I'm not building a custom tag here. I'm building a service. So one small amount of setup isn't so bad. Why the array? Well, this (and the next block) are the one part I'm most unsure of. For security reasons, I didn't want you to pass in full paths to files. Therefore, it makes sense to embed a root folder in the service itself. However, many people keep their CSS and JS files separate. I could allow folks to simply use their web root for the root but then they would need to pass in the subfolder for every resource requested. Ie, something like /js/foo.js, /js/goo.js, /js/zoo.js. My solution was to allow for either a simple string value or an array. If you use a string, well, it's used as is. If you use an array, I allow you to pass which index to use for the root in the URL string. More on that in a second. If you don't specify one, then the first array element is used.
<cfparam name="url.root" default="">
<!--- Only care about url.root if passed and if variables.rootfolder is an array --->
<cfif len(url.root) and isArray(variables.rootfolder) and isNumeric(url.root) and
url.root gte 1 and url.root lte arrayLen(variables.rootfolder) and round(url.root) is url.root>
<cfset variables.folder = variables.rootfolder[url.root]>
<cfelseif isArray(variables.rootfolder)>
<cfset variables.folder = variables.rootfolder[1]>
<cfelse>
<cfset variables.folder = variables.rootfolder>
</cfif>
So yeah, here is the part I really don't like. As I said, you can use an array of root folders, and you can ask for a specific one via the URL. I use url.root for that value. I didn't want you to pass in a string value of course, so instead, I simply let you pass in the index. This requires some knowledge of the configuration. All in all, this feels a bit wonky. You've never had to really hide the paths of resources before, so why bother? If I were to rewrite this, I'd probably suggest folks use the web root and simply use the subfolders when requesting resources. Actually, I don't have to rewrite it - that works already. So um - consider it deprecated. ;) Ok, carrying on....
<!--- Set our content type based on roottype --->
<cfif url.roottype is ".js">
<cfset variables.contenttype = "text/javascript">
<cfelse>
<!--- I set url.roottype just to be anal since we use it again later for file security --->
<cfset url.roottype = ".css">
<cfset variables.contenttype = "text/css">
</cfif>
<!---
Root Type: This should be .js or .css.
--->
<cfparam name="url.roottype" default=".js">
The next block of code handles setting up a requirement for the type of file being requested. This will allow us to do a security check later on, and it also allows us to use the right content type. I could have simply looked at the first resource requested (is it something.js or something.css), but I felt like being anal about allowed me to really lock it down to *.js or *.css.
<!--- If blank, quickly leave. --->
<cfif url.list is "">
<cfabort>
</cfif>
<cfparam name="url.list" default="">
Here we define the URL parameter that will contain the requested resources.
<cfparam name="url.refreshcache" default="0">
Here is our hook to let us refresh the cache. More on that in a bit.
<cfset variables.scope = "application">
The service will use a cache for it's file work. I can't imagine needing a different scope than Application, but if you need to - you can tweak it.
<cfset variables.cacheRoot = "_multiloadres2">
And there is the key used for the cache.
<cfset cacheScope = structGet(variables.scope)>
<cfset needInit = false>
<cflock scope="#variables.scope#" type="readOnly" timeout="10">
<cfif not structKeyExists(cacheScope, variables.cacheRoot)>
<cfset needInit = true>
</cfif>
</cflock>
<cfif needInit>
<cflock scope="#variables.scope#" type="exclusive" timeout="10">
<cfif not structKeyExists(cacheScope, variables.cacheRoot)>
<cfset cacheScope[variables.cacheRoot] = {}>
</cfif>
</cflock>
</cfif>
There we have the cache set up routine. Notice I use structGet (remember it?) to create a pointer to the scope holding my cache. I do the necessary locks and create the root structure for my cache if I need to.
<cfif structKeyExists(cacheScope[variables.cacheRoot], url.list)>
<cfheader name="expires" value="#getHTTPTimeString("1/1/2032")#">
<cfcontent type="#variables.contenttype#"><cfoutput>#cacheScope[variables.cacheRoot][url.list]#</cfoutput><cfabort>
</cfif> </cfif>
<cfif isBoolean(url.refreshcache) and not url.refreshcache>
Now that we have a cache, we can actually use it - if it exists in the cache. Notice too the use of the expires header. YSlow pointed this out to me during my development. To my readers in 2032, I apologize. Also, please tell the alien overlords to be nice to my kids.
<cfset buffer = "">
<cfloop index="res" list="#url.list#">
<!--- For each file, if it contains .., assume it is a hack attempt and immediately barf. --->
<cfif find("..", res)>
<cfabort>
</cfif>
<!--- For each file, if it does not end in js, assume it is a hack attempt and immediately barf. --->
<cfif right(res, len(url.roottype)) is not url.roottype>
<cfabort>
</cfif>
<cfset trueFile = variables.folder & "/" & res>
<!--- If the file doesn't exist, we skip. Don't throw an error because we don't want to be used to scan the system. --->
<cfif fileExists(trueFile)>
<cfset buffer &= fileRead(trueFile)>
</cfif>
</cfloop>
Woot! Finally some real work. Here you can see how we loop over the list of requested files. For each, I'm going to do a quick extension check, and if it passes, and if the file exists, I read the contents into a buffer variable. That's really it. Pretty simple, right?
<cfif len(buffer)>
<cfset cacheScope[variables.cacheRoot][url.list] = buffer>
<cfif isBoolean(url.refreshcache) and not url.refreshcache>
<cfheader name="expires" value="#getHTTPTimeString("1/1/2032")#">
<cfelse>
<cfheader name="expires" value="#getHTTPTimeString(now())#">
</cfif>
<cfcontent type="#variables.contenttype#"><cfoutput>#cacheScope[variables.cacheRoot][url.list]#</cfoutput>
</cfif>
The final bits then simply handle storing the buffer into the cache and finally returning it to the client. Again, note the user of the expires header. Ok, so how does it look when I use it? Here is a modified form of the original HTML:
</head>
<body>
Hello World.
</body>
</html>
<html>
<head>
<script src="multiload.cfm?list=jquery.js,jquery.cookie.js,jquery.flot.js,jquery.validate.js,jquery.selectboxes.js"></script>
<script>
$(document).ready(function() {
$("body").append("<p>Loaded jQuery")
})
</script>
<link rel="stylesheet" href="multiload.cfm?root=2&roottype=.css&list=newton_ie.css,newton_ns.css,toc.css" type="text/css" />
As you can see, each of my multiple JS and CSS requests have been turned into one. For the CSS one I have to specify a bit more as most of my defaults are for JS. But really, it isn't that difficult to use. And the result?
Woot! An A! Those of you who know how fragile my ego is will not be surprised to hear this made me do a quick little dance of joy. Anyway, it was really fun to build this, but again, I'll point people to the combine project by Joe Roberts. His also adds compression to the mix for even more performance. The complete code for the script is below. Enjoy.
Note - this is the only line you need to edit (most likely!)
--->
<!---
<cfset variables.rootfolder = "/Library/WebServer/Documents/jquery/">
--->
<cfset variables.rootfolder = ["/Library/WebServer/Documents/jquery/", "/Library/WebServer/Documents/cfdocs/"]> <cfparam name="url.root" default="">
<!--- Only care about url.root if passed and if variables.rootfolder is an array --->
<cfif len(url.root) and isArray(variables.rootfolder) and isNumeric(url.root) and
url.root gte 1 and url.root lte arrayLen(variables.rootfolder) and round(url.root) is url.root>
<cfset variables.folder = variables.rootfolder[url.root]>
<cfelseif isArray(variables.rootfolder)>
<cfset variables.folder = variables.rootfolder[1]>
<cfelse>
<cfset variables.folder = variables.rootfolder>
</cfif> <!---
Root Type: This should be .js or .css.
--->
<cfparam name="url.roottype" default=".js"> <!--- Set our content type based on roottype --->
<cfif url.roottype is ".js">
<cfset variables.contenttype = "text/javascript">
<cfelse>
<!--- I set url.roottype just to be anal since we use it again later for file security --->
<cfset url.roottype = ".css">
<cfset variables.contenttype = "text/css">
</cfif> <!---
List of resources to load.
--->
<cfparam name="url.list" default=""> <!--- If blank, quickly leave. --->
<cfif url.list is "">
<cfabort>
</cfif> <!---
If true, we don't use a cached version of the resource.
--->
<cfparam name="url.refreshcache" default="0"> <!---
Default scope for the cache. No reason normally to tweak this.
--->
<cfset variables.scope = "application"> <!---
Key used to store cached info
--->
<cfset variables.cacheRoot = "_multiloadres2"> <!---
Ok, begin working.
---> <!--- I handle creating initial cache struct --->
<cfset cacheScope = structGet(variables.scope)>
<cfset needInit = false>
<cflock scope="#variables.scope#" type="readOnly" timeout="10">
<cfif not structKeyExists(cacheScope, variables.cacheRoot)>
<cfset needInit = true>
</cfif>
</cflock>
<cfif needInit>
<cflock scope="#variables.scope#" type="exclusive" timeout="10">
<cfif not structKeyExists(cacheScope, variables.cacheRoot)>
<cfset cacheScope[variables.cacheRoot] = {}>
</cfif>
</cflock>
</cfif> <!--- I handle caching concerns --->
<cfif isBoolean(url.refreshcache) and not url.refreshcache> <cfif structKeyExists(cacheScope[variables.cacheRoot], url.list)>
<cfheader name="expires" value="#getHTTPTimeString("1/1/2032")#">
<cfcontent type="#variables.contenttype#"><cfoutput>#cacheScope[variables.cacheRoot][url.list]#</cfoutput><cfabort>
</cfif> </cfif> <!--- I handle loading my files --->
<cfset buffer = "">
<cfloop index="res" list="#url.list#">
<!--- For each file, if it contains .., assume it is a hack attempt and immediately barf. --->
<cfif find("..", res)>
<cfabort>
</cfif>
<!--- For each file, if it does not end in js, assume it is a hack attempt and immediately barf. --->
<cfif right(res, len(url.roottype)) is not url.roottype>
<cfabort>
</cfif>
<cfset trueFile = variables.folder & "/" & res>
<!--- If the file doesn't exist, we skip. Don't throw an error because we don't want to be used to scan the system. --->
<cfif fileExists(trueFile)>
<cfset buffer &= fileRead(trueFile)>
</cfif>
</cfloop> <!--- All done - if we actually have content, cache it and store it. --->
<cfif len(buffer)>
<cfset cacheScope[variables.cacheRoot][url.list] = buffer>
<cfif isBoolean(url.refreshcache) and not url.refreshcache>
<cfheader name="expires" value="#getHTTPTimeString("1/1/2032")#">
<cfelse>
<cfheader name="expires" value="#getHTTPTimeString(now())#">
</cfif>
<cfcontent type="#variables.contenttype#"><cfoutput>#cacheScope[variables.cacheRoot][url.list]#</cfoutput>
</cfif>
<cfsetting enablecfoutputonly="true" showdebugoutput="false">
<!---
Root Folder:
This may be a simple path (full path!) or an array. Using an array allows you to use
one instance of this script and N root paths. If an array is used, you specify a specific
root folder by using root=N. It may be confusing to have to specify the array index, but it
also means you aren't passing a real path along the query string. You can also leave it out
for the 1st item in the array.