JavaScript のサイズを減らすことと効率良くキャッシュさせるためのメモ

題について、頭に浮かんだ考えや、手を動かして試したことのメモ。試行錯誤中なので、都度追記するか、そのときの考えを別途書く。

背景

  • モジュールバンドラに Browserify や Webpack を利用して一枚岩の bundle.js を生成している
  • 機能追加を繰り返したことによって、 bundle.js のサイズが増え、ロードにかかる時間が気になってきた

サイズを減らす心がけ

ファイルを分割する前に

  • ライブラリの選定時に、lodash のように関数ごとにパッケージが分割されており、必要なものだけ install, load できるかを選定の条件にする
  • Webpack2 や Rollup のような Tree Shaking に対応しているモジュールバンドラを利用している場合、Tree Shaking が効くかどうかをライブラリ選定の条件にする
  • ライブラリの機能のほんの一部しか使わない場合は、自身で利用する機能と同じ内容のコードを記述できないか検討する(ついでに元のライブラリに敬意を表しつつ、それを公開できないか検討する)
  • Browserify を使ったプロジェクトでファイルサイズを大きくしているライブラリを探す - kitak.blog に書かれているようなツールを使ってサイズが大きくなる原因のライブラリを特定し、置き換えを試みる

コード分割で効率良くキャッシュさせる

サイズを減らすように心がけてもサイズが大きくなってきたら

  • 前提: ライブラリのコードはアプリケーションのコードよりも変更頻度が少なく、サイズが大きい
  • ライブラリのコードとアプリケーションのコードを分離して、ライブラリのコードをブラウザに長くキャッシュさせるようにする
  • Multi Page, Single Pageを問わず、エントリ(URL)ごとに読み込むスクリプトが異なる場合は、ライブラリのコード(各エントリで共通のJS)のキャッシュが効けば、各エントリで必要なJSのみ取得すればよい
  • 以下のような Webpack2 の設定で、ライブラリのコードとアプリケーションのコードを分離できる (Webpack2 は、この記事を書いている時点でrcですが、あとからバージョンを上げる手間を鑑みて、今のうちから2を使ったほうがよい)
'use strict';

const webpack = require("webpack");
const AssetsPlugin = require('assets-webpack-plugin');

module.exports = {
  context: __dirname + "/src",
  entry: {
    home: "./home.js",
    events: "./events.js",
    contact: "./contact.js",
    vendor: [
      'vue',
      'moment'
      // ...
    ]
  },
  plugins: [
    new webpack.optimize.CommonsChunkPlugin({
      name: "vendor",
      minChunks: 2,
    }),
    new AssetsPlugin({
      filename: 'assets.json',
      processOutput: function (assets) {
        const result = {};
        for (let key of Object.keys(assets)) {
          result[key] = assets[key].js;
        }
        return JSON.stringify(result);
      }
    })
  ],
  output: {
    path: __dirname + "/dist",
    filename: "[name].[chunkhash].bundle.js",
  },
}
  • CommonsChunkPlugin を使うと各エントリで共通の JS をチャンクとして取り出してくれるが、各エントリで利用するライブラリが変わると共通のチャンクも変わるので、vendor エントリでライブラリを明示的に指定する
  • ( 共通コードを抜き出すだけであれば、Browserify でもできる https://github.com/substack/factor-bundle )
  • 毎日ライブラリのバージョンアップ & デプロイを起こっている場合はキャッシュが高々1日しか効かないことを理解しておく
  • Webpack2 で生成される JS ファイルに変更があった場合にブラウザのキャッシュではなく、新しく取得するようにファイル名に chunkhash を入れる
  • AssetsPlugin で生成されたファイル名をファイルに出力できる。html-webpack-plugin で JS をロードする HTML を生成してもよいが、いったん JSON で出力させておけば、サーバ・クライアント両方で扱うことができ、使い方の幅が広がる。出力される内容は processOutput オプションの関数で調整する
  • Single Page の場合、Webpack2 の require.ensure や System.import でオンデマンドにダイナミックローディングを行うのとは別に、Resource hints の preload でブラウザが暇になったタイミングで他エントリのスクリプトをロードさせる手法も有効ではないか
  • Multi Page の場合は、Resource hints の prefetch で事前に他のエントリのスクリプトをフェッチさせてもよいのではないか。先で生成した JSON を使って、以下のアプローチのいずれかで prefetch, preload を実現できる
    • アプリケーションサーバで生成する HTML に <link rel="prefetch" href="/assets/events.56d94462197c3b18193b.bundle.js" as="script"> のような link タグを加える
    • タグではなく、レスポンスヘッダーでも指定できる。アプリケーションサーバ、リバースプロキシのどちらでヘッダーを追加するかは適切に判断する
    • サーバではなく、クライアントでも以下のようなスクリプトで prefetch できる。先の JSON をあらかじめ取得して、他のエントリのスクリプトをフェッチするイメージ。Safari は prefetch ( http://caniuse.com/#feat=link-rel-prefetch ) に対応していないので、Image や XHR を使った polyfill を書く
const link = document.createElement("link");
link.rel = "prefetch";
link.as = "script";
link.href = "/assets/events.56d94462197c3b18193b.bundle.js";
document.head.appendChild(link);

おわり

  • リソースのキャッシュ戦略はサービス・アプリケーションの性質に依って変わるものなので、常に正しいやり方はないことを頭に入れておく
  • 効率の良い配信・取得をおこなおうとすると、(自動化できる部分もあるが)その分、管理や運用のコストが発生する。トレードオフ
  • (題と話が反れるが) Webpack は、JavaScript だけでなく css や画像もモジュールとして扱うことができ、他のモジュールバンドラーよりも自由度が高い。便利なツールだが、以下の懸念から、プロジェクトに導入するときに「何をどこまで Webpack でおこなうのか」協業者と決め、都度それを見直したほうが良いと思う。
    • stylesheet の中で require を呼んだり、plugin や loader を多用して、ビルドパイプラインが複雑化して、内容の把握や修正が困難になる( plugin や transform を多用して複雑化するのは Browserify でも同じ)
    • Dynamic Loading( require.ensure や System.import ) や 上の「stylesheet 内での require」のようなことをおこなうと、ソースコードが Webpack というツールに強く依存する形になる。Webpack 自体、比較的息の長いツールではあるが、長く機能追加やメンテナンスを行うプロジェクトでは、いつでも今使っているツールを捨てて、別のツールに乗り換えるぐらいの心構えでビルド環境を維持したほうがよいと思う。コードの機械的な置換やちょっとした書き換えでツールを捨てることができるのであれば問題ないと判断する ( Browserify を単純に使う場合は、ソースコードは common.js のモジュールの仕様に依存していて、Browserify というツールには依存していない )