長期キャッシュ保存を利用する

Webpack によるアセットのキャッシュ保存の仕組み

アプリのサイズを最適化した後、アプリの読み込み時間を改善するために次に行うのはキャッシュです。これを使用すると、アプリの一部をクライアントに保持し、毎回再ダウンロードする必要がなくなります。

バンドル バージョニングとキャッシュ ヘッダーを使用する

キャッシュ保存の一般的なアプローチは次のとおりです。

  1. ファイルを長期間(たとえば 1 年間)キャッシュに保存するようブラウザに指示するには、次のようにします。

    # Server header
    Cache-Control: max-age=31536000
    

    Cache-Control の仕組みに詳しくない場合は、キャッシュのベスト プラクティスに関する Jake Archibald の投稿をご覧ください。

  2. 変更時にファイル名を変更し、強制的に再ダウンロードされるようにします。

    <!-- Before the change -->
    <script src="./index-v15.js"></script>
    
    <!-- After the change -->
    <script src="./index-v16.js"></script>
    

この方法では、JS ファイルをダウンロードし、キャッシュに保存してそのキャッシュ コピーを使用するようにブラウザに指示します。ブラウザがネットワークにアクセスするのは、ファイル名が変更された場合(または 1 年が経過した場合)のみです。

webpack でも同じ処理を行いますが、バージョン番号の代わりにファイル ハッシュを指定します。ハッシュをファイル名に含めるには、[chunkhash] を使用します。

// webpack.config.js
module.exports = {
  entry: './index.js',
  output: {
    filename: 'bundle.[chunkhash].js' // → bundle.8e0d62a03.js
  }
};

クライアントに送信するためにファイル名が必要な場合は、HtmlWebpackPlugin または WebpackManifestPlugin を使用します。

HtmlWebpackPlugin はシンプルですが、柔軟性は劣ります。コンパイル時に、このプラグインはコンパイル済みのすべてのリソースを含む HTML ファイルを生成します。サーバーのロジックが複雑でなければ、次の作業で十分です。

<!-- index.html -->
<!DOCTYPE html>
<!-- ... -->
<script src="bundle.8e0d62a03.js"></script>

WebpackManifestPlugin はより柔軟なアプローチであり、複雑なサーバー部分がある場合に役立ちます。ビルド中に、ハッシュなしのファイル名とハッシュありのファイル名のマッピングを含む JSON ファイルが生成されます。サーバーでこの JSON を使用して、作業対象のファイルを確認します。

// manifest.json
{
  "bundle.js": "bundle.8e0d62a03.js"
}

関連情報

依存関係とランタイムを別のファイルに抽出する

依存関係

アプリの依存関係は、実際のアプリコードよりも変更される頻度が低くなります。これらのファイルを別のファイルに移動すると、ブラウザはファイルを個別にキャッシュに保存できるようになり、アプリコードが変更されても再度ダウンロードされることはありません。

依存関係を別のチャンクに抽出するには、次の 3 つのステップを行います。

  1. 出力ファイル名を [name].[chunkname].js に置き換えます。

    // webpack.config.js
    module.exports = {
      output: {
        // Before
        filename: 'bundle.[chunkhash].js',
        // After
        filename: '[name].[chunkhash].js'
      }
    };
    

    webpack はアプリをビルドするときに、[name] をチャンク名に置き換えます。[name] の部分を追加しない場合は、ハッシュによってチャンクを区別する必要がありますが、これはかなり困難です。

  2. entry フィールドをオブジェクトに変換します。

    // webpack.config.js
    module.exports = {
      // Before
      entry: './index.js',
      // After
      entry: {
        main: './index.js'
      }
    };
    

    このスニペットで、「main」はチャンクの名前です。この名前は、手順 1 の [name] の代わりに置き換えられます。

    この時点では、アプリをビルドした場合と同様に、このチャンクにはアプリコード全体が含まれます。これはすぐに変わります。

  3. webpack 4 では、webpack の構成に optimization.splitChunks.chunks: 'all' オプションを追加します。

    // webpack.config.js (for webpack 4)
    module.exports = {
      optimization: {
        splitChunks: {
          chunks: 'all'
        }
      }
    };
    

    このオプションにより、スマートなコード分割が可能になります。この場合、webpack はベンダーコードが 30 KB を超えた場合(圧縮前と gzip の前)にベンダーコードを抽出します。また、共通コードを抽出します。これは、ビルドで複数のバンドルが生成される場合(アプリをルートに分割する場合など)に役立ちます。

    webpack 3CommonsChunkPlugin を追加します。

    // webpack.config.js (for webpack 3)
    module.exports = {
      plugins: [
        new webpack.optimize.CommonsChunkPlugin({
        // A name of the chunk that will include the dependencies.
        // This name is substituted in place of [name] from step 1
        name: 'vendor',
    
        // A function that determines which modules to include into this chunk
        minChunks: module => module.context && module.context.includes('node_modules'),
        })
      ]
    };
    

    このプラグインは、パスに node_modules が含まれているすべてのモジュールを取得して、vendor.[chunkhash].js という別のファイルに移動します。

