CommonJS によるバンドルの拡大

CommonJS モジュールがアプリケーションのツリー シェイキングに与える影響について学習する

この投稿では、CommonJS の概要と、それによって JavaScript バンドルが必要以上に大きくなる理由を説明します。

概要: バンドラがアプリケーションを適切に最適化できるようにするには、CommonJS モジュールに依存せず、アプリケーション全体で ECMAScript モジュール構文を使用してください。

CommonJS とは

CommonJS は、JavaScript モジュールの規則を確立した 2009 年からの標準です。当初は、主にサーバーサイド アプリケーションなど、ウェブブラウザ以外での使用を想定していました。

CommonJS を使用すると、モジュールの定義、モジュールからの機能のエクスポート、他のモジュールへのインポートを行うことができます。たとえば、以下のスニペットは、addsubtractmultiplydividemax の 5 つの関数をエクスポートするモジュールを定義しています。

// utils.js
const { maxBy } = require('lodash-es');
const fns = {
  add: (a, b) => a + b,
  subtract: (a, b) => a - b,
  multiply: (a, b) => a * b,
  divide: (a, b) => a / b,
  max: arr => maxBy(arr)
};

Object.keys(fns).forEach(fnName => module.exports[fnName] = fns[fnName]);

後で、別のモジュールで次の関数の一部またはすべてをインポートして使用できます。

// index.js
const { add } = require('./utils.js');
console.log(add(1, 2));

nodeindex.js を呼び出すと、コンソールに 3 という番号が出力されます。

2010 年代初頭にブラウザに標準化されたモジュール システムがなかったため、CommonJS は JavaScript クライアントサイド ライブラリにも広く利用されるようになりました。

CommonJS が最終的なバンドルのサイズにどのように影響するか

サーバー側の JavaScript アプリケーションのサイズは、ブラウザほど重要ではありません。そのため、CommonJS は本番環境のバンドルのサイズを小さくするように設計されていませんでした。同時に、分析では、JavaScript バンドルのサイズがブラウザアプリの速度を低下させる最大の理由であることも明らかになりました。

webpackterser などの JavaScript バンドラとミニファイアは、アプリのサイズを縮小するためにさまざまな最適化を行います。ビルド時にアプリケーションを分析すると、使用していないソースコードから可能な限り削除しようとします。

たとえば、上記のスニペットの場合、最終的なバンドルには add 関数のみを含める必要があります。これは、index.js にインポートする utils.js の唯一のシンボルであるためです。

次の webpack 構成を使用してアプリをビルドしましょう。

const path = require('path');
module.exports = {
  entry: 'index.js',
  output: {
    filename: 'out.js',
    path: path.resolve(__dirname, 'dist'),
  },
  mode: 'production',
};

ここでは、本番環境モードの最適化を使用し、index.js をエントリ ポイントとして使用することを指定します。webpack を呼び出した後に出力サイズを調べると、次のように表示されます。

$ cd dist && ls -lah
625K Apr 13 13:04 out.js

バンドルのサイズは 625 KB です。出力を調べると、utils.js のすべての関数と、lodashの多くのモジュールがあることがわかります。index.js では lodash は使用しませんが、出力の一部であるため、本番環境用アセットに多くの重みが加わります。

次に、モジュール形式を ECMAScript モジュールに変更して、もう一度挑戦してみましょう。今回は、utils.js は次のようになります。

export const add = (a, b) => a + b;
export const subtract = (a, b) => a - b;
export const multiply = (a, b) => a * b;
export const divide = (a, b) => a / b;

import { maxBy } from 'lodash-es';

export const max = arr => maxBy(arr);

また、index.js は、ECMAScript モジュール構文を使用して utils.js からインポートします。

import { add } from './utils.js';

console.log(add(1, 2));

同じ webpack 構成を使用して、アプリケーションをビルドし、出力ファイルを開くことができます。現在は 40 バイトです。次の出力があります。

(()=>{"use strict";console.log(1+2)})();

最終的なバンドルには、使用しない utils.js の関数は含まれていません。また、lodash からのトレースもないことに注意してください。さらに、terserwebpack が使用する JavaScript ミニファイア)により、console.logadd 関数がインライン化されています。

CommonJS を使用すると、出力バンドルが約 16,000 倍大きくなるのはなぜか」という疑問が湧くかもしれません。もちろん、これは単純な例です。実際にはサイズの差はそれほど大きくないかもしれませんが、CommonJS によって本番環境のビルドが大幅に増える可能性があります。

CommonJS モジュールは ES モジュールよりもはるかに動的であるため、一般的に最適化が難しくなります。バンドラとミニファイアでアプリケーションを正常に最適化するには、CommonJS モジュールに依存せず、アプリケーション全体で ECMAScript モジュール構文を使用してください。

index.js で ECMAScript モジュールを使用していても、使用するモジュールが CommonJS モジュールの場合は、アプリのバンドルサイズに影響が生じることに注意してください。

CommonJS によってアプリが大きくなる理由

この質問に答えるために、webpack 内の ModuleConcatenationPlugin の動作を確認してから、静的分析可能性について説明します。このプラグインは、すべてのモジュールのスコープを 1 つのクロージャに連結し、ブラウザでのコードの実行時間を短縮します。例を見てみましょう。

