5
Help us understand the problem. What are the problem?

More than 1 year has passed since last update.

posted at

updated at

Organization

JSエコシステムぶらり探訪(5): CommonJSモジュールバンドラー

←前 目次 次→

モジュールバンドラーとは

前3回までで説明したNode.jsのモジュールシステムとnpmのパッケージシステムは、再利用可能なJavaScriptプログラムを作り、配布し、利用する仕組みとして非常によくできたものでした。そのため、このNode.jsのモジュールシステムをブラウザでも利用したいという需要が生じることになりました。正確には以下の2つの需要がありました。

  • Node.js用に書いたプログラムをブラウザでも再利用したい。
  • 純粋にブラウザ向けのJavaScriptコードでも、モジュールシステムやパッケージシステムを使いたい。

これらの願いを同時に叶えるべく、Browserifyをはじめとしたモジュールバンドラーというツールが開発されました。モジュールバンドラーは複数のJavaScriptモジュールを結合し、ひとつのJavaScriptファイルを生成します。

モジュールバンドラーの機能を持つツールは一つではなく、比較的有名なものでもBrowserify, Webpack, rollup.js, Parcel, FuseBox, esbuild など多くの実装がありますが、本稿では現在最もメジャーと思われる多機能バンドラー Webpack を代表例として扱います。

Webpackでバンドルしてみる

CommonJS modulesの説明に使った例を再掲します。

src/main.js
var util = require('./util');
console.log(util.square(2)); // => 4
console.log(util.private_value); // => undefined
src/util.js
var private_value = 42;
// exports.square = ... でもよい
module.exports.square = function(x) {
  return x * x;
};

これをWebpackでバンドリングしてみます。

package.json
{
  "scripts": {
    "build": "webpack --mode none --entry ./src/main.js"
  },
  "devDependencies": {
    "webpack": "^5.4.0",
    "webpack-cli": "^4.2.0"
  }
}
npm install
npm run build

これで dist/main.js が生成されます。多くの場合はこれを <script> から呼び出しますが、今回はブラウザの機能を使っていないので、そのままNode.jsで動かすことができます。

node dist/main.js

CommonJSモジュールバンドラーの制約

CommonJSモジュールバンドラーは、あらゆるCommonJSモジュールに対応しているわけではありません。原則として、以下のように定数インポートのみを行っているモジュールのみがバンドルできます。

  • require は必ず関数呼び出しの形で出現する。 require(...) はOKだが f(require)let x = require のような形は駄目。
  • require(...) の引数は必ず文字列リテラルである。require("./hoge") はOKだが require(addExtension("./hoge")) は駄目。

ただし、モジュールバンドラーによっては上記を満たさないコードも何とかしてバンドルできる場合があります (例: Webpackのrequire context)。

また、モジュールバンドルという機能自体はNode.jsとブラウザの環境差異を吸収しない1ため、環境差異については別途考慮する必要があります。本稿ではモジュールバンドル自体の性質のみを扱います。

条件つきrequire

この定数インポート条件は冠頭形モジュールよりも制約が緩やかで、条件つき requireを行うことができます。たとえばReactのエントリポイントは以下のように記述されています。

index.js
'use strict';

if (process.env.NODE_ENV === 'production') {
  module.exports = require('./cjs/react.production.min.js');
} else {
  module.exports = require('./cjs/react.development.js');
}

これは冠頭形モジュールの条件は満たしていませんが、定数インポートの条件は満たしています。

CommonJSバンドリングの実現方法

上記のように、CommonJSバンドリングでは条件つきrequireを扱う必要があります。また、Node.jsには「同一モジュールは1度しか評価されない」という挙動があり、一部のライブラリはこの性質に依存しているため、この挙動の再現もほぼ必須です (パフォーマンス上も重要)。

