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));

node を指定して index.js を呼び出すと、コンソールに数値 3 が出力されます。

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

CommonJS が最終的なバンドルのサイズに与える影響

サーバーサイドの JavaScript アプリケーションのサイズは、ブラウザほど重要ではありません。そのため、CommonJS は、本番環境のバンドルのサイズを小さくすることを念頭に置いていませんでした。同時に、分析では、JavaScript バンドルのサイズが、ブラウザアプリの速度低下の原因となる最大の要因であることに変わりはありません。

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

たとえば、上記のスニペットでは、最終的なバンドルに 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 を呼び出した後に output のサイズを調べると、以下のように表示されます。

$ 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 によってアプリの規模が拡大するのはなぜですか?

この問いに答えるために、webpackModuleConcatenationPlugin の動作を確認し、その後、静的分析可能性について説明します。このプラグインは、すべてのモジュールのスコープを 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));**

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

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

上記のソースコードを処理する場合、圧縮ツールは次の処理を行います。

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

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

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「runtime」が含まれています。これは、バンドルされたモジュールから機能をインポート/エクスポートするコードを挿入したコードです。今回は、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 プラグインを使用して、ビルド時に未使用のエクスポートを削除できることがあります。このプラグインはツリー シェイキングのサポートを追加しますが、依存関係で CommonJS を使用するさまざまな方法をすべて網羅しているわけではありません。つまり、ES モジュールと同じ保証が得られません。また、デフォルトの webpack の動作に加えて、ビルドプロセスの一環として追加コストがかかります。

まとめ

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

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

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