🥝

JavaScriptのESMとCJSって?

2024/07/26に公開

皆さんは普段Node.jsを使って開発していてESModules(ESM)とCommonJS(CJS)という主に2つのモジュールシステムがあるのを意識しながらコードを書いていますか?あまり分かってなくて雰囲気で書いている方も少なくないんじゃないかなと思います(以前は自分もそうでした)。今回はこれらのモジュールシステムについて解説しようと思います。

JavaScriptにおけるモジュールシステム

モジュールというのは簡単に言うとプログラムの特定の機能のまとまりであり、モジュールシステムはあるモジュールが他のモジュールが提供している機能を利用するための仕組みのことです。
モジュールシステムがあることで、コードの再利用性や保守性を上げたり名前空間を管理して名前の衝突を防いだり依存関係を解決したりすることができます。
多くのプログラミング言語にはモジュールシステムが存在しており、例えばPythonにおいてはimport文, Rubyにおいてはrequireメソッドが外部モジュール利用のために用意されています。
しかし、Webページ上で動かす軽量なスクリプト言語として開発されたJavaScriptには当初名前空間の管理や依存解決ができるようなモジュールシステムは存在せず、jQueryの$に代表されるようにグローバル変数を通してライブラリを利用していました。そのため、当時はスクリプト読み込みの順序やグローバル空間の汚染などを意識してコードを書く必要がありました。
後にJavaScriptにもモジュールシステムが追加されますが、ESModulesやCommonJSなど複数のモジュールシステム[1]が登場することになります。

ESModules(ESM)とは

ECMAScript(ES)はJavaScriptの標準化団体であるECMAによって定められたJavaScriptの標準仕様で1997年に最初に策定されました。
ESMはブラウザでもブラウザ外でも使用できる標準的なモジュールシステムを提供するために2016年にES6の一部としてJavaScriptの標準仕様に追加されました。

モジュールの使用方法

exportキーワードでモジュールを公開し、importキーワードでモジュールを読み込みます。

// add.js
export function add(x, y) { return x + y }

// main.js
import add from './add.js'
<!-- ブラウザで読み込む場合, scriptタグのtype属性にmoduleを指定します -->
<script type="module" src="main.js"></script>

CommonJS(CJS)とは

CommonJSはJavaScriptをブラウザ外で動かすときのためにブラウザに存在しなかった標準入出力などの共通仕様を定めることを目指したプロジェクトで、2009年頃に発足しました。ブラウザ外では外部ファイルを読み込む仕組みもなかったのでCommonJSにおいてモジュールシステムの仕様が定められ、同時期にリリースされたNode.jsもCommonJSにおけるモジュールシステムの仕様に基づいて実装していました。

モジュールの使用方法

module.exportsでモジュールを公開し、requireでモジュールを読み込みます。

// add.js
module.exports = function add(x, y) { return x + y }

// main.js
var add = require('./add')
module.exportsとexportsの違い

module.exportsのエイリアスとしてexportsを使われることがあります。

// 以下は等価
module.exports.foo = foo
exports.foo = foo

console.log(module.exports === exports) // => true

注意点として、ただのエイリアスなので以下のコードは等価ではなくなり、実際にexportできるのは前者のみとなります。

module.exports = foo
exports = foo

ESMとCJSの違い

実行環境

ESMはブラウザやv12以上のNode.jsや動作します。また、DenoやBunといった新しいランタイムでも動作します。
CJSはNode.jsで動作します。また、BunでもサポートされていますがDenoではサポートされていません。
Bunにおいては常に両方が動作するように実装されており、1つのファイル中でimportもrequireも使うことができます。
Node.jsでは拡張子がjsの場合はpackage.jsonに"type": "module"が書かれているとESM、書かれていなければCJSとして解釈されます。また、拡張子がmjsの場合はESM、cjsの場合はCJSとして解釈されます。

同期か非同期か

ESMでは非同期でモジュールをロードします[2]
例えば以下の例ではfooとbarは(両者に依存関係が無ければ)並行して読み込まれます。

import foo from 'foo.js'
import bar from 'bar.js'

