webpack@5で入るPersistent Cachingについて

2020 / 10 / 05

Edit
🚨 This article hasn't been updated in over a year
💁‍♀️ This post was copied from Hatena Blog

webpack は in-memory のみで今まで永続的なキャッシュを実装していませんでした。理由としては、パフォーマンスよりも安全性を優先していたためです。 cache-loader を使ったことがある人はわかるかもしれませんが、確かに速くなる一方、安全性が損なわれているのは事実です。

この機能は、webpack はデフォルトでファイルキャッシュをオンにはしませんがそれでもビルドの速度を上げたい場合に使う機能です。

以下がデフォルトの挙動となります。

modecache
developmentmemory
productionfalse

webpack/lib/config/defaults.js

実際に使うときの設定

結論ですが、webpack.config.js へ以下のように書くことが推奨されます。

module.exports = {
  cache: {
    type: "filesystem",
    buildDependencies: {
      config: [__filename],
    },
  },
};

あとは、各コードの設定に依存するためversion等の追加が必要になる可能性があります。

ドキュメント

Other Options | webpack webpack is a module bundler. Its main purpose is to bundle JavaScript files for usage in a browser, ...

仕組み

ファイルキャッシュでは以下のようにデフォルトではnode_modules/.cache/webpackというディレクトリにスナップショットを生成します。

(๑˃̵ᴗ˂̵)و ~/D/w/node_modules tree .cache
.cache
└── webpack
    └── default-production
        ├── 0.pack <-- 生成済みコードの記録
        └── index.pack <-- 依存ファイル等の記録

2 directories, 2 files

このようにシリアル化されたデータを保存します。 MD4 ハッシュアルゴリズムを用いた etag を識別子とし、それと一致したものを webpack は使用します。(実装が知りたい人はcreateHash.jsPackFileCacheStrategy.jsを読んでください) 本番環境と buildDependencies には、 timestamp + hash モードがデフォルトで適応されます。 使うユーザーは snapshot のオプションを設定することはないと思うので、この記事では割愛します。

https://webpack.js.org/configuration/other-options/#snapshot

webpack は、すべてのモジュールそれぞれに対し、compilation.fileDependencies, compilation.contextDependencies, compilation.missingDependenciesをトラッキングし、スナップショットを生成していきます。 余談ですが、この三点は、webpack@5 からSortable SetからSetとなり、並び替えが不可能となりましたのでプラグイン作者は気をつけてください。

つまり、特定ファイルを変更すると webpack はそのファイルのキャッシュ(webpack 内ではキャッシュエントリと呼ばれる)を無効化し、各種 loader を実行後にファイル解析を行い再生成を行います。また、bundle.js のキャッシュエントリを無効化し、このファイルを再生成する可能性があります。

例えば、依存ファイルを変更すると以下のように変更されます。

(๑˃̵ᴗ˂̵)و ~/D/w/node_modules tree .cache
.cache
└── webpack
    └── default-production
        ├── 0.pack
        ├── 1.pack
        ├── index.pack
        └── index.pack.old

2 directories, 4 files

1 の方が新しくなり、スナップショットが追加されました。

キャッシュエントリが無効化されるケース

以下の場合にキャッシュエントリが無効化されます。

  • 監視下のファイルが変更されたとき
  • 設定を変更したとき
    • webpack.config.js のcache等の設定変更
  • loader か plugin がパッケージアップデートされたとき
  • 依存関係(node_modules)がパッケージアップデートされたとき
  • cli からビルドに影響のある値を送ったとき
    • --optimization-minimize, コードで判断できないもの
  • カスタムなビルドスクリプトが変更されたとき
    • cache.version, cache.name, cache.buildDependencies

ビルド結果を変更する可能性のあるものはキャッシュエントリが無効化されます。 例えば、--optimization-minimizeを渡せばビルド結果には影響されます。しかし入力されたソースコードの変更だけではこれを検知できませんが、キャッシュはこれを考慮する必要があります。 webpack ではそれに対して、cache.version, cache.name, cache.buildDependenciesを使い処理をしますが、これを自動的に認識するのは難しいため影響が生じたときに再構築する必要が出てきます。(かなり安全性を重視しています)

オプション

最低限のものだけ説明します。

type

memoryfilesystemが存在し、どちらを選択することができます。

buildDependencies

cache.buildDependenciesには、ビルドにおけるコード依存関係を追加します。

defaultWebpack

webpack のすべての依存関係を取得するために、デフォルトでwebpack/libとなります。 この設定は基本的に設定する必要はないです。

module.exports = {
  cache: {
    type: "filesystem",
    buildDependencies: {
      defaultWebpack: ["webpack/lib"],
    },
  },
};

config

公式では、最新の設定(webpack.config.js, etc)とすべての依存を取得するために__ filenameを設定することが推奨されます。 このように書くことにより、設定とすべての依存関係を取得するようになります。

https://webpack.js.org/configuration/other-options/#cachebuilddependencies

