この記事は夏のブログリレー17日目の記事です。
こんにちは、19Bの翠(sappi_red)です。普段はSysAd班で活動しています。Viteのチームメンバーだったりもします。
npmからインストールしたパッケージをモジュールバンドラーで使えないことに遭遇したことのある人は少なくないでしょう。
この記事ではそれが発生する要因の一つである、package.jsonのエントリーポイント用フィールドについて書いていきます。
モジュールバンドラーとトランスパイラは意味が異なるものですが、モジュール形式の変換においては境界線が非常に曖昧になるため、この記事ではまとめてバンドラーと呼ぶことにします。
モジュール形式
話を進める前に、前提となるモジュール形式という概念について説明しておきます。
JavaScriptでは元々、プログラムを複数のモジュールに分割する標準仕様が存在しませんでした。そのため、標準ではない仕様がいくつか登場しました。現在では、標準にモジュールの仕様が存在します。
形式の例
CommonJS modules (CJS)
CommonJSと呼ばれるブラウザ以外も考慮した仕様が存在しました。それに含まれるモジュールの仕様を指します。
require('foo')のように他のモジュールを読み込み、exports.foo = 0のように変数/関数を露出します。
この仕様を拡張したものがNode.jsに実装されており、現在ではこのNode.jsに実装されているものを指している場合がほとんどです。
Universal Module Definition (UMD)
ブラウザでモジュールを扱うための仕様としてAMDというものがあるのですが、それとCJSを両方とも扱えるモジュール形式がこのUMDです。
Immediately Invoked Function Expression (IIFE)
これはモジュールの形式かといわれると怪しいのですが、即時実行関数でモジュールをくるむような形式です。
import { bar } from 'bar';
console.log(bar);
export var foo = 1;
例えば、後述のESMで書かれた上のコードをrollupでIIFEに変換するとこのようにになります。変換結果を簡略化したものが下のコードです。
var mod = (function (exports, bar) {
console.log(bar.bar); // console.log(bar);
var foo = 1;
exports.foo = foo; // export var foo = 1;
return exports;
})({}, bar);
ECMAScript Modules (ES Modules, ESM)
前述した標準のモジュールの仕様で、ES2015で導入されました。
ブラウザやdenoでも利用可能で、Node.jsでも利用可能です。
importやexportといったキーワードでモジュールの読み込みと変数/関数の露出を行い、ほかの形式と異なり静的解析が行いやすいという利点があります。
ただし、importとexportの構文はトップレベルでしか宣言できないため、ESMに対応していなかったらCJSを利用するのような書き方ができません。
多様な環境で利用可能ですが、Node.jsなどの実行環境でのESM (native ESM)とバンドラーが受け入れるESM (faux ESM)では、パスの解決方法などが異なります。
faux ESMはESMであるのにかかわらず、CJSも†いい感じ†に解釈されていて、これが複雑さを生み出している大きな要因の一つです。
例えば、__dirnameはCJSの仕様ですが、faux ESMでは使えたりします。
ほかには、ESMとCJSを混ぜた記述をしても解釈されます。
import path from 'path'
const fs = require('fs')
つまり、こういうことをしてもfsもpathもそれっぽく振る舞います (native ESMであればrequireが使えません)。
ただし、これらはバンドラーに依存するので、あるバンドラーで動いても別のバンドラーでは動かなかったりします。
package.jsonのエントリーポイントフィールド
さて、本題に戻りまして、package.jsonのエントリーポイント用のフィールドについてです。
package.jsonはNode.jsで利用されるパッケージの情報を書くメタファイルですが、Node.jsだけでなくバンドラーもエントリーポイントを解決するために利用します。
main
Node.jsでのエントリーポイントを指定するフィールドです。指しているファイルの拡張子やそのファイルの近くのpackage.jsonのtypeフィールドによって、そのファイルがESMかCJSかが決定されます。
Node.jsでは、後述するexportsフィールドが存在すればそちらが優先されます。
バンドラーでは、後述するほかのフィールドが存在すればそれらが優先されます。
module (2014/07~)
ESMの静的解析のしやすさを利用するため、Node.jsがESMに対応する前からバンドラーはESMを利用していました。
前述した通り、ESMに対応していない実行環境でESMの記述を行うとエラーになるため、CJSとESMの両対応のファイルを置くことはできません(mainフィールドをESMのファイルにするとNode.jsで実行できなくなる)。そのため、ESMのファイルを指すエントリーポイント用のフィールドとして、このmoduleフィールドが非標準で用意されました。
このmoduleフィールドはバンドラー用なので、native ESMだけでなくfaux ESMが指定されることがあります。つまり、moduleフィールドに指定されたファイルをバンドルせずにNode.jsで実行することはできないことがあります。
過去にはフィールド名がjsnext:mainやjsnextというフィールド名でした。
Moment.jsではv2.24.4現在でもjsnext:mainで指定されていたりします。
browser (2012/12~)
様々な環境で実行できるパッケージ(universal、isomorphic)を作成する際、Node.jsで実行する場合とブラウザで実行する場合に異なる挙動にしたいことが存在します。例えば、Node.jsではhttp.getを利用するがブラウザには存在しないので代わりにfetchを利用する場合ですね。このようにブラウザ向けのファイルを指定するために提案された非標準のフィールドがbrowserフィールドです。
ある程度の仕様はあるのですが、ファイルの形式が特に定まっておらずESMやUMD、はたまたCJSやIIFEが指定されていることがあります。
その他
ほかのフィールドもあるのですが、この先の話にあまり関係なかったりマイナーなものなので、ここで軽く紹介するのに留めます。
JSからのインポートではなく、CSSからのインポート用のフィールドとして、styleフィールドが存在します。Webpackなどが対応していて、style以外にもsassフィールドなども存在します。
CDNで配布するファイルの指定用のフィールドとしてjsdelivrやunpkgが存在します。これらが存在するとき、mainフィールドの代わりに利用されます。
ほかにもここ (stereobooster/package.json)にいろいろ載ってます。
課題点
この通り、複数のエントリーポイントが存在しているのですが、ブラウザ用のESMのコードはどれに指定すればよいのか、ブラウザ用のUMDとESMのコードを両方指定するにはどうするべきなのか、などが発生していました。
ブラウザ用のESMのコードはbrowserとmoduleフィールドを以下のように組み合わせることで実現できはすることが2018/01にWebpackのissue (browser vs module fields in package.json (webpack/webpack#4674))で示されています。
{
"module": "dist/index.esm.js",
"browser": {
"./dist/index.js": "./dist/index.browser.js",
"./dist/index.esm.js": "./dist/index.browser.esm.js"
}
}
しかし、browserフィールドが2012年、moduleフィールドが2014年に提案されていて、このissueは2017年に建てられ上記の回答が2018年についていることを考えると、その期間では混沌が発生していたでしょうし、今もこのプラクティスが周知されていません(少なくとも検索で全くヒットしない)。
その結果として、moduleフィールドにNode.js向けのESMのファイルを指定し、browserフィールドにブラウザ向けのESMのファイルを指定する方法をとっているパッケージや、moduleフィールドにブラウザ向けのESMのファイルを指定してbrowserフィールドにブラウザ向けのUMDのファイルを指定する方法をとっているパッケージが存在します。
So... some packages are using
modulefield to point to Node.js ESM entry, withbrowserpointing to browser ESM... and some packages pointsmoduleto browser ESM, andbrowserto UMD (thus no tree-shaking)...How is the bundler supposed to know? 😅
— Evan You, 2021/01/11. https://twitter.com/youyuxi/status/1348413401299562497
exportsフィールド
この状況を改善したNode.jsの仕様がexportsフィールドです。
これは2018/11に提案され、この仕様のうちこれから話すconditional exportsは2019/11にNode.js 13.2.0で実装されました。
この仕様にはconditional exportsという機能があります。これは条件によって別々のファイルに解決するという機能です。
このようにモジュール形式によって分けたり
{
"exports": {
".": {
"require": "./index.cjs.cjs",
"import": "./index.esm.mjs"
}
}
}
実行環境によって分けたり
{
"exports": {
".": {
"deno": "./index.deno.js",
"browser": "./index.browser.js",
"node": "./index.node.js"
}
}
}
できます。
さらには、実行環境とモジュール形式の両方の条件を指定することも可能です。例えば、この場合でブラウザ用のESMのファイルを解決する場合はbrowserの中のimportに指定されているパス (./index.browser.esm.mjs) になります。
{
"exports": {
".": {
"browser": {
"require": "./index.browser.cjs.cjs",
"import": "./index.browser.esm.mjs"
},
"node": {
"require": "./index.node.cjs.cjs",
"import": "./index.node.esm.mjs"
}
}
}
}
このexportsフィールドは、以下のバージョンでサポートされています。
- Vite: 2.0.0-alpha.1+ (2020/12)
- Webpack: 5.0.0-beta.18+ (2020/06) (4以下は対応していません)
- esbuild: 0.9.0+ (2021/03)
- rollup (
@rollup/plugin-node-resolve): 11.0.0+ (2020/12) - parcel: 未対応 (parcel-bundler/parcel#4155)
exportsフィールドの課題
これで無事解決、と言いたいところですが、このexportsフィールドにも課題が存在します。
exportsフィールドをNode.jsで利用する際に発生するdual package hazardと同様のことが発生します。このdual package hazardに関して説明するのは結構長くなるので、詳しい話は以下の記事/ドキュメントを参照してください。
一言でいえば、インポートの形式によって異なるファイルに解決されるので、変数の状態が共有されない、ということが発生するという問題です。
Webpackやrollupではインポート元の形式によらず常にmoduleフィールドを優先して選択していました。このため、CJSからインポートした場合でもESMのファイルが解決されていました。これと同じ方法をexportsフィールドでも行えばdual package hazardは発生しません。また、同じパッケージが複数回バンドルに含まれないため、ファイルサイズの点も有利です。
しかし、これはNode.jsとは異なる挙動を行うため、バンドル前とバンドル後の挙動が変わることを意味します。
この話は2020/04~07にこのあたりで議論されていました (Add support for pkg.exports.module or similar (webpack/webpack#11014), Support Dual CommonJS/ES Module Packages with isolated stat (rollup/rollup#3514))。
話の結論としては、exportsフィールドにmodule条件を導入することになっています。Webpackではmodule条件は常に利用されるようになっています(参考) (exportsフィールドは前にあるものを優先するので、正確にはmodule条件がimport条件などよりも前に指定されている必要がある)。存在しないときはNode.jsと同じように、importで呼び出されていたらimport条件、requireで呼び出されていたらrequire条件を利用します。
rollupでも同じようになっています(参考)。
最後に
このように今の現状は歴史的理由で複雑な挙動になっています。それ故、設定が難しく、異なるものをフィールドに指定していたり、特定のパッケージから特定のパッケージを読み込むと動かないということが発生したりします。すべてESMだけになれば、ある程度わかりやすくなるので、すべてがESMになる世界が早めに到来することを祈っています。