はじめに
npmパッケージを開発するとき、パッケージ利用者の実行環境に合わせて適切なモジュール形式のファイルをパッケージに含め、提供する必要があります。
具体的には、たとえば以下のようなバリエーションが考えられます。
- Node.js環境であれば CommonJS 形式 (
module.exports
/require()
) - ブラウザ環境で、webpackやRollupなどのモジュールバンドラーを前提とするならば CommonJS や ES Modules 形式 (
export
/import
) - ブラウザ環境で、モジュールバンドラーなどは使わず
<script>
タグでファイルを読み込んで利用するならば UMD 形式
このとき、パッケージ提供側はどういったファイルをパッケージに含めるべきなのか、またそれを TypeScript でどのように実現できるのかがあまりよくわかっていなかったので、整理しました。
注:今回想定していない環境
- AMD (Asynchronous Module Definition) 形式
- Node.js環境でES Modules形式でファイルを扱う
- ブラウザ環境でES Modules形式のファイルをそのまま扱う
<script type="module">
目次
最初にまとめ
最終的なディレクトリ構成は以下のようになりました。
また、package.jsonには以下main
, module
, types
, files
フィールドを指定します。
// package.json { "main": "lib/index.js", "module": "esm/index.js", "types": "lib/index.d.js", "files": [ "lib", "esm", "umd" ], // ... }
1. CommonJS 形式
CommonJSはNode.js環境で採用されているモジュール形式であることと、ブラウザ環境でもwebpackなどのモジュールバンドラーやTypeScriptがこの形式をサポートしていることから、
とりあえずCommonJS形式でパッケージを提供しておけばNode.js環境とブラウザ環境を両方サポートできます。
tsconfig.json
TypeScriptでCommonJS形式でコンパイルする場合、tsconfig.jsonのmodule
プロパティにcommonjs
を指定します。
また、出力先のディレクトリ名は任意ですが、ここではlib
としてoutDir
プロパティに指定しておきます。
// tsconfig.json { "compilerOptions": { "module": "commonjs", "outDir": "./lib" // ... } }
こうすると、ソースファイルを格納しているディレクトリ(ここではsrc
)と同じ構成のまま、
TypeScript → JavaScript にコンパイルした結果がlib
ディレクトリに出力されます。
- src/ - index.ts - moduleA.ts - moduleB.ts - lib/ - index.js - moduleA.js - moduleB.js
package.json
package.jsonでは、main
フィールドにパッケージのエントリーポイントとなるファイルのパスを、files
に出力先ディレクトリlib
を記載します。
// package.json { "main": "lib/index.js", "files": [ "lib" ] }
2. ES Modules 形式
webpack(v2以降)やRollupなどのモジュールバンドラーには、Tree Shakingという機能が備わっています。
これは、export
しているがどこからもimport
されていない不要なモジュールをバンドル時に削除する機能です。
参考:webpackの公式ドキュメント内でTree Shakingについて説明されているページ
パッケージ提供側が多数のモジュールを提供している場合、ES Modules形式つまり import
/export
で書かれたファイルをCommonJS形式に変換せずそのまま提供することで、利用者側はTree Shakingの恩恵を受けることができます。
tsconfig.json
TypeScriptでこれを実現する場合、CommonJSと同様にmodule
プロパティを使用します。
先ほどはcommonjs
という値を指定しましたが、今度はESNext
を指定します。
また、先ほどのCommonJS形式のファイルと出力先のディレクトリを分けるため、outDir: "./esm"
も指定します。同じくディレクトリ名は任意です。
// tsconfig.json { "compilerOptions": { "module": "ESNext", "outDir": "./esm" // ... } }
こうすることで、CommonJS形式と同じくsrc
下と同じ構成のファイル群のJSがesm
に生成されます。
- src/ - index.ts - moduleA.ts - moduleB.ts - lib/ # 中身はCommonJS( module.exports / require ) - index.js - moduleA.js - moduleB.js - esm/ # 中身はES Modules( export / import ) - index.js - moduleA.js - moduleB.js
NOTE: tsconfig.jsonの構成をどのようにすべきか
このように、同じTypeScriptのソースファイルから複数の設定でコンパイルする場合、プロパティの異なる複数のtsconfig.jsonが必要になります。
tsconfig.jsonはextends
というプロパティがあり、これを使うとある設定ファイルの内容を継承して一部だけ上書きできます。
モジュール形式によらないプロパティはベースの設定ファイルに記述し、モジュール形式によって差が出るプロパティはベースを継承した別の設定ファイルを定義するのが良いかと思います。
// tsconfig.base.json { "compilerOptions": { "target": "es5", "strict": true, // ... } }
// tsconfig.esm.json { "extends": "./tsconfig.base", "compilerOptions": { "module": "ESNext", "outDir": "./esm" } }
また、コンパイル時は-p
または--project
オプションで使用する設定ファイルを指定できるので、これをpackage.jsonのscriptsに記載しておくと良いでしょう。
// package.json { "scripts": { "build": "npm run build:cjs && npm run build:esm", // CommonJS用 "build:cjs": "tsc -p tsconfig.cjs.json", // ES Modules 用 "build:esm": "tsc -p tsconfig.esm.json" } }
詳しくはサンプルリポジトリを参考にしてください。
package.json
package.jsonには、CommonJS形式のときと同じくfiles
フィールドに出力したディレクトリ(esm
)を追加するほか、module
フィールドでesm
内のエントリーポイントを指定します。
// package.json { "main": "lib/index.js", "module": "esm/index.js", "files": [ "lib", "esm" ] }
module
フィールドについて、公式ドキュメントには載っていませんでしたが、以下のStack Overflowが見つかりました。
Node.jsでES Modulesを扱うためのProposalということでしょうか...(あんまりわかってない)
また、Rollupのドキュメントには以下の記述があります。
https://rollupjs.org/guide/en/#publishing-es-modules
If your
package.json
file also has amodule
field, ESM-aware tools like Rollup and webpack 2+ will import the ES module version directly.
つまり、モジュールバンドラーはこのフィールドで指定したパスのファイルをES Modules形式で扱うようです。
3. UMD
Reactなどのメジャーなライブラリでは、インストール方法として npm install
だけではなく以下のようにCDNで利用できる形式をサポートしています。
https://reactjs.org/docs/cdn-links.html
<script crossorigin src="https://unpkg.com/react@16/umd/react.development.js"></script> <script crossorigin src="https://unpkg.com/react-dom@16/umd/react-dom.development.js"></script>
これをHTMLに書くとReact
やReactDOM
がグローバル変数に定義され、使えるようになるため、利用者側はモジュールバンドラーを使った環境を構築する必要がありません。
オンラインエディターなどでも簡単に試すことができます。
これを実現するためのモジュール形式がUMDです。
webpack.config.js
パッケージをUMD形式で配布するためには最終的にファイルを1つにバンドルする必要があり、これはTypeScript単体では完結しません。何らかのモジュールバンドラーを利用する必要があります。
ここではwebpackを例にします。
webpackでUMD形式のファイルを作成するには、output.library
およびoutput.libraryTarget
フィールドを使用します。
https://webpack.js.org/configuration/output/#outputlibrary
https://webpack.js.org/configuration/output/#outputlibrarytarget
// webpack.config.js const path = require("path"); module.exports = { mode: "production", entry: "./src/index.ts", output: { path: path.resolve(__dirname, "umd"), filename: "my-typescript-package.js", library: "MyTsPackage", libraryTarget: "umd" }, module: { rules: [ { test: /\.ts(x*)?$/, exclude: /node_modules/, use: { loader: "ts-loader", options: { configFile: "tsconfig.umd.json" } } } ] }, resolve: { extensions: [".ts", ".tsx", ".js"] } };
libraryTarget
にumd
を指定することでUMD形式でのバンドルを行います。またバンドルしたファイルを読み込んだときにlibrary
に指定したグローバル変数名でアクセスできるようになります。
なお、webpackを使用する場合、TypeScriptもwebpack内で使用するためts-loaderが必要となります。
options.configFile
は設定ファイルがtsconfig.json
という名前の場合は不要です。
tsconfig.json
tsconfig.json側は特別な設定は必要ありません。出力先のディレクトリも今回はwebpack.config.js側で指定しているため不要です。
ただ、
最新版TypeScript+webpack 4の環境構築まとめ(React, Vue.js, Three.jsのサンプル付き) - ICS MEDIA
にもあるように、module
にESNext
を指定していないと、ソースファイル内に使用していないexport
が混ざっていてもTree Shakingが機能せず、バンドルファイルに含まれることになります。
(これはアプリケーションを開発する際は重要ですが、npmパッケージとして提供したいようなライブラリの場合は使っていないexport
は基本ないはず...)
また、ベースの設定ファイルでdeclaration: true
にして型定義ファイルを出力するようにしている場合、UMD用の設定では出力をOFF(declaration: false
)にしておくのが良いと思います。
package.json
files
にumd
ディレクトリを追加することと、UMDビルド用のスクリプトを追加すること以外は特にありません。
"scripts": { - "build": "npm run build:cjs && npm run build:esm", + "build": "npm run build:cjs && npm run build:esm && npm run build:umd", "build:cjs": "tsc -p tsconfig.cjs.json", "build:esm": "tsc -p tsconfig.esm.json", + "build:umd": "webpack", "test": "echo \"Error: no test specified\" && exit 1" }, "files": [ "lib", - "esm" + "esm", + "umd" ],
ここまでで、CommonJS、ES Modules、UMDのファイルが以下のような構成で出力されるようになっています。
- src/ - index.ts - moduleA.ts - moduleB.ts - lib/ # 中身はCommonJS( module.exports / require ) - index.js - moduleA.js - moduleB.js - esm/ # 中身はES Modules( export / import ) - index.js - moduleA.js - moduleB.js - umd/ # UMD - my-typescript-package.js
NOTE: development用とproduction用の2つのバンドルを用意する場合は?
先ほど例として挙げたReactもそうですが、UMD形式に対応しているライブラリはdevelopment用 (foo.development.js
)とproduction用(foo.production.js
)の2種類を提供していることがあります。
https://reactjs.org/docs/cdn-links.html
(試していませんが) これを実現したい場合、webpackの
Configuration Types > Exporting multiple configurations
によると、webpack.config.jsで配列を返すようにすれば複数の設定でバンドルした結果を出力してくれます。
mode
やoutput.filename
だけ異なる2つの設定を用意し、まとめてumd/
以下に出力すれば良いでしょう。
NOTE: 出力先のディレクトリ名について
UMD形式についてもディレクトリ名は任意です。
が、umd
またはdist
という名前にしておくと、unpkg.comというCDNがnpm publish時に自動的に認識してくれ、publishしたパッケージが
https://unpkg.com/<package name>@<version>/<`umd` or `dist`>/<file name>
というURLで利用できるようになるので、この慣習にならっておくのがおすすめです。
参考:https://unpkg.com/#workflow
4. (おまけ) 型定義ファイル(.d.ts
)
モジュール形式とは別の話ですが、TypeScriptで開発したパッケージの場合、本体のスクリプトと一緒に型定義ファイルもパッケージに含めて提供してあげる必要があります。
必要なことは
の2点です。
後者は、たとえばlib/
ディレクトリ以下に
- src/ - index.ts - moduleA.ts - moduleB.ts - lib/ - index.js - index.d.ts - moduleA.js - moduleA.d.ts - moduleB.js - moduleB.d.ts
というように型定義ファイルを出力するようにしている場合、
// package.json { "main": "lib/index.js", "types": "lib/index.d.js" }
となります。
※サンプルリポジトリではlib/
にもesm/
にも型定義ファイルを出力するようになっていますが、どちらかだけで十分のはず
余談
上に書きませんでしたが、調べている過程で見つけたTips的な情報を。
package.jsonのfiles
フィールドは必ず必要なのか
files
はnpm publish時にパッケージに含めるファイルやディレクトリを指定するためのフィールドで、指定しなかった場合は全てのファイルがパッケージに含められます。
そのため、余計なファイルが含まれることを許容すればこのフィールドは指定しなくても良さそうに思えます。
が、 https://docs.npmjs.com/files/package.json#files を読むと
You can also provide a
.npmignore
file in the root of your package or in subdirectories, which will keep files from being included. (中略) If there is a.gitignore
file, and.npmignore
is missing,.gitignore
’s contents will be used instead.
とあります。つまり
.npmignore
に列挙したファイルやディレクトリはパッケージから除外される.npmignore
を置いていない場合、.gitignore
の内容が使われる
ということです。
今回のlib
のようにコンパイルしたファイルの出力先はgit commitしないよう.gitignore
に記載するのが一般的だと思われるので、files
の指定がないと意図せずパッケージにも含まれなくなってしまいます。
このような事故を避けるため、またパッケージの内容を明示的にするためにもfiles
は記載しておくのがおすすめです。
package.jsonのunpkg
フィールドとは
UMD形式をサポートしているパッケージをいくつか調べたところ、package.jsonにunpkg
というフィールドを指定しているパッケージがありました。
これについては unpkg.com のサイトを確認したところ
If you omit the file path (i.e. use a “bare” URL), unpkg will serve the file specified by the
unpkg
field inpackage.json
, or fall back tomain
.
とあるので、UMD形式で出力したファイルのパスを指定しておけば、利用者は
https://unpkg.com/<package name>@<version>
というURLでパッケージを読み込めるというわけですね。
(あんましなさそうですが)
webpack.config.jsのexternals
オプションとは
これもUMD形式をサポートしているパッケージでよく見かけました。
参考:
- Semantic-UI-React
- design-system-react
// webpack.config.js externals: { react: 'React', 'react-dom': 'ReactDOM', } // または externals: { react: { amd: 'react', commonjs: 'react', commonjs2: 'react', root: 'React', }, // ... }
これについてはwebpackの公式ドキュメント
https://webpack.js.org/configuration/externals/
によると、このオプションで指定したモジュールはバンドルしたファイルに含まれなくなり、パッケージ利用者側が別途モジュールを読み込む必要があります。
リファレンス
モジュール形式については以下が参考になりました。
- イマドキのnpmでは何を配布すべきか - Qiita
- "module"フィールド対応 · Webフロントエンド パフォーマンス改善ハンドブック
- npm で公開するブラウザー向けパッケージのファイル構成は Redux を参考にしよう | パークのソフトウエア開発者ブログ
- JavaScript モジュールの現状 | POSTD
参考にしたライブラリ
- React
- ビルドはRollupでやってるのでこのあたり https://github.com/facebook/react/tree/master/scripts/rollup
- Material-UI
- design-system-react
- SmartHR-UI
- React Bootstrap
- Semantic UI React
- Babel + webpack
- CommonJS, ESM, UMDをサポート
- ビルドはgulp: https://github.com/Semantic-Org/Semantic-UI-React/blob/master/gulp/tasks/dist.js