module.exports = {
  cache: {
    type: "filesystem",
    buildDependencies: {
      config: [__filename],
    },
  },
};

ディレクトリの場合、最も近い package.json の依存関係が分析され、ファイルの場合は、Node.js のモジュールのキャッシュを見て、依存関係を webpack は把握します。

注意点として、ディレクトリの場合は、必ずスラッシュで終わる必要があります(そうしないとファイルと識別されてしまう)

version

たとえ同じ内容でも、この値を変更することにより永続的キャッシュを無効化することができます。 ビルドの一部の依存において、表現できない場合が存在します。(e.g. DB から読み込まれた値、環境変数、コマンドラインで渡される値) もしキャッシュがおかしい場合このオプションを確認・検討してください。

もしコードが definePlugin 経由で環境変数を入れていてそれをバンドルに埋め込む場合、これはこの環境変数(e.g. git の revision)への依存があるので、バージョン名をこの値にし、キャッシュを無効化する必要があります。

module.exports = {
  cache: {
    type: "filesystem",
    buildDependencies: {
      config: [__filename],
    },
  },
  plugins: [
    new webpack.DefinePlugin({
      "process.env.foo": JSON.stringify("foo"),
    }),
  ],
};

上記の場合はconfigでこの webpack.config.js を監視下に置いており、そこでfooを定義しているため、この文字列を変更したら webpack は検知できるため問題ないです。問題は、監視下で webpack が変更されたか認知できない(ずっと末端まで変数等)です。

しかし、.envを利用する場合は以下のようにversionを指定しないとキャッシュは更新されません。

# .env
VERSION=1.0
const webpack = require("webpack");
const { config } = require("dotenv");

config();

module.exports = {
  cache: {
    type: "filesystem",
    buildDependencies: {
      config: [__filename],
    },
    version: process.env.VERSION,
  },
  plugins: [
    new webpack.DefinePlugin({
      "process.env.version": process.env.VERSION,
    }),
  ],
};

さてこれで問題になるのが、もしこれをしっかり設定しないと**過去のスナップショットを参照するため生成結果も過去の状態になることです。**これが webpack が恐れていた問題です。この場合だと、versionを指定しない場合、出力は常に最初のビルド時のものとなり.env で書き換えしても反映されません。

つまり、cache.nameでも、キャッシュを無効化できるためコードに依存しますが導入の検討する可能性があります。

パフォーマンスの最適化

node_modules のコードに対して、timestamp + hash で管理するとコストがかかりビルド速度が低下するため webpack では、package.json 内のバージョンと名前を利用し評価しています。 なので、絶対に node_modules 内のコードを編集することは避けてください。

この最適化は、snapshot.managedPathsのパスに適応され、デフォルトでは webpack がインストールされている node_modules となります。yarn.pnp の場合、ファイルパスでハッシュを利用するため上記の最適化は yarn がカバーするため行われません。

d3 を使った場合のパフォーマンス測定

// index.js
import * as d3 from "d3";
import "foo.js";
// foo.js
console.log("foo");
// webpack.config.js
module.exports = {
  cache: {
    type: "filesystem",
    buildDependencies: {
      config: [__filename],
    },
  },
};

最初はキャッシュがないため、webpack@4 同様に以下の速度ぐらいとなります。

asset main.js 36.3 KiB [emitted] [minimized] (name: main)
orphan modules 584 KiB [orphan] 554 modules
cacheable modules 117 KiB
  ./src/index.js + 103 modules 117 KiB [built] [code generated]
  ./src/foo.js 21 bytes [built] [code generated]
webpack 5.0.0-rc.2 compiled successfully in 1836 ms

もう一度、何も変更せずにビルドを行ってみます。

asset main.js 36.3 KiB [compared for emit] [minimized] (name: main)
cached modules 700 KiB [cached] 556 modules
webpack 5.0.0-rc.2 compiled successfully in 429 ms

前回と同様なのですべてキャッシュが使われていることがわかり、約 4.5 倍程度早くなったことが確認できます。

それでは、foo.js の中身を変更します。

asset main.js 36.3 KiB [emitted] [minimized] (name: main)
cached modules 700 KiB [cached] 555 modules
./src/foo.js 18 bytes [built] [code generated]
webpack 5.0.0-rc.2 compiled successfully in 1228 ms

foo.js のコードのみが再度生成され、foo.js のスナップショットが更新されました。index.js を含め更新されたわけではないため速度はフルビルドの時よりも速くなります。

再度、何も変更せずにビルドを行います。

asset main.js 36.3 KiB [compared for emit] [minimized] (name: main)
cached modules 700 KiB [cached] 556 modules
webpack 5.0.0-rc.2 compiled successfully in 416 ms

すべてのキャッシュが利用され変更がないため、400ms 台で落ち着きました。

さいごに

webpack5 への機能追加として、一番投票率が多かったのがこの永続的キャッシュという機能でした。

https://webpack.js.org/vote

この機能は、開発時に大いに役に立つと思います。もし、webpack のビルド時間に不満がある人はこの機能を試してみると良いかなと思います。