この記事は Node.js その2 Advent Calendar 2020 の 2 日目の記事です。投稿が大幅に遅れて申し訳ございません。
Node.js v15.3.0 から ES Modules が experimental から stable になりました 🎉
今年はモジュール周りも大きく飛躍しました。個人的に仕事で探求していたこともあってモジュール周りを追うことが多かったです。
ES Modules を使って import できる npm パッケージも増えてきています。
そこで今回はモジュール関連で追加された package.json のフィールドについてまとめて紹介します。
以前会社のブログに書いた内容と重複する部分もありますが、そこでは紹介できなかった機能も紹介したいと思います。
また、この記事の内容は Node.js の公式ドキュメント Module: Packages を読めば、すべて書いている内容です。
今回紹介する機能は Node.js v12.19 または v14.13.0 から利用可能です。
"exports"
と "imports"
フィールドと関連する機能について以下の内容を紹介します。
- exports によるパスの指定
- imports によるモジュール名とファイルのマッピング
- --conditions による実行時の条件変更
- Self-referencing による自パッケージのロード
- まとめ
exports によるパスの指定
"exports"
はパッケージのエントリポイントのファイルやサブパスが指定されたときに読み込まれるファイルを定義することができます。次のようにサブパスを複数指定することができます。
// package.json { "name": "my-mod", "exports": { ".": "./path/to/index.js", "./lib": "./path/to/lib/index.js" } }
"exports"
で定義することで、次のように import することができます。
// node_modules/my-mod/path/to/lib/index.js を読みこむ import { somefunc } from "my-mod/lib";
この "exports"
には glob パターンのように *
で指定することでマッチングするファイルを読み込み可能にすることもできます。
// package.json { "name": "my-mod", "exports": { ".": "./path/to/index.js", "./lib": "./path/to/lib/index.js", "./lib/*": "./path/to/lib/*.js", "./package.json": "./package.json" } }
例えば、path/to/lib/string_util.js
というファイルがあったとします。この場合次のように import することができます。
import { somefunc } from "my-mod/lib/string_util";
また、 "."
で指定したファイルはパッケージのエントリポイントとなり、 "main"
より優先されます。
// node_modules/path/to/index.js を読みこむ import { somefunc } from "my-mod";
たとえサブパスを定義しないとしても、 "main"
と "exports"
両方定義しておくことが推奨されています。
// package.json { "main": "./main.js", "exports": "./main.js" }
**"exports"
を定義することで、パッケージのサブパスはすべて隠蔽されます。**"exports"
に記載のないパスを指定して import しようとすると、ERR_PACKAGE_PATH_NOT_EXPORTED
エラーが発生するようになります。これはユーザーに本来使われることを意図していないファイルを隠蔽するのに役立ちます。
Conditional Exports による条件に応じたロード
"exports"
は条件に応じて実行ファイルを切り替える機能も備えています。たとえば、次のように定義している場合、Node.js とブラウザとそれ以外で実行するファイルを変更できます。
// package.json { "name": "my-mod", "exports": { "./feature": { "node": "./feature-node.js", "browser": "./feature-browser.js", "default": "./feature.js" } } }
上記の定義がされている npm パッケージを利用する以下のコードがあります。
import { somefunc } from "my-mod/feature";
このコードが実行される環境に応じて、実行されるパッケージ内のファイルは以下のように変わります。
- Node.js で実行する場合...
feature-node.js
が実行される - ブラウザで実行する場合...
feature-browser.js
が実行される - その他の実行環境(Electron など)で実行する場合...
feature.js
が実行される
このように実行環境に応じてファイルを切り替えられるようにすることで、1 つのパッケージで複数の実行環境をサポートすることができます。
ただし、パッケージを読み込む実行環境がこの Conditional Exports に対応している必要があります。ブラウザ自体は対応していませんが、webpack や Rollup は対応しているのでバンドルするときには "browser"
に定義されたファイルがバンドルされます。
Conditional Exports は ES Modules と CommonJS の両方をサポートする npm パッケージの開発にも便利です。以前会社のブログに書きましたので、そちらをお読みください。
Node.js Dual Packages (CommonJS/ES Modules) に対応した npm パッケージの開発 - Cybozu Inside Out | サイボウズエンジニアのブログ
imports によるモジュール名とファイルのマッピング
"imports"
はパッケージを使う人が利用するフィールドです。"imports"
を利用すれば、import するモジュールを任意の名称にマッピングできます。
Web でいうところの import-maps に似た機能です。
次のように使いたいモジュールを #
からはじまる名前にマッピングすることで、import 文の短縮ができます。
// package.json { "imports": { "#math": "./path/to/lib/utils/math.js" } }
import する JS には次のように書くことができます。
// path/to/lib/utils/math.js を読み込む import { rand } from "#math";
これも "exports"
と同じく、*
を使ったパターンマッチングによる指定ができます。
// package.json { "imports": { "#utils/*": "./path/to/lib/utils/*.js" } }
// path/to/lib/utils/math.js を読み込む import { rand } from "#utils/math";
"imports"
に関しても "exports"
同様に条件に応じて実行するファイルを切り替える機能を備えています。
次のように定義することで import する実行環境に応じて実行するファイルを切り替えるができます。
// package.json { "imports": { "#dep": { "node": "dep-node-native", "default": "./dep-polyfill.js" } }, "dependencies": { "dep-node-native": "^1.0.0" } }
import するコードは次のように同じでも実行する環境によって実行するファイルが変わります。
import { somefunc } from "#dep";
このコードを実行するとき次のように実行環境に応じて実行するファイルが切り替わります。
- Node.js で実行する場合...
dep-node-native
パッケージが実行される - Node.js 以外の実行環境で実行される場合...
dep-polyfill.js
が実行される
この機能を使えば、アプリケーションやパッケージを開発するときに、import する側のコードからは Node.js やブラウザを意識せずに import することができます。Node.js とブラウザの両方をサポートしている isomorphic な npm パッケージの開発にとても便利です。
しかし、この機能も Conditional Exports と同じように実行環境が対応していないと使えません。現在は webpack や Rollup など module bundler は対応していません。
--conditions による実行時の条件変更
紹介した "exports"
や "imports"
の条件は実行環境による切り替えやモジュールシステムによる切り替えだけでなく、ユーザーの開発時にも応用できます。
たとえば、開発時と本番で実行するファイルを変更したい場合は以下のように定義することができます。
{ "imports": { "#log": { "development": "./path/to/log-dev.js", "default": "./path/to/log.js" } } }
import するコードは次のように log-dev.js
か log.js
かを意識する使うことができます。
// index.js import log from "#log";
実行時に Node.js の実行オプション --conditions
を使えば import するファイルを切り替えることができます。たとえば開発時は "developmen"
で定義された log-dev.js
を使いたい場合は次のように --conditions
オプションを用いて実行します。
node --conditions=development index.js
"exports"
でも同様のことができます。次のように定義しておくとパッケージのユーザーの指定によりファイルを切り替えることができます。
// package.json { "name": "my-mod", "exports": { ".": { "node": { "development": "./dev.js", "default": "./index.js" } } } }
パッケージを使うユーザーは次のように import して使います。
// index.js import { somefunc } from "my-mod";
実行時に --conditions
オプションを指定すればパッケージ内の dev.js
を実行します。
node --conditions=development index.js
Self-referencing による自パッケージのロード
"exports"
の定義はパッケージを使うユーザーだけでなく、パッケージ自身でも利用することができます。
たとえば、次のように定義されたパッケージがあったとします。
// package.json { "name": "my-mod", "exports": { ".": "./path/to/index.js", "./lib/*": "./path/to/lib/*.js" } }
次に my-mod
パッケージ内のファイルから次のように自身を import して使うことができます。
// たとえば、my-mod/another-path/to/util.js から読み込んでいるとする // path/to/index.js を読み込む import { somefunc } from "my-mod"; // path/to/lib/log.js を読み込む import log from "my-mod/lib/log";
このようにユーザーと同じように import することができるので、テストで使ったりサンプルコードなどで使えば実際のユーザーの使用に近いコードを書くことができます。
まとめ
今年はついに Node.js でも ES Modules が本格的に使える年になり、それに付随する形でモジュール周りに様々な機能が追加されました。主要な npm パッケージはすでに "exports"
フィールドを使って ES Modules 形式でも配信を始めています。今後も今日紹介した機能を用いた ES Modules に対応した npm パッケージが増えていくと予想されます。
モジュール周りではまだ Experimental な機能があります。ニーズに応じて新機能も追加されていくでしょう。来年もモジュール周りが進化していくと思います。個人的には JSON modules なんかは WHATWG の仕様や import assertion なども含めてどうなるのか楽しみです。
Node.js のモジュール関連については以下のリポジトリでモジュールチームが議論をしていたりします。もし興味があればウォッチしてみてください。
最後までお読みいただきありがとうございました。不備や質問は @shisama_までお願いします。