fine, later feeling

晴れのち気分

webpackで自動でJSファイルにリビジョンを付けて配信する

業務でレガシーなフロントエンドJSをbundleして、自動でリビジョンを付けて配信するようなタスクを任されたのでその軌跡を紹介します。

自動でリビジョンを付けて配信するというのは、このようなランダムな文字列が付与された静的ファイルのことです。

mypage-7dae16c420f219dde9b5.bundle.js

毎回ソースコードに変更がある状態でビルドすると毎回違うリビジョンが自動で付与されます。

これにどんなメリットがあるかというと、ファイル名が変わることでブラウザは新規ファイルだと判断しGETしてくれます。

ブラウザが良きにとキャッシュしてしまうとソースコードに変更があるのに(キャッシュされた)古いソースコードを参照してしまって予期せぬ挙動をすることがあります。

それを防止するために、確実に最新のJSを取得してもらうためファイル名を変更して配信することが良いことだと言われます。

つまりRailsでお馴染みのアセットパイプラインをwebpackを使ってbundleするついでにやってしまおうということです。

今回の要件は

  • ES2015で書かれたJSをbabelでトランスパイルしてbundleすること
  • SPAではないので画面ごとにbundleされたJSが欲しい
  • ミニファイすること
  • リビジョンを付与してブラウザキャッシュを回避すること

完成版はgithubで公開してます。

github.com

準備

必要なモジュールを導入します。

$ npm i -D babel-cli babel-core babel-loader babel-polyfill babel-preset-es2015 babel-preset-stage-0 webpack assets-webpack-plugin

モジュールがインストールできたら.bablercを定義します。

{
  "presets": [
    "es2015",
    "stage-0"
  ]
}

次にwebpack.config.babel.jsを作成し、以下を定義します。

import 'babel-polyfill';
import path from 'path';
import webpack from 'webpack';
import AssetsPlugin from 'assets-webpack-plugin';

const DEBUG = !process.argv.includes('--release');
const VERBOSE = process.argv.includes('--verbose');
const assetsPluginInstance = new AssetsPlugin();

export default {
  cache: DEBUG,

  debug: DEBUG,

  stats: {
    colors: true,
    reasons: DEBUG,
    hash: VERBOSE,
    version: VERBOSE,
    timings: true,
    chunks: VERBOSE,
    chunkModules: VERBOSE,
    cached: VERBOSE,
    cachedAssets: VERBOSE,
  },

  entry: {
    mypage: ['./src/js/mypage.entry.js'],
    login: ['./src/js/login.entry.js']
  },

  output: {
    publicPath: 'dist/',
    sourcePrefix: '  ',
    path: path.join(__dirname, 'dist'),
    filename: '[name]-[hash].bundle.js',
  },

  target: 'web',

  devtool: DEBUG ? 'cheap-module-eval-source-map' : false,

  plugins: [
    new webpack.optimize.OccurenceOrderPlugin(),
    new webpack.DefinePlugin({ 'process.env.NODE_ENV': `"${process.env.NODE_ENV || (DEBUG ? 'development' : 'production')}"` }),
    ...(DEBUG ? [] : [
      new webpack.optimize.DedupePlugin(),
      new webpack.optimize.UglifyJsPlugin({ compress: { screw_ie8: true, warnings: VERBOSE, drop_console: true } }),
      new webpack.optimize.AggressiveMergingPlugin(),
    ]),
    assetsPluginInstance
  ],

  resolve: {
    extensions: ['', '.js', '.ts'],
  },

  module: {
    loaders: [
      { test: /\.js?$/, include: [path.resolve(__dirname, 'src/js')], loader: 'babel' },
      { test: /\.ts?$/, include: [path.resolve(__dirname, 'src/js')], loader: 'babel' },
    ],
  },

};

今回はマイページとログインページそれぞれでbundleされたJSを配信するということでmypage.entry.jslogin.entry.jsの2つを記述しています。 /src/js/以下に2つのJSファイルを作成します。

import 'babel-polyfill';
import { sleep } from './common';

class MyPage {

    async start() {
        console.log('start');
        await sleep(2000);
        console.log('This page is mypage');
        console.log('end');
    }
}

document.addEventListener('DOMContentLoaded', () => {
    const myPage = new MyPage();
    myPage.start();
});

login.entry.jsも上記をコピペしてconsole.logの中身の文言とclass名を修正します。

import 'babel-polyfill';
import { sleep } from './common';

class Login {