この変更を行うと、各ビルドで main.[chunkhash].jsvendor.[chunkhash].js(Webpack 4 の場合は vendors~main.[chunkhash].js)の 1 つではなく 2 つのファイルが生成されます。Webpack 4 の場合、依存関係が小さいとベンダー バンドルが生成されないことがありますが、それで問題ありません。

$ webpack
Hash: ac01483e8fec1fa70676
Version: webpack 3.8.1
Time: 3816ms
                        Asset      Size  Chunks             Chunk Names
 ./main.00bab6fd3100008a42b0.js   82 kB       0  [emitted]  main
./vendor.d9e134771799ecdf9483.js  47 kB       1  [emitted]  vendor

ブラウザはこれらのファイルを個別にキャッシュに保存し、変更されたコードのみを再ダウンロードしていました。

Webpack ランタイム コード

残念ながら、ベンダーコードを抽出するだけでは不十分です。アプリコードの一部を変更しようとすると、次のようになります。

// index.js
…
…

// E.g. add this:
console.log('Wat');

vendor ハッシュも以下のように変更されています。

                           Asset   Size  Chunks             Chunk Names
./vendor.d9e134771799ecdf9483.js  47 kB       1  [emitted]  vendor

                            Asset   Size  Chunks             Chunk Names
./vendor.e6ea4504d61a1cc1c60b.js  47 kB       1  [emitted]  vendor

これは、モジュールのコードとは別に、Webpack バンドルにランタイム(モジュールの実行を管理する小さなコード)があるためです。コードを複数のファイルに分割すると、まずこのコード部分にチャンク ID と対応するファイルとのマッピングが含まれます。

// vendor.e6ea4504d61a1cc1c60b.js
script.src = __webpack_require__.p + chunkId + "." + {
    "0": "2f2269c7f0a55a5c1871"
}[chunkId] + ".js";

Webpack は、このランタイムを最後に生成されたチャンク(この場合は vendor)にインクルードします。また、チャンクが変更されるたびに、このコード部分も変更され、vendor チャンク全体が変更されます。

この問題を解決するには、ランタイムを別のファイルに移動します。webpack 4 ではoptimization.runtimeChunk オプションを有効にすることでこれを実現できます。

// webpack.config.js (for webpack 4)
module.exports = {
  optimization: {
    runtimeChunk: true
  }
};

webpack 3 ではCommonsChunkPlugin を使用して追加の空のチャンクを作成することで、これを行います。

// webpack.config.js (for webpack 3)
module.exports = {
  plugins: [
    new webpack.optimize.CommonsChunkPlugin({
      name: 'vendor',
      minChunks: module => module.context && module.context.includes('node_modules')
    }),
    // This plugin must come after the vendor one (because webpack
    // includes runtime into the last chunk)
    new webpack.optimize.CommonsChunkPlugin({
      name: 'runtime',
      // minChunks: Infinity means that no app modules
      // will be included into this chunk
      minChunks: Infinity
    })
  ]
};

この変更後、各ビルドで次の 3 つのファイルが生成されます。

$ webpack
Hash: ac01483e8fec1fa70676
Version: webpack 3.8.1
Time: 3816ms
                            Asset     Size  Chunks             Chunk Names
   ./main.00bab6fd3100008a42b0.js    82 kB       0  [emitted]  main
 ./vendor.26886caf15818fa82dfa.js    46 kB       1  [emitted]  vendor
./runtime.79f17c27b335abc7aaf4.js  1.45 kB       3  [emitted]  runtime

これらを逆の順序で index.html に追加します。これで完了です。

<!-- index.html -->
<script src="./runtime.79f17c27b335abc7aaf4.js"></script>
<script src="./vendor.26886caf15818fa82dfa.js"></script>
<script src="./main.00bab6fd3100008a42b0.js"></script>

関連情報

追加の HTTP リクエストを保存するインライン Webpack ランタイム

さらに改善するには、webpack ランタイムを HTML レスポンスにインライン化してみてください。これを次のように変更します。