そのため、CommonJSのバンドルは原則として「Node.jsの require の挙動を実行時に再現する」というアプローチになります。 2

  1. バンドル時には以下を行います。
    • 全ての require 呼び出しを再帰的に収集し、収録するべきモジュールの一覧を確定する。 (このために定数インポートである必要がある)
    • モジュールにIDを振る (絶対パス3や番号などが使われる)
    • 全ての require 呼び出しを相対パスからIDに置き換える
    • モジュールのソースコードを、関数のリスト (オブジェクト) として1つのJavaScriptにまとめる。
  2. 実行時には以下を行います。
    • 読み込み済みモジュールを管理するためのオブジェクトを用意する。
    • バンドル済みのモジュール (関数) を実行する require 関数を作る。
    • require を使って、最初のモジュールを読み込む。

例として、以下のようなプログラムを考えます。

src/index.js
console.log("Loading: index.js");
require("./utils/module2");
require("./utils/module3");
console.log("Loaded: index.js");
src/utils/module1.js
console.log("Loading: module1.js");
console.log("Loaded: module1.js");
src/utils/module2.js
console.log("Loading: module2.js");
require("./module1");
console.log("Loaded: module2.js");
src/utils/module3.js
console.log("Loading: module3.js");
require("./module1");
console.log("Loaded: module3.js");

これをバンドルすると、以下のようなJavaScriptになります。 (Webpackの出力を参考に手書きし、説明を付与したものです)

bundled.js
// IIFEでスコープを作って実行
(function () {
  // 必要なモジュールのソースがそのまま入ったオブジェクト。
  // この例ではCWDからの相対パスをそのままモジュールIDとして使っている
  var moduleDefs = {
    // 渡されるexports, require, moduleはNode.jsで使われているものと互換
    "src/index.js": function(exports, require, module) {
      console.log("Loading: index.js");
      // この部分がモジュール相対ではなく、CWD相対に変換されていることがポイント!
      require("src/utils/module2.js");
      require("src/utils/module3.js");
      console.log("Loaded: index.js");
    },
    "src/utils/module1.js": function(exports, require, module) {
      console.log("Loading: module1.js");
      console.log("Loaded: module1.js");
    },
    "src/utils/module2.js": function(exports, require, module) {
      console.log("Loading: module2.js");
      require("src/utils/module1.js");
      console.log("Loaded: module2.js");
    },
    "src/utils/module3.js": function(exports, require, module) {
      console.log("Loading: module3.js");
      require("src/utils/module1.js");
      console.log("Loaded: module3.js");
    },
  };

  // ロード済みモジュールの一覧を入れるオブジェクト
  var modules = {};
  // Node.jsのrequireのかわりに使う関数。
  // ここではわかりやすさのためにpathという変数名にしているが、
  // Node.jsのrequireと異なり、モジュールIDなら何でもよい。
  // (あらかじめバンドラーによってrequire()の呼び出しが変換されているため)
  function require(path) {
    // 読み込み済み、または読み込み中 (循環参照) のときはキャッシュされたモジュールを返す
    if (modules[path]) return modules[path].exports;

    // Node.js互換のCommonJS呼び出しロジック
    var module = modules[path] = { exports: {} };
    moduleDefs[path](module.exports, require, module);

    return module.exports;
  }

  // エントリポイントの読み込みを開始
  require("src/index.js");
})();

まとめ

  • Node.jsのモジュールシステムをブラウザでも使うための仕組みとして、モジュールバンドラーが生まれた。
  • CommonJSモジュールバンドラーは、ビルド時にはファイルの収集と最低限の変換だけ行い、実行時にNode.jsのインポート処理を模倣するという方法で実装されている。

本稿で説明したものはCommonJSモジュールバンドラーの中核となる機能にすぎません。本シリーズの後半で、高機能アセットパイプラインとしてのWebpackを詳しく見ていきますが、その前にCommonJS以外のモジュールシステムに触れる予定です。

←前 目次 次→


  1. 実際にはWebpackのnode shimのように、そういった機能が同梱されているものも多い 

  2. モジュール結合という形で、より積極的な変換が可能な場合もある。 

  3. 正確には絶対パスよりもCWDからの相対パスなどが使われることが多いと思われます。重要なのは、インポート元のモジュールからの相対ではないということです。 

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Sign upLogin
5
Help us understand the problem. What are the problem?