30歳からのプログラミング

30歳無職から独学でプログラミングを開始した人間の記録。

webpack のコード分割の初歩

JavaScript や TypeScript を使ってウェブアプリを提供する場合、開発時はimportexportなどの ES Modules を使い、公開時はファイルをバンドルして公開することが多い。
以下の記事に書いたように、現在の主要なブラウザは ES Modules に対応してものの、バンドルせずに公開してしまうとパフォーマンスに悪影響を与える可能性がある。

numb86-tech.hatenablog.com

ファイル数が増えれば増えるほど影響は深刻になるため、依存関係が深いライブラリを使っている場合などは、レイテンシが飛躍的に増加してしまう。
そのため、バンドルせずに公開するのは現実的ではない。

バンドルしてひとつのファイルにまとめてしまえば、JavaScript のダウンロードは一度で済む。
しかしそうすると今度は、バンドルファイルの肥大化という問題が発生する。
巨大なファイルはダウンロードやパースに時間がかかるため、ユーザ体験を悪化させてしまう。

この問題を解決するために、主要なモジュールバンドラにはコードを分割するための機能が備わっている。
この機能を適切に使用することで、バンドルファイルのサイズを削減し、パフォーマンスを改善することができる。

この記事では、webpack でコード分割を行うにはどうすればよいのか、具体的にどのような形に分割されるのか、などを見ていく。

使用したライブラリのバージョンは以下の通り。

  • webpack@5.6.0
  • webpack-cli@4.2.0
  • clean-webpack-plugin@3.0.0
  • html-webpack-plugin@5.0.0-alpha.14
  • nodemon@2.0.6
  • react@17.0.1
  • react-dom@17.0.1
  • @babel/core@7.12.7
  • @babel/preset-react@7.12.7
  • babel-loader@8.2.1
  • webpack-bundle-analyzer@4.1.0
  • lodash@4.17.20

環境構築

まず、検証用の環境を準備する。

以下のpackage.jsonを用意してから$ yarnを実行して、必要なライブラリをインストールする。

{
  "name": "chunk",
  "license": "MIT",
  "scripts": {
    "start": "nodemon server.js",
    "build": "NODE_ENV=production webpack"
  },
  "devDependencies": {
    "clean-webpack-plugin": "^3.0.0",
    "html-webpack-plugin": "^5.0.0-alpha.14",
    "nodemon": "^2.0.6",
    "webpack": "^5.6.0",
    "webpack-cli": "^4.2.0"
  }
}

webpack.config.jsは以下のようにする。
この時点では、コード分割に関する設定は入れていない。

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const {CleanWebpackPlugin} = require('clean-webpack-plugin');

const config = {
  entry: {
    index: './src/index.js',
  },
  output: {
    filename: '[name].bundle.js',
    path: path.resolve(__dirname, 'dist'),
    publicPath: '/',
  },
  plugins: [
    new HtmlWebpackPlugin({
      scriptLoading: 'defer',
    }),
    new CleanWebpackPlugin(),
  ],
}

module.exports = config;

最後に、server.js
HTML ファイルは即時レスポンスを返すが、JavaScript ファイルについては3秒後にレスポンスを返す。
こうすることで、挙動を確認しやすくしている。

const http = require('http');
const fs = require('fs');

http.createServer((req, res) => {
  switch(true) {
    case /^\/$/.test(req.url):
      fs.readFile('./dist/index.html', 'utf-8', (err, data) => {
        res.writeHead(200, {'Content-Type': 'text/html; charset=utf-8'});
        res.write(data);
        res.end();
      })
      break;
    case /\.js$/.test(req.url):
      fs.readFile(`./dist${req.url}`, 'utf-8', (err, data) => {
        setTimeout(() => {
          res.writeHead(200, {'Content-Type': 'text/javascript'});
          res.write(data);
          res.end();
        }, 3000)
      })
      break;
    default:
      res.writeHead(404);
      res.end();
  };
}).listen(8080);

まずはこの状態で検証を行う。必要に応じて設定を追加、変更していく。

Dynamic Import によるコード分割

Dynamic Import を使うと、webpack が自動的にコードを分割してくれる。

まずは Dynamic Import を使わずにコードを書いてみる。

// src/a.js
const elem = document.createElement('div');
elem.textContent = 'execute a';
document.body.appendChild(elem);

export const a = 1;
// src/index.js
import {a} from './a.js';

const elem = document.createElement('button');
elem.textContent = 'click';
elem.addEventListener('click', () => {
  alert(a);
})