<!-- index.html -->
<script src="./runtime.79f17c27b335abc7aaf4.js"></script>

手順:

<!-- index.html -->
<script>
!function(e){function n(r){if(t[r])return t[r].exports;…}} ([]);
</script>

ランタイムは小さく、それをインライン化すると HTTP リクエストを保存しやすくなります(HTTP/1 では重要ですが、HTTP/2 ではそれほど重要ではありませんが、影響を及ぼす可能性があります)。

次にその方法をご紹介します。

HTMLWebpackPlugin を使用して HTML を生成する場合、

HtmlWebpackPlugin を使用して HTML ファイルを生成する場合は、InlineSourcePlugin があれば十分です。

const HtmlWebpackPlugin = require('html-webpack-plugin');
const InlineSourcePlugin = require('html-webpack-inline-source-plugin');

module.exports = {
  plugins: [
    new HtmlWebpackPlugin({
      inlineSource: 'runtime~.+\\.js',
    }),
    new InlineSourcePlugin()
  ]
};

カスタム サーバー ロジックを使用して HTML を生成する場合

Webpack 4 を使用する場合:

  1. WebpackManifestPlugin を追加して、生成されたランタイム チャンクの名前を確認します。

    // webpack.config.js (for webpack 4)
    const ManifestPlugin = require('webpack-manifest-plugin');
    
    module.exports = {
      plugins: [
        new ManifestPlugin()
      ]
    };
    

    このプラグインでビルドすると、次のようなファイルが作成されます。

    // manifest.json
    {
      "runtime~main.js": "runtime~main.8e0d62a03.js"
    }
    
  2. ランタイム チャンクのコンテンツを便利な方法でインライン化する。たとえば、Node.js と Express の場合は次のようになります。

    // server.js
    const fs = require('fs');
    const manifest = require('./manifest.json');
    const runtimeContent = fs.readFileSync(manifest['runtime~main.js'], 'utf-8');
    
    app.get('/', (req, res) => {
      res.send(`
        …
        <script>${runtimeContent}</script>
        …
      `);
    });
    

webpack 3:

  1. filename を指定して、ランタイム名を静的にします。

    module.exports = {
      plugins: [
        new webpack.optimize.CommonsChunkPlugin({
          name: 'runtime',
          minChunks: Infinity,
          filename: 'runtime.js'
        })
      ]
    };
    
  2. わかりやすい方法で runtime.js コンテンツをインライン化する。たとえば、Node.js と Express の場合は次のようになります。

    // server.js
    const fs = require('fs');
    const runtimeContent = fs.readFileSync('./runtime.js', 'utf-8');
    
    app.get('/', (req, res) => {
      res.send(`
        …
        <script>${runtimeContent}</script>
        …
      `);
    });
    

今は不要なコードの遅延読み込み

ページは、重要性の低い部分から構成されることがあります。

  • YouTube で動画ページを読み込む場合、ユーザーはコメントよりも動画を重視しています。ここでは、動画はコメントよりも重要です。
  • ニュースサイトで記事を開く場合、広告よりも記事のテキストを重視します。ここでは、広告よりもテキストの方が重要です。

このような場合は、最も重要な部分のみを最初にダウンロードし、残りの部分は後で遅延読み込みすることで、初期読み込みのパフォーマンスを向上させます。そのためには、import() 関数コード分割を使用します。

// videoPlayer.js
export function renderVideoPlayer() { … }

// comments.js
export function renderComments() { … }

// index.js
import {renderVideoPlayer} from './videoPlayer';
renderVideoPlayer();

// …Custom event listener
onShowCommentsClick(() => {
  import('./comments').then((comments) => {
    comments.renderComments();
  });
});

import() は、特定のモジュールを動的に読み込むことを指定します。webpack は、import('./module.js') を認識すると、このモジュールを別のチャンクに移動します。

$ webpack
Hash: 39b2a53cb4e73f0dc5b2
Version: webpack 3.8.1
Time: 4273ms
                            Asset     Size  Chunks             Chunk Names
      ./0.8ecaf182f5c85b7a8199.js  22.5 kB       0  [emitted]
   ./main.f7e53d8e13e9a2745d6d.js    60 kB       1  [emitted]  main
 ./vendor.4f14b6326a80f4752a98.js    46 kB       2  [emitted]  vendor
./runtime.79f17c27b335abc7aaf4.js  1.45 kB       3  [emitted]  runtime

実行が import() 関数に達した場合にのみダウンロードします。

