【Node.js】module.exportsで自作モジュールを分割する|exportsの罠・named/default

【Node.js】module.exportsで自作モジュールを分割する|exportsの罠・named/default Node.js

コードが大きくなってきたら、処理をファイルごとに分けて、必要なところで読み込むと整理できます。Node.jsのCommonJSでは、公開する側がmodule.exports、読み込む側がrequireを使います。

ここで多くの人がつまずくのが、module.exportsexportsという、よく似た2つの存在です。exports.foo = ...は動くのに、exports = {...}と書くとなぜか何も公開されない、という現象が起きます。この記事では、実機のNode.jsで確認しながら、自作モジュールの作り方と、この罠の正体を解説します。

先に結論

  • 公開はmodule.exports = ...、読み込みはconst x = require("./file")です。
  • 複数公開はmodule.exports = { add, sub }、単一公開はmodule.exports = 関数やクラスです。
  • exportsmodule.exportsへの近道(参照)です。exports.foo = ...は動きます。
  • exports = {...}という再代入は効きません。参照が切れて、何も公開されなくなります。
  • 迷ったら常にmodule.exportsを使うのが安全です。
  • requireは一度読み込んだモジュールをキャッシュし、2回目以降は同じものを返します。

requireとESモジュールのimportの全体像はrequireとimportの違い、ファイル分割と一緒に使うファイル操作はfsでファイルを読み書きする方法、パッケージの公開はnpmとpackage.jsonの基礎もあわせて参考になります。

スポンサーリンク

基本:module.exportsで公開、requireで読み込む

まず基本形です。公開したい関数や値をmodule.exportsに入れ、使う側でrequireします。

math.js
// 公開する側(math.js)
function add(a, b) {
    return a + b;
}

module.exports = add;
main.js
// 読み込む側(main.js)
const add = require("./math");

console.log(add(2, 3));   // 5

require("./math")のように、自作ファイルは./から始まる相対パスで指定します(拡張子.jsは省略できます)。requireが返すのは、そのファイルのmodule.exportsに入れた値そのものです。

複数の値を公開する(名前付き)

1つのファイルから複数の関数を公開するなら、オブジェクトにまとめてmodule.exportsに入れます。読み込む側は分割代入で取り出せます。

calc.js
// 複数の関数をオブジェクトで公開
function add(a, b) { return a + b; }
function sub(a, b) { return a - b; }

module.exports = { add, sub };
use-calc.js
// まとめて受け取る
const calc = require("./calc");
console.log(calc.add(5, 2));   // 7

// 分割代入で必要なものだけ取り出す
const { add, sub } = require("./calc");
console.log(sub(5, 2));   // 3

実機でも、オブジェクトで公開した関数をrequire側で呼び出せました。exports.add = ...のように1つずつ追加する書き方もできますが、まとめてmodule.exports = { ... }と書くほうが、何を公開しているか一目で分かります。

1つの値(関数・クラス)を公開する

そのファイルが「1つの関数」や「1つのクラス」だけを提供する場合は、module.exportsに直接代入します。

logger.js
// クラスをそのまま公開する
class Logger {
    log(message) {
        console.log("[LOG] " + message);
    }
}

module.exports = Logger;
use-logger.js
const Logger = require("./logger");

const logger = new Logger();
logger.log("起動しました");   // [LOG] 起動しました

実機でも、module.exports = 関数の形で公開した関数やクラスを、require側でそのまま使えました。「このファイルは何を提供するのか」がはっきりするため、1ファイル1機能のときはこの形が分かりやすいです。

【最重要】module.exportsとexportsの違い

ここがこの記事の核心です。Node.jsには、似た名前のmodule.exportsexportsがあります。実は、exportsmodule.exportsを指す「近道(ショートカット)」にすぎません。最初は両方が同じ空のオブジェクトを指しています。

module-vs-exports.js
// 最初は exports と module.exports は「同じオブジェクト」を指す
// exports === module.exports  // true

// exports.foo = ... は、その共有オブジェクトにプロパティを足すので効く
exports.add = (a, b) => a + b;
// → module.exports にも add が入る(同じものだから)

// これは下記と同じ意味
module.exports.add = (a, b) => a + b;

つまり、exports.add = ...は、共有しているオブジェクトにプロパティを追加しているだけなので、ちゃんとmodule.exportsに反映されます。実機でも、exports.addで公開した関数はrequire側でadd(2,3) = 5と正しく呼べました。問題は、次の「再代入」です。

【最重要】exports = の再代入は効かない

exports = {...}丸ごと代入し直すと、exportsは新しいオブジェクトを指すようになり、module.exportsとのつながりが切れます。その結果、require側には何も公開されません。

reassign-gotcha.js
// NG: exports に丸ごと代入し直すと、module.exports と切り離される
exports = { add: (a, b) => a + b };
// → require 側では add が undefined になる(何も公開されない)

// OK: module.exports に代入する
module.exports = { add: (a, b) => a + b };
// → require 側で add が使える
丸ごと公開するなら module.exports を使う

実機で確認したところ、exports = { add: ... }と書いたファイルをrequireすると、addundefinedでした(何も公開されない)。一方、module.exports = { add: ... }ならadd(2,3) = 5と正しく使えました。理由は、requireが返すのはあくまでmodule.exportsであり、exportsを別のオブジェクトに付け替えてもmodule.exportsは元のままだからです。オブジェクトや関数を丸ごと公開するときは、必ずmodule.exportsに代入してください。プロパティを1つずつ足すときだけexports.foo = ...が使えます。迷ったらmodule.exportsに統一するのが安全です。