document.body.appendChild(elem);

この状態でビルド($ yarn build)すると、dist/index.bundle.jsが出力される。
このファイルは、src/index.jssrc/a.jsをバンドルしたものなので、読み込まれると両方のファイルの内容が実行される。

$ yarn startでサーバを起動して、確認してみる。
http://localhost:8080/にアクセスすると、以下のようになる。

f:id:numb_86:20201201050601g:plain

index.bundle.jsが実行されるとsrc/a.jsの内容も実行されるため、execute aが挿入される。

次に、Dynamic Import を使った形に書き換えてみる。
Dynamic Import の基本的な使い方については、以下に書いた。

numb86-tech.hatenablog.com

src/index.jssrc/a.jsimportしているが、そこで使われているaという値は、ボタンが押下されたときに必要になる。
そのため、初期読み込み時にはimportが不要であり、Dynamic Import を使うことができる。
以下のコードでは、ボタンが押下された時に動的にsrc/a.jsを読み込むようにしている。

// src/index.js
const elem = document.createElement('button');
elem.textContent = 'click';
elem.addEventListener('click', () => {
  import('./a.js').then(res => {
    alert(res.a);
  })
})

document.body.appendChild(elem);

この状態でビルドすると、index.bundle.jsの他に、85.bundle.jsが生成されている。
これが、コード分割である。ふたつのバンドルファイルに分割されている。

挙動は以下のようになる。

f:id:numb_86:20201201050650g:plain

HTML ファイルのhead要素に<script defer="defer" src="/index.bundle.js"></script>があるのでindex.bundle.jsが読み込まれるが、このファイルにはsrc/a.jsの内容は含まれていないため、execute aは挿入されない。
ボタンを押下したタイミングで85.bundle.jsのダウンロードが始まり、その後ファイルが実行され、execute aが挿入される。
より具体的には、ボタンを押下するとhead要素にscript要素が追加される。そして読み込みが終わると、追加されたscript要素は削除される。

f:id:numb_86:20201201050742g:plain

ダウンロードは一度のみ行われるので、ボタンを複数回押下しても重複してダウンロードされることはない。

今回は JavaScript のダウンロードに3秒かかるようにしているので却って使い勝手が悪くなったが、コード分割を上手く使うことで、「ページロード時は必要最低限の JavaScript コードだけを読み込み、それ以外のコードは必要になったタイミングで読み込む」ということが可能になる。

prefetch

先程の例では、ボタンを押下したタイミングで85.bundle.jsのダウンロードが開始されるため、ボタンを押下してからアラートが表示されるまで、必ず3秒以上かかってしまう。
prefetchを使うことで、この問題を解決できる。

/* webpackPrefetch: true*/というコメントを追加することで、prefetchが有効になる。

 const elem = document.createElement('button');
 elem.textContent = 'click';
 elem.addEventListener('click', () => {
-  import('./a.js').then(res => {
+  import(/* webpackPrefetch: true*/ './a.js').then(res => {
     alert(res.a);
   })
 })

index.bundle.jsのダウンロードと実行が終わったタイミングで<link rel="prefetch" as="script" href="/85.bundle.js">が追加され、85.bundle.jsのダウンロードが開始される。
ページの初期化に必要なindex.bundle.jsの実行をまず行い、それが終わったら85.bundle.jsをダウンロードしておく。
こうすることで、ページの読み込みを最適化しつつ、ボタンを押下した時の待ち時間を短縮できる。

事前に行われるのはダウンロードのみで実行はされない。ボタンを押下したタイミングで、ファイルが実行されてexecute aが挿入される。

f:id:numb_86:20201201050840g:plain

分割されたファイルに名前をつける

デフォルトだと、先程の85.bundle.jsのように数字がファイル名になる。
このままだと分かりづらいので、設定を変えていく。

まず、webpack.config.jsoptimization.chunkIds'named'にすると、パスに基づいて自動的に名前がつけられる。

     }),
     new CleanWebpackPlugin(),
   ],
+  optimization: {
+    chunkIds: 'named'
+  }
 }

今回の例の場合、output.filenameの設定と合わさって、src_a_js.bundle.jsという名前になる。

通常のバンドルファイルとは別のルールを適用させたい場合は、output.chunkFilenameを使う。
以下のようにすると、src_a_js.foo.jsという名前になる。

   },
   output: {
     filename: '[name].bundle.js',
+    chunkFilename: '[name].foo.js',
     path: path.resolve(__dirname, 'dist'),
     publicPath: '/',
   },