これにより、main バンドルのサイズが小さくなり、初期読み込み時間が短縮されます。さらに、キャッシュ保存が改善されます。main チャンクのコードを変更しても、コメント チャンクは影響を受けません。

関連情報

コードをルートとページに分割する

アプリに複数のルートまたはページがあるものの、そのコードを含む JS ファイルが 1 つ(main チャンクも 1 つ)しかない場合は、各リクエストで余分なバイトが提供されている可能性があります。たとえば、ユーザーがサイトのホームページにアクセスすると、次のようになります。

Web Fundamentals ホームページ

別のページの記事をレンダリングするための コードを読み込む必要はありませんがさらに、ユーザーが常にホームページのみにアクセスしていて、記事コードを変更すると、webpack はバンドル全体を無効にし、ユーザーはアプリ全体を再ダウンロードする必要があります。

アプリをページ(シングルページ アプリの場合はルート)に分割した場合、ユーザーは関連するコードのみをダウンロードします。また、ブラウザによるアプリコードのキャッシュが改善されます。ホームページのコードを変更した場合、Webpack は対応するチャンクのみを無効にします。

シングルページ アプリの場合

ルートごとにシングルページ アプリを分割するには、import() を使用します(「今は不要なコードの遅延読み込み」セクションをご覧ください)。フレームワークを使用している場合は、これに対する既存のソリューションがある場合があります。

従来のマルチページ アプリの場合

従来のアプリをページに分割するには、webpack のエントリ ポイントを使用します。アプリにホームページ、記事ページ、ユーザー アカウント ページの 3 種類のページがある場合は、次の 3 つのエントリが必要です。

// webpack.config.js
module.exports = {
  entry: {
    home: './src/Home/index.js',
    article: './src/Article/index.js',
    profile: './src/Profile/index.js'
  }
};

エントリ ファイルごとに、webpack は個別の依存関係ツリーをビルドし、そのエントリで使用されるモジュールのみを含むバンドルを生成します。

$ webpack
Hash: 318d7b8490a7382bf23b
Version: webpack 3.8.1
Time: 4273ms
                            Asset     Size  Chunks             Chunk Names
      ./0.8ecaf182f5c85b7a8199.js  22.5 kB       0  [emitted]
   ./home.91b9ed27366fe7e33d6a.js    18 kB       1  [emitted]  home
./article.87a128755b16ac3294fd.js    32 kB       2  [emitted]  article
./profile.de945dc02685f6166781.js    24 kB       3  [emitted]  profile
 ./vendor.4f14b6326a80f4752a98.js    46 kB       4  [emitted]  vendor
./runtime.318d7b8490a7382bf23b.js  1.45 kB       5  [emitted]  runtime

そのため、記事ページのみが Lodash を使用している場合、home バンドルと profile バンドルに Lodash は含まれません。したがって、ユーザーがホームページにアクセスするときに、このライブラリをダウンロードする必要はありません。

ただし、個別の依存関係ツリーには欠点があります。2 つのエントリ ポイントが Lodash を使用し、依存関係をベンダー バンドルに移動していない場合、両方のエントリ ポイントに Lodash のコピーが含まれます。この問題を解決するには、webpack 4 で webpack の構成に optimization.splitChunks.chunks: 'all' オプションを追加します。

// webpack.config.js (for webpack 4)
module.exports = {
  optimization: {
    splitChunks: {
      chunks: 'all'
    }
  }
};

このオプションにより、スマートなコード分割が可能になります。このオプションを使用すると、webpack は共通のコードを自動的に検索して個別のファイルに抽出します。

または、Webpack 3 では CommonsChunkPlugin を使用します。これは、共通の依存関係を、指定された新しいファイルに移動します。

module.exports = {
  plugins: [
    new webpack.optimize.CommonsChunkPlugin({
      name: 'common',
      minChunks: 2    // 2 is the default value
    })
  ]
};

minChunks の値を試して最適な値を見つけてください。一般的にはサイズを小さく保ちますが、チャンク数が増えた場合は増やす必要があります。たとえば、チャンクが 3 つの場合、minChunks は 2 ですが、30 のチャンクでは 8 になることがあります。2 のままにすると、多くのモジュールが共通ファイルを取り込んで、インフレートしすぎるためです。

関連情報

モジュール ID の安定性を高める

コードをビルドする際、webpack は各モジュールに ID を割り当てます。その後、これらの ID はバンドル内の require() で使用されます。通常、ビルド出力でモジュール パスの直前の ID が表示されます。

