JS、CSS、画像ファイルといった静的ファイルにリビジョンを付与してブラウザのキャッシュ問題を回避する - Gulp で作る Web フロントエンド開発環境 #9

【前置き】ブラウザのキャッシュ問題について

Web ページ高速化の手法のひとつに『キャッシュサーバーやブラウザキャッシュを活用する』というのがあります。予めキャッシュ済みの静的ファイル ( 画像、CSS、JS など ) をキャッシュサーバーから高速で返したり、Web ブラウザ自身でキャッシュしておいたものを即座に表示・実行するというもので、サーバー負荷の軽減やスピードアップによる快適なユーザー体験の提供といった効果が見込めます。これだけ聞くと良いことずくめのように思えますが、時にはそれが足かせになることもあります。

Web サイトリリース後に CSS や JS に何らかの不具合が見つかり、その修正版を本番サーバーに再リリースしたとしましょう。普通であれば修正版リリース以降にアクセスしてきたユーザーには、修正が適用された状態の Web ページが提供されるはずですよね?しかし、もしキャッシュ機能が有効となっているとブラウザが修正版のファイルを取得しに来てくれずに古いものが提供され続けるといった状態に陥ってしまいます。これがキャッシュ問題です。

このブラウザのキャッシュですが、そのまま放置していてもなかなか消えてくれません。例えば Web サーバーに NGINX を使用している場合、expire ヘッダというものを付与することでブラウザに静的ファイルなどをキャッシュするよう指示することができます。静的ファイルが更新されたらそのキャッシュをクリアすることでブラウザに再取得させることが出来るということになっています。出来るはずなのです。しかし出来ずにずっとキャッシュされっぱなしということがよく起こりえます。とても困りますね。
(´・ω・`)

【対処法】リリースする度にファイル名を変えてしまえばいいのだ

Ruby on Rails に同梱されている Asset Pipeline というフレームワークには Sass, CoffeeScript, ERB のビルド、ミニファイ化、ファイル結合の他に、ビルドして生成されたファイル名にコンテンツハッシュを付与するという機能が備わっています。例えば app.css というCSSファイルが生成されると以下のような名前に変換されます。

app-908e25f4bf641868d8683022a5b62f54.css

Fingerprinting と呼ばれる機能ですが、ここで付与される文字列はファイルの中身をハッシュ化したものとなります。つまり更新される度にことなるファイル名となるため、確実にブラウザのキャッシュ問題を回避することができるというわけです。

【本題】ファイル名にリビジョンを付与する Gulp タスクを作成する

Fingerprinting と同じ仕組みを Gulp 上に作成してみるとします。ファイル名にリビジョンと呼ばれる文字列を自動で付与し、静的ファイルを読み込む側のコードもそのファイル名に差し替えるようにします。ここではリビジョンと呼びましたが、実際は Fingerprinting 同様にコンテンツハッシュが付与されます。

前提条件

  • Mac OS X El Capitain
  • Node.js インストール済み ( v5.4.0 ~ )1)Node.js は Ver.4.0 から ES6 で記述出来るようになりましたので、当エントリの Gulp タスクも ES6 で書いております。

ディレクトリおよびファイル構成は以下になります。

.
├── app/
│   ├── images/
│   ├── scripts/
│   │   ├── Main.ts
│   │   └── reference.ts
│   ├── styles/
│   │   ├── _variables.scss
│   │   ├── app.scss
│   │   └── modules
│   ├── templates/
│   │   ├── index.jade
│   │   ├── pages/
│   │   └── templates/
│   └── vendors
│       └── jquery.min.js
├── bower.json
├── bower_components/
├── dtsm.json
├── gulp/
│   ├── config.js
│   └── tasks/
├── gulpfile.js
├── node_modules/
├── package.json
├── public/
│   ├── assets/
│   │   ├── css/
│   │   ├── fonts/
│   │   ├── images/
│   │   ├── js/
│   │   └── vendors/
│   └── index.html
└── typings/

ソースコードはJade、SCSS、TypeScriptで記述し、それぞれビルドして HTML、CSS、JavaScript として出力します。出力先はpublic/ディレクトリ配下とし、ここに出力されたファイルに対してリビジョンを付与します。

gulp-rev

gulp-rev はファイル名にコンテンツハッシュの文字列を付与することでリビジョン管理することが出来る Node モジュールです。要するに Asset Pipeline の Fingerprinting と同じ機能を提供するものというわけです。

以下のコマンドを実行してモジュールをインストールします。

$ npm install --save-dev gulp-rev

無事にインストール出来たら Gulp タスクを定義します。

var gulp = require('gulp');
var rev  = require('gulp-rev');
gulp.task('rev', () => {
    return gulp.src('./public/assets/**/*.+(js|css|png|gif|jpg|jpeg|svg|woff)')
        .pipe(rev())
        .pipe(gulp.dest('./public/assets'));
});

public/assetsディレクトリ以下にある全ての静的ファイルに対してリビジョンを付与します。これらに対して rev()メソッドを実行すると、コンテンツハッシュが付与されたファイルが新たに生成されます。生成されたファイルの出力先は上記のように public/assets とだけ指定しておけばそれぞれ元となったファイルと同じディレクトリに出力されます。

Gulp タスクを実行する

タスクを実行すると、元となる静的ファイルが複製されてファイル名にコンテンツハッシュが付与された状態で出力されます。

$ gulp rev
[13:58:47] Using gulpfile ~/Documents/sandbox/createjs/try_createjs/gulpfile.js
[13:58:47] Starting 'rev'...
[13:58:48] Finished 'rev' after 

出力結果はこちら。割と簡単にリビジョンを付与することが出来ました。

./public/
├── assets/
│   ├── css/
│   │   ├── app.3b08ebea.css
│   │   └── app.css
│   ├── fonts/
│   │   ├── fontawesome-webfont.076a7c59.woff  // リビジョン付き
│   │   ├── fontawesome-webfont.29800836.svg   // リビジョン付き
│   │   ├── fontawesome-webfont.svg
│   │   └── fontawesome-webfont.woff
│   ├── images/
│   │   ├── logo-wakamsha.dc28b988.jpg         // リビジョン付き
│   │   ├── logo-wakamsha.jpg
│   │   ├── sprite_sheet.ba67ee99.png          // リビジョン付き
│   │   └── sprite_sheet.png
│   ├── js/
│   │   ├── app.098db440.js                    // リビジョン付き
│   │   ├── app.js
│   │   ├── vendor.56deda0e.js                 // リビジョン付き
│   │   └── vendor.js
│   └── vendors/
│       ├── jquery.min.2ae98805.js             // リビジョン付き
│       └── jquery.min.js
└── index.html

と、ここまでは良いのですが、せっかく静的ファイルにリビジョンを付与してもそれらを読み込む側がそのファイル名に対応していなければ何の意味もありません。app.3b08ebea.cssとなったのであれば、HTML 側のファイルパスもこれに合わせる必要があります。

NG
<link rel="stylesheet" href="./assets/css/app.css"/>
.hoge {
    background-image: url(../images/logo-wakamsha.jpg);
}
OK
<link rel="stylesheet" href="./assets/css/app-0e2fb77cdb.css"/>
.hoge {
    background-image: url(../images/logo-wakamsha.dc28b988.jpg);
}

これを解決するために、まずは元のファイル名とリビジョン付与後のファイル名のマッピングを生成する必要があります。

リビジョンファイルのマッピングリストを生成する

gulp-rev には元のファイル名とリビジョン付与後のファイル名がマッピングされた JSON 形式のマニフェストファイルを生成する機能があります。先ほどの Gulp タスクを以下のように書き換えます。

var gulp   = require('gulp');
var rev = require('gulp-rev');
gulp.task('rev', () => {
    return gulp.src('./public/assets/**/*.+(js|css|png|gif|jpg|jpeg|svg|woff)')
        .pipe(rev())
        .pipe(gulp.dest('./public/assets'))
        .pipe(rev.manifestFile())
        .pipe(gulp.dest('./public/assets'));
});

これでタスクを実行するとrev-manifest.jsonというマニフェストファイルが生成されます。

{
  "css/app.css": "css/app.131cdf1e.css",
  "fonts/fontawesome-webfont.svg": "fonts/fontawesome-webfont.29800836.svg",
  "fonts/fontawesome-webfont.woff": "fonts/fontawesome-webfont.076a7c59.woff",
  "images/logo-wakamsha.jpg": "images/logo-wakamsha.dc28b988.jpg",
  "images/sprite_sheet.png": "images/sprite_sheet.ba67ee99.png",
  "js/app.js": "js/app.098db440.js",
  "js/vendor.js": "js/vendor.56deda0e.js",
  "vendors/jquery.min.js": "vendors/jquery.min.2ae98805.js"
}

JSON ファイル名を変更したい場合は、rev() の第一引数にファイル名を指定することで任意の名前で生成することが出来ます。

rev.manifestFile('my-revision.json');

マッピングリストが出来たので後はこれを読み込む側ファイルに対応させるだけです。これには次に紹介する gulp-rev-replace という Node モジュールを使います。

gulp-rev-replace

gulp-rev-replace は HTML や CSS などに書かれたファイル名を gulp-rev で生成されたリビジョン付きのファイル名に置換する事ができる Node モジュールです。

以下のコマンドを実行してモジュールをインストールします。

$ npm install --save-dev gulp-rev-replace

インストールが完了したところで Gulp タスクを定義します。

var gulp       = require('gulp');
var revReplace = require('gulp-rev-replace');
gulp.task('rev:replace', () => {
    var manifest = gulp.src('./public/assets/rev-manifest.json');
    return gulp.src('./public/**/*.+(html|css|js)')
        .pipe(revReplace({manifest: manifest}))
        .pipe(gulp.dest('./public/'));
});

ターゲットを./public./配下にある全ての html, css, js ファイルとし、これらのファイルに記述されている静的ファイル名を先ほど gulp-rev から生成したマニフェストファイルを元に置換します。

Gulp タスクを実行する

コマンドを入力して Gulp タスクを実行します。

$ gulp rev:replace
[17:49:55] Using gulpfile ~/Documents/sandbox/createjs/try_createjs/gulpfile.js
[17:49:55] Starting 'rev:replace'...
[17:49:55] Finished 'rev:replace' after 151 ms

置換後の HTML と CSS はこちら。

<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8">
        <title>Hello, gulp-rev</title>
        <link rel="stylesheet" href="./assets/css/app-0e2fb77cdb.css"/>
    </head>
    <body>
        <script src="./assets/js/vendor-56deda0eab.js"></script>
        <script src="./assets/vendors/glowfilter-2ae9880557.min.js"></script>
        <script src="./assets/js/app-098db44069.js"></script>
    </body>
</html>
.hoge {
    background-image: url(../images/logo-wakamsha-0078650779.jpg);
}

マニフェストの内容と一致する箇所が全て置換されました。これで本番環境にリリースしても更新済みのファイルが読み込まれるようになります。

締め

冒頭でも述べましたが、ブラウザキャッシュを放置しておくと高確率で非常に面倒なことになります。修正版をリリースしたにも関わらずそれが反映されていなかったり、変に一部だけ反映されてしまったことで却って悪化してしまったりといったトラブルの原因になりかねません。我々 Web 開発者であればそのような状況に遭遇してもすぐにキャッシュが原因だと気が付き、自分でブラウザのキャッシュを削除するなどして対処することが出来るでしょうが、一般のエンドユーザーにそこまで求めることは難しいでしょう。地道な対処法ではありますが、プロダクトを安心してリリースし続けるためにも、こういった仕組みは積極的に自動化しておくことをオススメします。

脚注

脚注
1 Node.js は Ver.4.0 から ES6 で記述出来るようになりましたので、当エントリの Gulp タスクも ES6 で書いております。