chunkFilenameには関数を渡すことも可能。
以下の場合はsrc~a~js.async.jsという名前になる。

   },
   output: {
     filename: '[name].bundle.js',
-    chunkFilename: '[name].foo.js',
+    chunkFilename: (pathData) => {
+      return pathData.chunk.id.replace(/_/g, '~') + '.async.js';
+    },
     path: path.resolve(__dirname, 'dist'),
     publicPath: '/',
   },

Dynamic Import にwebpackChunkNameというコメントを書くことで、個別に名前をつけることもできる。
この場合、optimization.chunkIdsの設定は無視される。
以下のようにすると、先程のchunkFilenameの設定と合わさってx~y-z.async.jsという名前になる。

import(/* webpackChunkName: 'x_y-z', webpackPrefetch: true*/ './a.js')

ライブラリのコードを別のファイルに分割する

エントリポイントが複数ありそれぞれで同じライブラリを使っていた場合、重複してバンドルされ、バンドルファイルが肥大化してしまう。
コード分割は、これを回避するためにも使われる。

以降は、React アプリを作って動作確認していく。

まず、package.jsonを以下の内容にする。
必要なライブラリをインストールする他、buildコマンドに--analyzeオプションをつけてバンドル結果を見れるようにした。

{
  "name": "chunk",
  "license": "MIT",
  "scripts": {
    "start": "nodemon server.js",
    "build": "NODE_ENV=production webpack --analyze"
  },
  "devDependencies": {
    "@babel/core": "^7.12.7",
    "@babel/preset-react": "^7.12.7",
    "babel-loader": "^8.2.1",
    "clean-webpack-plugin": "^3.0.0",
    "html-webpack-plugin": "^5.0.0-alpha.14",
    "nodemon": "^2.0.6",
    "webpack": "^5.6.0",
    "webpack-bundle-analyzer": "^4.1.0",
    "webpack-cli": "^4.2.0"
  },
  "dependencies": {
    "react": "^17.0.1",
    "react-dom": "^17.0.1"
  }
}

次に、webpack.config.js
JavaScript ファイルを Babel で処理するようにした他、エントリポイントにanotherを追加した。

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const {CleanWebpackPlugin} = require('clean-webpack-plugin');

const config = {
  entry: {
    index: './src/index.js',
    another: './src/another.js',
  },
  output: {
    filename: '[name].bundle.js',
    path: path.resolve(__dirname, 'dist'),
    publicPath: '/',
  },
  module: {
    rules: [{test: /\.js$/, exclude: /node_modules/, loader: 'babel-loader'}],
  },
  plugins: [
    new HtmlWebpackPlugin({
      filename: 'index.html',
      chunks: ['index'],
      scriptLoading: 'defer',
    }),
    new HtmlWebpackPlugin({
      filename: 'another.html',
      chunks: ['another'],
      scriptLoading: 'defer',
    }),
    new CleanWebpackPlugin(),
  ],
  optimization: {
    chunkIds: 'named'
  }
}

module.exports = config;

babel.config.jsは以下のようにした。

module.exports = {
  presets: [
    [
      '@babel/preset-react',
      {
        development: process.env.NODE_ENV === 'development',
        'runtime': 'automatic'
      }
    ]
  ]
};

最後に、server.jsに以下の変更を加えて、/anotherのルーティングを追加する。

         res.end();
       })
       break;
