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

2 comments:

Kieran Hall said...

One point that I think is worth mentioning for anybody looking to serve up their JavaScript framework's a little quicker is the benefits of using a CDN (such as Google's).

See the responses to this question for clarification of what I'm talking about.

http://stackoverflow.com/questions/547384/where-do-you-include-the-jquery-library-from-google-jsapi

mrclay said...

What you have will work fine for browsers directly hitting your server, but if a proxy caches your output (which you want), you'll want to consider a few more details.

Whenever content-encoding is variable, make sure "Vary: Accept-Encoding" is being sent (ob_gzhandler might do this, but make sure).

Also technically your ETag should be different when a different encoding is applied, but gzhandler can't let you know that info. In practice this may not cause any problems.

Post a Comment