    async start() {
        console.log('start');
        await sleep(2000);
        console.log('This page is login');
        console.log('end');
    }
}

document.addEventListener('DOMContentLoaded', () => {
    const login = new Login();
    login.start();
});

これらのJSから共通で依存しているcommon.jsを同じディレクトリに作成します。

export const sleep = msec => new Promise((resolve) => {
  setTimeout(resolve, msec);
});

また出力先は/dist/になるのでディレクトリを作っておきます。 この時点でディレクトリ構成は以下のようになっているはずです。

├── .babelrc
├── dist
├── package.json
├── src
│   └── js
│       ├── common.js
│       ├── login.entry.js
│       └── mypage.entry.js
└── webpack.config.babel.js

これで準備は完了です。

webpackコマンドを叩くのにオプションをつけるのが面倒なのでnpmスクリプトを登録しておきます。

  "scripts": {
    "clean": "rm -rf dist/*",
    "build": "rm -rf dist/* && webpack --devtool source-map --verbose",
    "prod": "rm -rf dist/* && webpack --release"
  }

bundleする

さっそくbundleしてみます。

$ npm run build

上記コマンドを実行するとトランスパイル→bundleしてくれて、さらにデバッグ用のsource-mapを生成します。

sourcemapとはes2015で書いたコードとビルド(bundle)されたコードをマッピングし、ランタイムエラーがあった場合本来のes2015で書いたコードでエラー箇所を教えてくれるツールです。

これがないとエラーがあった場合コンソールのスタックトレースだけが頼りでデバッグをしなくてはならず、死ぬ思いをします。

無事ビルドが成功すると/dist/にランダム文字列が付与されたJSが生成されているはずです。(.mapはソースマップファイルです)

(中略)
├── dist
│   ├── login-7dae16c420f219dde9b5.bundle.js
│   ├── login-7dae16c420f219dde9b5.bundle.js.map
│   ├── mypage-7dae16c420f219dde9b5.bundle.js
│   └── mypage-7dae16c420f219dde9b5.bundle.js.map
(中略)

このランダム文字列はコンパイル時のハッシュ値が付与されています。

ソースコードに変更がない状態でコンパイルしてもハッシュ値は変更しないので、何度ビルドしてもファイル名は変わりません。

逆にソースコードに変更を加えるとハッシュ値が変わり新たなファイル名で生成されます。

そしてビルドした際にwebpack-assets.jsonという新たなjsonファイルが生成されていることに気がつくと思います。

これは元のJSファイル(entryポイント)とハッシュ値が付与されたJSの対応表みたいなものです。

デフォルトでwebpack.config.babel.jsと同じ階層に出力されますが出力先を変更したい場合はインスタンス生成時にコンストラクタにパスを渡してあげるとOKです。

// /app/conf/に出力
const assetsPluginInstance = new AssetsPlugin({path: path.join(__dirname, 'app', 'conf')});

ビルドして新たなファイルが生成される度に自動でこのマッピングファイルも更新されます。

結局はこのビルドされたJSを<script>タグで読み込む必要があります。

毎回ビルドする度に<script src="">を修正するのも大変です。

nodeやRails、Play2などサーバサイドアプリケーションでテンプレートエンジンを使っている場合はマッピング用のjsonファイルをパースしてテンプレートに渡すなどの一手間が必要になります。

テンプレートエンジンを使用していない場合は以下のようなリプレースしてくれるモジュールを入れる必要があります。 (今回はPlay2を使用していたので以下のようなモジュールは使用していません)

www.npmjs.com

本番で使う

npm run buildは開発用のコマンドです。

今回の要件ではミニファイ化も含まれているのでUglify.jsを使ってミニファイ化します。

外部モジュールのライセンス文もいい感じに残してくれるのでこの辺の不安も解消されます。

webpackにはデフォルトでUglifyのプラグインが含まれているのでこれを使用します。

# 本番向けのビルドコマンド
$ npm run prod

これでファイル容量の削減と難読化が実現できました。 drop_console: trueにしているのでデバッグ用などで書かれたconsole.logを削除してくれます。

まとめ

サーバサイドだけ更新されてもブラウザキャッシュが効いて更新されないとサーバサイドと不整合が起きて予期せぬ動作を起こすことがあります。

最近はCIで継続的なデプロイが主流になっています。

継続的なデリバリーを実現するためにブラウザキャッシュを回避して、確実に最新プロダクトを届けられるような仕組みを自動化しておくことが大切だと思います。