$ webpack
Hash: df3474e4f76528e3bbc9
Version: webpack 3.8.1
Time: 2150ms
                           Asset      Size  Chunks             Chunk Names
      ./0.8ecaf182f5c85b7a8199.js  22.5 kB       0  [emitted]
   ./main.4e50a16675574df6a9e9.js    60 kB       1  [emitted]  main
 ./vendor.26886caf15818fa82dfa.js    46 kB       2  [emitted]  vendor
./runtime.79f17c27b335abc7aaf4.js  1.45 kB       3  [emitted]  runtime

↓ こちら

[0] ./index.js 29 kB {1} [built]
[2] (webpack)/buildin/global.js 488 bytes {2} [built]
[3] (webpack)/buildin/module.js 495 bytes {2} [built]
[4] ./comments.js 58 kB {0} [built]
[5] ./ads.js 74 kB {1} [built]
+ 1 hidden module

デフォルトでは、ID はカウンタを使用して計算されます(つまり、最初のモジュールは ID 0、2 番目のモジュールは ID 1、というように続きます)。問題は、新しいモジュールを追加すると、そのモジュールがモジュール リストの中央に表示され、次のモジュールの ID がすべて変更されることです。

$ webpack
Hash: df3474e4f76528e3bbc9
Version: webpack 3.8.1
Time: 2150ms
                           Asset      Size  Chunks             Chunk Names
      ./0.5c82c0f337fcb22672b5.js    22 kB       0  [emitted]
   ./main.0c8b617dfc40c2827ae3.js    82 kB       1  [emitted]  main
 ./vendor.26886caf15818fa82dfa.js    46 kB       2  [emitted]  vendor
./runtime.79f17c27b335abc7aaf4.js  1.45 kB       3  [emitted]  runtime
   [0] ./index.js 29 kB {1} [built]
   [2] (webpack)/buildin/global.js 488 bytes {2} [built]
   [3] (webpack)/buildin/module.js 495 bytes {2} [built]

↓ 新しいモジュールを追加しました。

[4] ./webPlayer.js 24 kB {1} [built]

↓ 詳細をご覧ください。comments.js の ID が 4 から 5 に変わりました

[5] ./comments.js 58 kB {0} [built]

ads.js の ID が 5 から 6 になりました

[6] ./ads.js 74 kB {1} [built]
       + 1 hidden module

これにより、ID が変更されたモジュールを含む、またはそれに依存するすべてのチャンクが無効になります。実際のコードが変更されていない場合も同様です。この例では、0 チャンク(comments.js を含むチャンク)と main チャンク(他のアプリコードを含むチャンク)が無効になりますが、本来は main チャンクのみが無効になりました。

この問題を解決するには、HashedModuleIdsPlugin を使用してモジュール ID の計算方法を変更します。これは、カウンタベースの ID をモジュール パスのハッシュに置き換えます。

$ webpack
Hash: df3474e4f76528e3bbc9
Version: webpack 3.8.1
Time: 2150ms
                           Asset      Size  Chunks             Chunk Names
      ./0.6168aaac8461862eab7a.js  22.5 kB       0  [emitted]
   ./main.a2e49a279552980e3b91.js    60 kB       1  [emitted]  main
 ./vendor.ff9f7ea865884e6a84c8.js    46 kB       2  [emitted]  vendor
./runtime.25f5d0204e4f77fa57a1.js  1.45 kB       3  [emitted]  runtime

↓ こちら

[3IRH] ./index.js 29 kB {1} [built]
[DuR2] (webpack)/buildin/global.js 488 bytes {2} [built]
[JkW7] (webpack)/buildin/module.js 495 bytes {2} [built]
[LbCc] ./webPlayer.js 24 kB {1} [built]
[lebJ] ./comments.js 58 kB {0} [built]
[02Tr] ./ads.js 74 kB {1} [built]
    + 1 hidden module

この方法では、モジュールの ID は、そのモジュールの名前変更または移動を行った場合にのみ変更されます。新しいモジュールが他のモジュールの ID に影響することはありません。

プラグインを有効にするには、config の plugins セクションに追加します。

// webpack.config.js
module.exports = {
  plugins: [
    new webpack.HashedModuleIdsPlugin()
  ]
};

関連情報

まとめ

  • バンドルをキャッシュに保存し、バンドル名を変更してバージョンを区別する
  • バンドルをアプリコード、ベンダーコード、ランタイムに分割する
  • ランタイムをインライン化して HTTP リクエストを保存する
  • import で重要性の低いコードの遅延読み込みを行う
  • ルート/ページごとにコードを分割して不要な内容を読み込まないようにする