Supercharging Javascript, Part 5: Caching on the Client

Previous: Caching on the Server

This is already a well-established principle of high performance Web Sites. In this last section I am going to incorporate it into the script.

There are alternatives to doing it this way. Some use Apache modules like mod_expires or mod_headers. There is nothing wrong with that approach but for compacting Javascript like this script does, I think it's better done from the script so that's what we'll do.

For those unfamiliar with the concept, the idea is to put a far future Expires header on our Javascript. That way the client doesn't download the Javascript or check if there is a newer version on every page load.

This raises the question: what happens when you change the Javascript and you want the client to get the latest copy? Easy. You change the filename every time you want the client to download it. So firstly we need to modify our rewrite rule.

RewriteRule ^javascript/(\w+)\.(\d+)\.js$ /javascript.php?site=$1&mtime=$2 [L]

There are many techniques for doing this. Common alternatives include adding a dummy query string to the end of the URL. Now we just need to generate the right filename. So we will need a dynamically generated script tag. Instead of:

<script type="text/javascript" src="/javascript/site.js"></script>

we need

<?php
define('SCRIPT_DIR', $_SERVER['DOCUMENT_ROOT'] . '/script/');
define('SCRIPT_PATH', '/javascript/');

$bundles = array(
  'site' => array(
    'jQuery-1.3.2.js',
    'jquery.bgiframe.js',
    'jquery.dimensions.js',
    'supersubs.js',
    'superfish.js',
    'site.js',
  ),
);

function link_javascript($site) {
  global $bundles;
  if (!isset($bundles[$site])) {
    die("javascript.php: Unknown bundle '$site' requested");
  }
  $mtime = 0;
  foreach ($bundles[$site] as $file) {
    $file_mtime = filemtime(SCRIPT_DIR . $file);
    if ($file_mtime !== false && $file_mtime > $mtime) {
      $mtime = $file_mtime;
    }
  }
  return SCRIPT_PATH . $site . '.' . $mtime . '.js';
}
?>

and in our page:

<script type="text/javascript" src="<?php echo link_javascript('site') ?>"></script>

And finally the script that drives it all:

<?php
define('SCRIPT_DIR', $_SERVER['DOCUMENT_ROOT'] . '/script/');
define('CACHE_DIR', $_SERVER['DOCUMENT_ROOT'] . '/cache/');

$bundles = array(
  'site' => array(
    'jQuery-1.3.2.js',
    'jquery.bgiframe.js',
    'jquery.dimensions.js',
    'supersubs.js',
    'superfish.js',
    'site.js',
  ),
);

$site = $_GET['site'];
if (!isset($bundles[$site])) {
  error_log("javascript.php: Unknown bundle '$site' requested");
  exit;
}

$mtime = $_GET['mtime'];
$cache_file = CACHE_DIR . $site . '.js';
$cache_mtime = @filemtime($cache_file);

// we need to rebuild is the passed in mtime is newer than the cache file mtime
if ($mtime > $cache_mtime) {
  require 'jsmin-1.1.1.php';
  $scripts = '';
  foreach ($bundles[$site] as $file) {
    $contents = @file_get_contents(SCRIPT_DIR . $file);
    if ($str === false) {
      error_log("javascript.php: Error reading file '$file'");
    } else {
      $scripts .= $contents;
    }
  }
  $min_content = JSMin::minify($scripts);
  file_put_contents($cache_file, $min_content);
} else {
  $min_content = file_get_contents($cache_file);
}

header('Content-Type: text/javascript');
header('Expires: ' . gmdate('D, d M Y H:i:s', time()+365*24*3600) . ' GMT');
header('ETag: "' . md5($min_content) . '"');

ob_start('ob_gzhandler');

echo $min_content;
?>

The last thing we added was an ETag HTTP header. Because of everything else going on, this is essentially superfluous but, if nothing else, it will make YSlow happier. The expires header is set for one year in the future. This is strictly arbitrary and you can put anything you want there.

Of course, one little problem remains...

Next: The Internet Explorer Problem