// utils.js
export const add = (a, b) => a + b;
export const subtract = (a, b) => a - b;
// index.js
import { add } from './utils.js';
const subtract = (a, b) => a - b;

console.log(add(1, 2));

上の例には、index.js にインポートする ECMAScript モジュールがあります。また、subtract 関数も定義します。上記と同じ webpack 構成を使用してプロジェクトをビルドできますが、今回は最小化を無効にします。

const path = require('path');

module.exports = {
  entry: 'index.js',
  output: {
    filename: 'out.js',
    path: path.resolve(__dirname, 'dist'),
  },
  optimization: {
    minimize: false
  },
  mode: 'production',
};

生成された出力を見てみましょう。

/******/ (() => { // webpackBootstrap
/******/    "use strict";

// CONCATENATED MODULE: ./utils.js**
const add = (a, b) => a + b;
const subtract = (a, b) => a - b;

// CONCATENATED MODULE: ./index.js**
const index_subtract = (a, b) => a - b;**
console.log(add(1, 2));**

/******/ })();

上記の出力では、すべての関数が同じ名前空間内にあります。競合を防ぐために、webpack は index.jssubtract 関数の名前を index_subtract に変更しました。

圧縮ツールは上記のソースコードを処理すると、以下の処理を行います。

  • 未使用の関数 subtractindex_subtract を削除します。
  • すべてのコメントと不要な空白文字を削除する
  • console.log 呼び出しで add 関数の本文をインライン化する

多くのデベロッパーは、使用されていないインポートの削除を「ツリー シェイキング」と呼んでいます。ツリー シェイキングが可能だったのは、webpack が utils.js からインポートするシンボルとエクスポートするシンボルを(ビルド時に)静的に理解できたからです。

ES モジュールでは、CommonJS よりも静的に分析しやすいため、この動作はデフォルトで有効になっています。

まったく同じ例を見てみましょう。今回は、ES モジュールではなく CommonJS を使用するように utils.js を変更します。

// utils.js
const { maxBy } = require('lodash-es');

const fns = {
  add: (a, b) => a + b,
  subtract: (a, b) => a - b,
  multiply: (a, b) => a * b,
  divide: (a, b) => a / b,
  max: arr => maxBy(arr)
};

Object.keys(fns).forEach(fnName => module.exports[fnName] = fns[fnName]);

この小さな更新によって、出力が大幅に変化します。このページに埋め込むには長すぎるため、そのごく一部のみをお伝えしておきます。

...
(() => {

"use strict";
/* harmony import */ var _utils__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(288);
const subtract = (a, b) => a - b;
console.log((0,_utils__WEBPACK_IMPORTED_MODULE_0__/* .add */ .IH)(1, 2));

})();

最終的なバンドルには webpack「ランタイム」が含まれています。ランタイムとは、バンドルされたモジュールから機能をインポート/エクスポートする、挿入されたコードです。今回は、utils.jsindex.js のすべてのシンボルを同じ名前空間に配置するのではなく、実行時に __webpack_require__ を使用して add 関数を動的に指定する必要があります。

これが必要なのは、CommonJS を使用して任意の式からエクスポート名を取得できるためです。たとえば、以下のコードは絶対に有効な構造です。

module.exports[localStorage.getItem(Math.random())] = () => { … };

エクスポートされたシンボルの名前を Bundler がビルド時に知る方法はありません。これは、ユーザーのブラウザのコンテキストにおいて、実行時にのみ利用できる情報を必要とするためです。

このように、圧縮ツールは index.js が依存関係から何を使用するのかを正確に把握できないため、ツリー シェイクできません。サードパーティ モジュールの場合も、まったく同じ結果になります。CommonJS モジュールを node_modules からインポートすると、ビルド ツールチェーンで適切に最適化できなくなります。

CommonJS によるツリー シェイキング

CommonJS モジュールは本質的に動的であるため、分析ははるかに困難です。たとえば、ES モジュールではインポートの場所は常に文字列リテラルですが、CommonJS では式が使用されます。

使用しているライブラリが CommonJS の使用方法に関する特定の規則に従っている場合、サードパーティの webpack pluginを使用して、ビルド時に使用されていないエクスポートを削除できます。このプラグインはツリー シェイキングのサポートを追加しますが、依存関係で CommonJS を使用するすべてのさまざまな方法を網羅しているわけではありません。これは、ES モジュールと同じ保証が得られないことを意味します。また、デフォルトの webpack 動作に加えて、ビルドプロセスの一部として追加コストが発生します。

まとめ

バンドラがアプリケーションを適切に最適化できるようにするには、CommonJS モジュールに依存せず、アプリケーション全体で ECMAScript モジュール構文を使用してください。

ここでは、最適なパスを進んでいることを確認する実用的なヒントをいくつかご紹介します。

  • Rollup.js の node-resolve プラグインを使用し、modulesOnly フラグを設定して、ECMAScript モジュールのみに依存することを指定します。
  • is-esm パッケージを使用して、npm パッケージで ECMAScript モジュールが使用されていることを確認します。
  • Angular を使用している場合、デフォルトでは、ツリー シェイキングが不可能なモジュールに依存していると警告が表示されます。