npm パッケージは、Node.js アプリによって使用されるだけでなく、モジュールバンドラがバンドルを行う際に使用されたり、CDN 経由でブラウザから使用されたりする。
この記事ではそれぞれのケースにおいて、npm パッケージとして配布されるファイルのうち、どのファイルがどのように利用されるのか見ていく。
これを理解していないと、npm パッケージを公開する際に何をどのように配布するべきなのかも分からない。
動作確認に使った各環境のバージョンは、以下の通り。
- Node.js
14.7.0
- npm
6.14.7
- Google Chrome
84.0.4147.125
また、モジュールバンドラの例として webpack を使った検証を行っているが、そのバージョンは以下の通り。
- webpack
4.44.1
- webpack-cli
3.3.12
型定義ファイルについては以下の記事で説明したので、本記事では割愛する。
検証環境の準備
Yarn のワークスペースを使って、環境を作る。
ワークスペースについても、以前書いた。
今回検証したいのは「npm パッケージがどのように利用されるのか」なので、パッケージと、それを利用するアプリを用意する。
パッケージの名前はmy-package
、アプリの名前はmy-app
とする。
まず、ルートディレクトリに以下の内容のpackage.json
を作る。
{ "private": true, "workspaces": ["my-package"] }
これで、my-package
がワークスペースの対象になった。
早速、以下の内容のmy-package/package.json
とmy-package/main.js
を作る。
{ "name": "my-package", "version": "1.0.0", "main": "./main.js" }
module.exports = { x: 1, }
続いて、以下の内容のmy-app/package.json
を作る。
{ "name": "my-app", "dependencies": { "my-package": "1.0.0" } }
そしてルートディレクトリで$ yarn
を実行すると、以下のような構成になる。
├── my-app │ ├── index.js │ └── package.json ├── my-package │ ├── main.js │ └── package.json ├── node_modules │ └── my-package -> ../my-package ├── package.json └── yarn.lock
これで、my-app
からmy-package
を利用できるようになった。
Node.js から使用する
以下のmy-app/index.js
を用意する。
const Package = require('my-package'); console.log(Package);
そして$ node my-app/index.js
を実行すると、{ x: 1 }
が表示される。
npm パッケージをrequire
すると、そのパッケージのpackage.json
のmain
フィールドで指定されているファイルを、読み込む。
今回の例ではmy-package/package.json
のmain
フィールドに./main.js
を指定したので、my-package/main.js
が読み込まれた。
また、require
ではなくimport
で読み込むこともできる。
まず、my-app/package.json
に"type": "module"
を追加して、アプリ側のモジュールシステムを ES Modules(以下、ESM)にする。
その上でmy-app/index.js
を以下の内容にして、実行する。
import Package from 'my-package'; console.log(Package);
この場合も、{ x: 1 }
が表示される。
この状態で今度は、my-package/package.json
にも"type": "module"
を追加し、my-package/main.js
を以下のように書き換える。
export default 9;
そして$ node my-app/index.js
を実行すると、9
が表示される。
これは、ESM で書かれた書かれた npm パッケージを、同じく ESM で書かれたアプリがインポートした形になる。
Node.js におけるモジュールシステムやpackage.json
のtype
フィールドについては、以下の記事に書いた。
この記事では、ESM と CommonJS(以下、CJS) の互換性についても書いたが、npm パッケージにおいてもこのルールは変わらない。
例えば、CJS で書かれたモジュールを ESM でimport
する場合、名前付きインポートはできない。
つまり、package.json
のmain
フィールドで指定されているファイルが読み込まれる、ということ以外は、npm パッケージもそれ以外のファイルも同じように扱われる。
モジュールバンドラから使用する
フロントエンドで使用するコードの場合、モジュールバンドラで複数のファイルを結合することが多い。
その場合、npm パッケージを含む依存関係の解決も、バンドル時に行われる。
このときの挙動は、Node.js アプリから npm パッケージを使用するときのそれとは、異なる。
例えば、必ずしもmain
フィールドで指定されているファイルが読み込まれるわけではない。
モジュールバンドラとして webpack を使い、確認していく。
まず、ルートディレクトリで以下を実行して、必要なライブラリをインストールする。
$ yarn add -D -W webpack webpack-cli
そして、ルートディレクトリにwebpack.config.js
を用意する。
const path = require('path'); module.exports = () => { return { entry: { index: './my-app/index.js', }, output: { path: path.resolve(__dirname, 'dist'), }, resolve: { mainFields: ['module', 'main'] }, } };
この状態で$ yarn run webpack --mode=production
を実行すると、my-app/index.js
をエントリポイントとしてビルドが行われ、dist/index.js
が生成される。
my-app/index.js
は先程と同様に以下の内容にしておく。
import Package from 'my-package'; console.log(Package);
my-package
側に、以下のファイルを用意する。
// my-package/main.js module.exports = { x: 1, }
// my-package/module.js module.exports = { x: 'a', }
そして、my-package/package.json
を、以下の内容にする。
{ "name": "my-package", "version": "1.0.0", "main": "./main.js", "module": "./module.js" }
この状態でビルドしたdist/index.js
を実行してみる。
$ node dist/index.js { x: 'a' }
{ x: 'a' }
が出力されているので、module
フィールドで指定したファイルが読み込まれていることになる。
次に、my-package/package.json
からmodule
フィールドを削除したうえで、ビルドして実行してみる。
$ node dist/index.js { x: 1 }
そうすると、main
フィールドで指定したファイルが読み込まれている。
これは、webpack.config.js
のresolve.mainFields
で指定した順番に、package.json
のフィールドを探していくため。
['module', 'main']
とすると、まずmodule
フィールドを探し、それが見つからなければmain
フィールドを探す。
列挙されたフィールド名がひとつもpackage.json
になかった場合、ビルドに失敗する。
resolve.mainFields
にはデフォルト値が設定されており、webpack.config.js
のtarget
の値によって決まる。
target
がwebworker
、web
、もしくは未指定の場合は、resolve.mainFields
のデフォルト値は['browser', 'module', 'main']
になる。
target
がそれ以外の場合は['module', 'main']
になる。
ちなみに、webpack 経由で npm パッケージを利用する場合、互換性に関する挙動も変化する。
例えば、ESM から CJS を読み込む場合、Node.js では名前付きインポートができないのだが、webpack で依存関係を解決する場合は可能になる。
// my-package // module.exports = { // x: 1, // } // 名前付きインポートがエラーにならない import {x} from 'my-package'; console.log(x); // 1
CDN からの利用
npm パッケージは、CDN サービスを経由してブラウザで使うこともできる。
例えば、UNPKGという CDN サービスの場合、公式サイトに以下のように書かれており、全ての npm パッケージが配信の対象となっている。
unpkg is a fast, global content delivery network for everything on npm.
具体的には、https://unpkg.com/:package@:version/:file
という URL で、npm パッケージの中身が配布されている。
また、https://unpkg.com/:package@:version/
にアクセスすると、配信されているファイルの一覧を見ることができる。
例えば、拙作のken-all
の場合、https://unpkg.com/browse/ken-all@0.2.1/ で、v0.2.1
におけるファイルの一覧を見ることができる。
このうち、umd/index.js
はブラウザで使用されることを想定してビルドしたファイルなので、以下のようにブラウザから読み込んで使用することができる。
<!DOCTYPE html> <html lang="ja"> <head> <meta charset="UTF-8"> <title>Ken All Sample</title> </head> <body> <input id="post-code" maxlength="7"> <p id="result"></p> <script src="https://unpkg.com/ken-all@0.2.1/umd/index.js"></script> <script> const searchBoxElem = document.querySelector('#post-code'); searchBoxElem.addEventListener('input', e => { const postCode = e.currentTarget.value; if (postCode.length === 7) { const resultTextElem = document.querySelector('#result'); KenAll.default(postCode).then(res => { if (res.length === 0) { resultTextElem.textContent = '該当する住所はありません'; } else { resultTextElem.textContent = res[0].join(' '); } }); } }, false); </script> </body> </html>
npm パッケージの中身がそのまま配布されているだけなので、ブラウザで使われることを想定したファイルでない場合、上手く動かない可能性がある。
例えば、https://unpkg.com/ken-all@0.2.1/dist/index.js
をブラウザで読み込むと、ReferenceError: exports is not defined
というエラーが出る。
バージョンの指定については、セマンティックバージョニングの範囲指定を行うこともできる。
例えば記事執筆時点でのken-all
の場合、^0.2.0
を指定すると、0.2.1
にリダイレクトされる。
$ curl -I https://unpkg.com/ken-all@^0.2.0/umd/index.js HTTP/2 302 (省略) location: /ken-all@0.2.1/umd/index.js