技術探し

JavaScriptを中心に記事を書いていきます :;(∩´﹏`∩);:

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

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

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

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

mode cache
development memory
production false

https://github.com/webpack/webpack/blob/60f7ce301df553d8ab0276ba2838ddb60ead1c94/lib/config/defaults.js#L158-L160

実際に使うときの設定

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

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

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

ドキュメント

webpack.js.org

仕組み

ファイルキャッシュでは以下のようにデフォルトでは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.jsとPackFileCacheStrategy.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

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

buildDependencies

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

defaultWebpack

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

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

config

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

Other Options | webpack

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への機能追加として、一番投票率が多かったのがこの永続的キャッシュという機能でした。

webpack.js.org

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