+    case /^\/another$/.test(req.url):
+      fs.readFile('./dist/another.html', 'utf-8', (err, data) => {
+        res.writeHead(200, {'Content-Type': 'text/html; charset=utf-8'});
+        res.write(data);
+        res.end();
+      })
+      break;
     case /\.js$/.test(req.url):
       fs.readFile(`./dist${req.url}`, 'utf-8', (err, data) => {
         setTimeout(() => {

環境は整ったので、早速コードを書いていく。

// src/index.js
import {render} from 'react-dom';

const App = () =>
  <div>
    index<br />
    <a href="/another">to another</a>
  </div>;

render(<App />, document.body);
// src/another.js
import {render} from 'react-dom';

const App = () =>
  <div>
    another<br />
    <a href="/">to index</a>
  </div>;

render(<App />, document.body);

この状態でビルドするとindex.bundle.jsanother.bundle.jsが出力されるが、両方にreactreact-domが含まれてしまい、無駄が生まれている。

f:id:numb_86:20201201050056p:plain

http://localhost:8080/からhttp://localhost:8080/anotherに遷移した場合、ライブラリのコードが二重に読み込まれることになる。

webpack.config.jsoptimization.splitChunks.chunk'all'に設定すると、ライブラリのコードを自動的に分割してくれるようになる。

     new CleanWebpackPlugin(),
   ],
   optimization: {
-    chunkIds: 'named'
+    chunkIds: 'named',
+    splitChunks: {
+      chunks: 'all',
+    },
   }
 }

これでビルドした結果が以下。

f:id:numb_86:20201201050048p:plain

vendors-node_modules_react-dom_index_js-node_modules_react_jsx-runtime_js.bundle.jsが生成され、ライブラリのコードはそこに含まれている。
index.bundle.jsanother.bundle.jsからはライブラリのコードが取り除かれているため、重複が解消されている。
ライブラリのバンドルコードをキャッシュさせるなどすれば、パフォーマンスの向上が見込める。

なお、この設定を行うと、エントリポイントがひとつしかない場合でも、ライブラリのコードを分割してくれる。

HTML Webpack Plugin はコード分割に対応しているため、自動的に以下のscript要素をindex.htmlに挿入してくれる。
分割したファイルを手動で読み込ませる必要はない。

<script defer="defer" src="/vendors-node_modules_react-dom_index_js-node_modules_react_jsx-runtime_js.bundle.js"></script>
<script defer="defer" src="/index.bundle.js"></script>

名前付け

optimization.splitChunks.nameで、ファイル名を指定できる。
以下ではfooと指定しているので、output.filenameの設定と組み合わさり、foo.bundle.jsという名前になる。

  optimization: {
    chunkIds: 'named',
    splitChunks: {
      chunks: 'all',
      name: 'foo',
    },
  }

以降の例では、optimization.splitChunks.nameは何も設定せずにビルドしている。

使用状況に応じたコードの分割

先程の例では、両方のエントリポイントが同じライブラリに依存していた。
では、片方のエントリポイントのみが依存しているライブラリがある場合は、どうなるのか。

lodashをインストールし、それをsrc/index.jsでのみ使うようにすることで、確認してみる。

import {render} from 'react-dom';
import _ from 'lodash';

const App = () =>
  <div>
    another<br />
    {`${_.join(['Hello', 'webpack'], '-')}`}<br />
    <a href="/">to index</a>
  </div>;

render(<App />, document.body);

この状態でビルドすると、vendors-node_modules_react-dom_index_js-node_modules_react_jsx-runtime_js.bundle.jsの他に、vendors-node_modules_lodash_lodash_js.bundle.jsが生成される。

f:id:numb_86:20201201050028p:plain

後者は、http://localhost:8080/にアクセスしたときにのみ、読み込まれる。

<!-- http://localhost:8080/ -->
<script defer="defer" src="/vendors-node_modules_react-dom_index_js-node_modules_react_jsx-runtime_js.bundle.js"></script>
<script defer="defer" src="/vendors-node_modules_lodash_lodash_js.bundle.js"></script>
<script defer="defer" src="/index.bundle.js"></script>
<!-- http://localhost:8080/another -->
<script defer="defer" src="/vendors-node_modules_react-dom_index_js-node_modules_react_jsx-runtime_js.bundle.js"></script>
<script defer="defer" src="/another.bundle.js"></script>

このように、webpack が最適な形で分割してくれる。

cacheGropes.***.test で、分割するライブラリを指定する

再びエントリポイントをsrc/index.jsのみにして、ビルドしてみる。
そうすると、index.bundle.jsと、全てのライブラリのコードを含むバンドルファイルが、生成される。
この挙動は既に説明した通りである。

f:id:numb_86:20201201050018p:plain

cacheGropes.***.testを使うと、ライブラリを一緒くたにまとめてしまうのではなく、指定したライブラリだけを分割することが可能になる。

例えば、react-domlodashを分割したい場合は、以下のように書く。
パスの区切り文字を[\\/]にしているのは、クロスプラットフォームに対応するため。

  optimization: {
    splitChunks: {
      chunks: 'all',
      cacheGroups: {
        reactDom: {
          test: /[\\/]node_modules[\\/]react-dom[\\/]/,
          name: 'react_dom',
        },
        lodash: {
          test: /[\\/]node_modules[\\/]lodash[\\/]/,
          name: 'lodash',
        }
      }
    },
  }

この状態でビルドすると、index.bundle.jsの他に、react_dom.bundle.jslodash.bundle.jsが生成される。

f:id:numb_86:20201201045923p:plain

react-domlodashだけがそれぞれに分割され、それ以外のライブラリはindex.bundle.jsに含まれている。