一方、CJSでは同期的にモジュールをロードします。
必ずしも非同期で読み込む方が速いわけではないですが、ブラウザ環境などネットワーク通信が発生する環境では非同期の方が速いことが多いです。

静的解析可能か

ESMではexportは静的解析できるようになっておりimportも動的importを除くと静的解析できるため、実行前にモジュールの存在チェックがされたりツリーシェイキングが多くのツールでサポートされていたりします。
一方、CJSでは静的解析ができません。

読み込むファイルの拡張子が省略可能か

CJSでrequireするときは拡張子を省略できますが、ESMでimportするときは省略できません。

strictモードがデフォルトで有効か

ESMはデフォルトでstrictモードが有効ですが、CJSではファイルの先頭に'use strict';を書かないとstrictモードが有効になりません。

Node.jsにおける互換性

REPL環境ではCJSのみがサポートされています。

ESMからCJSの読み込み

ESMからESMを読み込むのと同様の書き方で読み込みができます。

CJSからESMの読み込み

ESMをrequireを用いて読み込むことはできませんが、動的import文を用いることでESMを読み込むことができます。
これはCommonJSにないTop-level awaitという機能によるものです。現在requireによるESMの読み込みがサポートされようとしていますが、読み込まれる側のESMでTop-level awaitが使用されていないことが条件になっています。

エコシステムとの関係

npmパッケージ

npmに存在するパッケージはNode.jsで当初CJSが採用されていたこともあり、CJSのみがサポートされていることが多かったですが近年はCJSとESMの両方またはESMのみがサポートされていることが増えています。
npmパッケージでCJSとESMの両方をサポートするにはpackage.jsonのexportsフィールドが用いられます。

package.json
{
    "exports": {
        "import": "./index.mjs",
        "require": "./index.cjs"
    }
}

また、lodashに対するlodash-esのように元々CJSで書かれたライブラリのESM版もリリースされていて、ESMの利点であるツリーシェイキングの恩恵を受けやすくなっています。

ツール

ツールやフレームワークの設定ファイル(例: .eslintrc.js, jest.config.js)ではrequireを使うことが多いと思います。この理由は上でも説明した通り、Node.jsにおいてはデフォルトではCJSとして解釈されるからです。
個別のツールで言うと、JestにおいてはデフォルトではCJSに変換して実行していたり、Viteの開発モードにおいてはブラウザ向けにはESMに変換して提供することでバンドルを不要にしています。

TypeScript

TypeScriptにおいては、モジュール読み込みのための構文としては通常importが使われています。これはTypeScriptをJavaScriptに変換するときにtsconfigの設定に応じてESMやCJSなどに対応したもの変換されます。
tscによってどの形式に変換されるかはtsconfigのmoduleオプションによって制御され、例えばcommonjsの場合はCJS、es2022の場合はESMに変換されます。また、node16を指定した場合はNode.jsの動作に準拠して拡張子ctsはcjs、拡張子mtsはmjs、拡張子tsはpackage.jsonのtypeフィールドによって決定されます。
また、TypeScriptのimportではESMと違って拡張子を省略できます[3]が、これもtscによってESMへ変換する場合は自動的に追加されています。

モジュールシステムの今後

現状としてはCJSのみで提供されるライブラリが多いこともあり、Node.jsにおいてはCJSが使用されることが多いです。
また、CJSのみにしか対応していないツールがあったり、ESMに対応していても別途設定やコードの変更が必要であるなどコミュニティ全体でESMへの移行するにあたって障壁はまだ多いです。
ですが、ESMしか対応していないライブラリが増加していることや、ESMがJavaScriptの標準仕様であること、ツリーシェイキングなどパフォーマンス面で有利であることなどから今後はNative ESMがだんだんと主流になっていくんじゃないかと思っています。

参考

https://zenn.dev/uhyo/articles/what-is-native-esm-era
https://zenn.dev/uhyo/articles/typescript-module-option
https://zenn.dev/qnighy/articles/19603f11d5f264

脚注
  1. 他にはAMD(Asynchronous Module Definition)など ↩︎

  2. 仕様で非同期と定められているわけではないですが、一般的には非同期でロードするように実装されています ↩︎

  3. tsconfigのmoduleResolutionnode16nodenextでない場合 ↩︎

Discussion