CommonJS によるバンドルの拡大

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 バンドルのサイズです。

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

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

次の 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 を使用するとアプリのサイズが大きくなるのはなぜですか?

この質問に答えるには、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));

上記の ECMAScript モジュールは、index.js でインポートします。また、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 からインポートするシンボルと、エクスポートするシンボルを静的に(ビルド時に)理解できたからです。

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

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

// 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())] = () => {  };

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

これにより、index.js が依存関係から何を使っているかを圧縮ツールが正確に把握できなくなるため、その依存関係を削除できなくなります。サードパーティ製モジュールでもまったく同じ動作になります。node_modules から CommonJS モジュールをインポートすると、ビルド ツールチェーンで適切に最適化できなくなります。

CommonJS でのツリー シェイキング

CommonJS モジュールは定義上動的であるため、分析がはるかに困難です。たとえば、ES モジュールのインポート ロケーションは常に文字列リテラルですが、CommonJS では式です。

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

まとめ

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

最適なパスを歩んでいることを確認するための実践的なヒントをいくつかご紹介します。

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