exportsとmodule.exportsを混在させない

同じファイルでexports.foo = ...module.exports = ...を両方書くと、混乱のもとになります。module.exports = ...で丸ごと置き換えると、それまでのexports.fooは消えてしまいます。

mixing.js
exports.foo = 1;                  // いったん foo を足す
module.exports = { bar: 2 };      // でも丸ごと置き換えると foo は消える

// require 側で受け取れるのは { bar: 2 } だけ
// foo は無くなっている

実機でも、上のように混在させるとrequire側が受け取るのは{ bar: 2 }だけで、fooは消えていました。1つのファイルでは、module.exportsに丸ごと」か「exports.xで1つずつ」のどちらかに統一してください。

requireはキャッシュされる

requireは、一度読み込んだモジュールをキャッシュします。同じファイルを2回requireしても、ファイルが再実行されるのではなく、1回目と同じものが返ります。

counter.js
// counter.js
let count = 0;
module.exports = () => ++count;
use-counter.js
const counter1 = require("./counter");
const counter2 = require("./counter");

console.log(counter1 === counter2);   // true(同じものが返る)

console.log(counter1());   // 1
console.log(counter1());   // 2
console.log(counter2());   // 3(状態が共有されている)

実機でも、同じファイルを2回requireすると同一のインスタンス===がtrue)が返り、カウンターの状態が共有されて1, 2, 3と進みました。この性質のおかげで、設定オブジェクトやデータベース接続を「一度だけ作って使い回す」ことができます。逆に、毎回新しいものがほしい場合は、モジュールから関数やクラスを公開して、使う側でnewする設計にします。

ESモジュールのexportとの対応

CommonJSのmodule.exportsは、ESモジュールのexportに対応します。新しく書くならESモジュールも選択肢です。書き方の対応は次のとおりです。

目的 CommonJS ESモジュール
複数を公開 module.exports = { add, sub } export { add, sub }
1つを公開 module.exports = fn export default fn
読み込み const { add } = require("./m") import { add } from "./m.js"

どちらの方式を使うか、混在の注意点などはrequireとimportの違いで詳しく解説しています。1つのプロジェクトでは、どちらかに統一するのが基本です。

よくある失敗

exports = {…} と書いて何も公開されない

exportsへの再代入はmodule.exportsとのつながりを切ります。丸ごと公開するならmodule.exports = {...}を使います。

exports.fooとmodule.exports=を混在させる

module.exports = ...で置き換えると、それまでのexports.fooは消えます。どちらかに統一します。

requireで毎回新しいものが返ると思う

requireはキャッシュされ、2回目以降は同じものが返ります。毎回新しくしたいなら、クラスを公開してnewします。

自作ファイルを./なしでrequireする

自作ファイルはrequire("./math")のように./を付けます。./が無いと、npmパッケージとして探しに行きます。

module.exportsへの代入を関数定義の前に置く

関数宣言は巻き上げられますが、読みやすさのため、公開する値を定義してからmodule.exportsに入れる順序がおすすめです。

よくある質問

Qmodule.exportsとexportsはどちらを使うべきですか?
A迷ったらmodule.exportsに統一するのが安全です。exportsmodule.exportsへの近道で、exports.foo = ...のようにプロパティを足すときだけ使えます。オブジェクトや関数を丸ごと公開するときは必ずmodule.exportsを使います。
Qexports = {…} と書いたのに何も公開されません。
Aexportsに丸ごと代入すると、module.exportsとのつながりが切れるためです。requireが返すのはmodule.exportsなので、module.exports = {...}と書いてください。
Q1つの関数だけを公開するには?
Amodule.exports = 関数名と直接代入します。読み込む側はconst fn = require("./file")でその関数をそのまま受け取れます。クラスも同じくmodule.exports = クラス名で公開できます。
Q同じモジュールをrequireすると毎回新しくなりますか?
Aいいえ。requireはキャッシュされ、2回目以降は1回目と同じものが返ります。状態も共有されます。毎回新しいインスタンスがほしいときは、クラスを公開して使う側でnewしてください。
QESモジュールのexportとどう対応しますか?
Amodule.exports = { add }export { add }module.exports = fnexport default fnに対応します。読み込みはrequireimportに当たります。詳しくはrequireとimportの違いの記事を参照してください。

まとめ

  • 公開はmodule.exports、読み込みはrequire("./file")です。
  • 複数はmodule.exports = { add, sub }、単一はmodule.exports = 関数やクラスです。
  • exportsmodule.exportsへの近道exports.foo = ...は効きます。
  • exports = {...}の再代入は効きません。丸ごと公開はmodule.exportsを使います。
  • requireはキャッシュされ、2回目以降は同じものが返ります。
  • CommonJSのmodule.exportsは、ESモジュールのexportに対応します。

自作モジュールでつまずく原因は、ほぼmodule.exportsexportsの混同に集約されます。「丸ごと公開はmodule.exports、プロパティ追加だけexports.x、迷ったらmodule.exports」と覚えておけば、ファイル分割で困ることはなくなります。