Customizations to merging, versioning and compressing CSS and Javascript
Ed Eliot is my favorite programming blogger right now. He just seems to write cool, thorough, well-documented PHP code on topics that are very synergistic to stuff I'm usually thinking about for NewsCloud.
He wrote a great piece on merging and versioning CSS and Javascript files to speed performance and prevent out of date code from appearing on user's browsers. Then, he followed up with integration with a compression engine for the Javascript files. It's very well written, simple and handles all the caching complexity.
If you load lots of script files and stylesheets from separate files in the HEAD of your HTML, more server requests slow performance. Also, when you make a change to any individual script or css, client browser caching may not update it right away and users will see broken or odd behavior. Ed's code solves these problems.
NewsCloud's page architecture is a bit more complex and we use a different mix of JS and CSS files for different pages. This is something I hope to fix over time. But I had to customized Ed's approach by splitting the code into two pieces. I extended his code to work for multiple pages and in different modes for JS or CSS. I also had to break his code into two parts. One part checks for the latest timestamps and rebuilds a cached bundle of files. The second part, now shorter, servers the proper bundle based on the client's browser cache. I also make some notes about using this technique with Prototype and Scriptaculous at the end below.
Overall, things seem a lot faster. The JSMin compression makes a big difference too. I should see bandwidth savings over time as NewsCloud grows.
Technorati Tags: css, ed eliot, javascript, javascript merging, javascript versioning, jsmin
Here are the classes I call in my page architecture when I'm generating the NewsCloud HTML page:
function fetchPkgVersion($page,$files,$mode='js',$jsCompress=false) {
// $page is a short prefix friendly string for the page name e.g. cover or friends
// $files is an array list of relative paths to the style sheets or scripts
// $mode tells whether we're building js or css
// $jsCompress is a boolean to determine whether to use compression
define('ARCHIVE_FOLDER', 'cache'); // location to store archive, don't add starting or trailing slashes
define('JSMIN_PATH', 'extlib/combine'); // full path to JSMin executable
define('JSMIN_COMMENTS', ''); // any comments to append to the top of the compressed output
define('JSMIN_AS_LIB', true);
$sDocRoot = $_SERVER['DOCUMENT_ROOT'];
// get file last modified dates
$aLastModifieds = array();
foreach ($files as $sFile) {
$aLastModifieds[] = filemtime("$sDocRoot/$sFile");
}
// sort dates, newest first
rsort($aLastModifieds);
$iETag=$aLastModifieds[0];
// create a directory for storing current and archive versions
if (!is_dir("$sDocRoot/".ARCHIVE_FOLDER)) {
mkdir("$sDocRoot/".ARCHIVE_FOLDER);
}
$sMergedFilename = "$sDocRoot/".ARCHIVE_FOLDER."/".$page."_".$iETag.".".$mode;
// if it does not exist, we need to create a new merged package
if (!file_exists($sMergedFilename)) {
// get and merge code
$sCode = '';
$aLastModifieds = array();
foreach ($files as $sFile) {
$aLastModifieds[] = filemtime("$sDocRoot/$sFile");
$sCode .= file_get_contents("$sDocRoot/$sFile");
}
// sort dates, newest first
rsort($aLastModifieds);
// reset iETag incase of late breaking file update
$iETag=$aLastModifieds[0];
$sMergedFilename = "$sDocRoot/".ARCHIVE_FOLDER."/".$page."_".$iETag.".".$mode;
$this->pkgWrite($sMergedFilename, $sCode);
if ($jsCompress) { require_once("$sDocRoot/".JSMIN_PATH."/jsmin.php");
if (JSMIN_COMMENTS != '') {
$jsMin = new JSMin(file_get_contents($sMergedFilename), false, JSMIN_COMMENTS);
} else {
$jsMin = new JSMin(file_get_contents($sMergedFilename), false);
}
$sCode = $jsMin->minify();
$this->pkgWrite($sMergedFilename, $sCode);
}
}
// return latest timestamp
return $iETag;
}
function pkgWrite($sFilename, $sCode) {
$oFile = fopen($sFilename, 'w');
if (flock($oFile, LOCK_EX)) {
fwrite($oFile, $sCode);
flock($oFile, LOCK_UN);
}
fclose($oFile);
}
Notice how I've extended Ed's eTag to be a string with the page name and the mode. This differentiates cached files for different pages and script or css.
$sMergedFilename = "$sDocRoot/".ARCHIVE_FOLDER."/".$page."_".$iETag.".".$mode;
Here is the shorter version of Ed's combine.php page that actually feeds the file to the browser based on the cache. Notice, it uses the extended eTag concept to check the cache based on the $page and $mode.
<?php
// Written by Ed Eliot (www.ejeliot.com) - provided as-is, use at your own risk, then customized for NewsCloud
define('CACHE_LENGTH', 31356000); // length of time to cache output file, default approx 1 year
define('ARCHIVE_FOLDER', 'cache'); // location to store archive, don't add starting or trailing slashes
if (isset($_GET['page']) AND isset($_GET['mode']) AND isset($_GET['version'])) {
$page=$_GET['page'];
$mode=$_GET['mode'];
$iETag = (int)$_GET['version'];
} else die ('Missing arguments');
if ($mode=='js') {
define('FILE_TYPE', 'text/javascript');
} else {
define('FILE_TYPE', 'text/css');
}
$sLastModified = gmdate('D, d M Y H:i:s', $iETag).' GMT';
// see if the user has an updated copy in browser cache
if (
(isset($_SERVER['HTTP_IF_MODIFIED_SINCE']) && $_SERVER['HTTP_IF_MODIFIED_SINCE'] == $sLastModified) ||
(isset($_SERVER['HTTP_IF_NONE_MATCH']) && $_SERVER['HTTP_IF_NONE_MATCH'] == $iETag)
) {
header("{$_SERVER['SERVER_PROTOCOL']} 304 Not Modified");
exit;
}
$sDocRoot = $_SERVER['DOCUMENT_ROOT'];
$sMergedFilename = "$sDocRoot/".ARCHIVE_FOLDER."/".$page."_".$iETag.".".$mode;
$sCode = file_get_contents($sMergedFilename);
// output merged code
// send HTTP headers to ensure aggressive caching
$ETag=$page.$mode.$iETag;
header('Expires: '.gmdate('D, d M Y H:i:s', time() + CACHE_LENGTH).' GMT'); // 1 year from now
header('Content-Type: '.FILE_TYPE);
header('Content-Length: '.strlen($sCode));
header("Last-Modified: $sLastModified");
header("ETag: $ETag");
header('Cache-Control: max-age='.CACHE_LENGTH);
echo $sCode;
?>
I added the PHP-based JSMin compression posted to Ed's blog by Martin. Martin also has an interesting post on Web site performance optimization.
I also wrote two helper functions. The scripts and stylesheets arrays are used by NewsCloud to construct the HTML head.
function pkgScripts($page='default',$scripts) {
$this->scripts[]="/pkg/".$page."_".$this->fetchPkgVersion($page,$scripts,'js',true).".js";
}
function pkgStyles($page='default',$sheets) { $this->stylesheets[]="/pkg/".$page."_".$this->fetchPkgVersion($page,$sheets,'css',false).".css";
}
Here is the updated line for your .htaccess file:
RewriteRule ^pkg/([a-zA-Z0-9]+)_([0-9]+).([a-z]+)$ combine.php?page=$1&version=$2&mode=$3 [L]
Requests come to /pkg/page_timestamp.mode and get mapped to the combine.php file.
For a given page, I can make these calls to include a bundle of css or js files on any given page. Notice how I bundle prototype and scriptaculous in the pkgScripts call. the order is important...and I had to comment out the constructor document.write in scriptaculous.
$this->page->pkgStyles('cover',array( 'styles/newCommon.css','styles/paging.css','styles/story.css'));
$this->page->pkgScripts('cover',array('scripts/tooltips.js','scripts/dg/ajax-dynamic-content.js','scripts/lightbox.js','scripts/cover.js','scripts/storyCommands.js','scripts/slideShow.js'));
Thanks again to Ed and Martin!

Nice article, I just looking how to put the Jsmin straight to my blog as a service, would you help me? Thanks..
Posted by: MC | Solution for Shutdown Problem | Nov 22, 2009 at 08:47 PM