この記事は夏のブログリレー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
module
field to point to Node.js ESM entry, withbrowser
pointing to browser ESM... and some packages pointsmodule
to browser ESM, andbrowser
to 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になる世界が早めに到来することを祈っています。