dackdive's blog

新米webエンジニアによる技術ブログ。JavaScript(React), Salesforce, Python など

複数のモジュール形式(CommonJS, ES Modules, UMD)をサポートしたnpmパッケージの作り方 in TypeScript

はじめに

npmパッケージを開発するとき、パッケージ利用者の実行環境に合わせて適切なモジュール形式のファイルをパッケージに含め、提供する必要があります。
具体的には、たとえば以下のようなバリエーションが考えられます。

  • Node.js環境であれば CommonJS 形式 (module.exports / require() )
  • ブラウザ環境で、webpackやRollupなどのモジュールバンドラーを前提とするならば CommonJSES Modules 形式 (export / import )
  • ブラウザ環境で、モジュールバンドラーなどは使わず<script>タグでファイルを読み込んで利用するならば UMD 形式

このとき、パッケージ提供側はどういったファイルをパッケージに含めるべきなのか、またそれを TypeScript でどのように実現できるのかがあまりよくわかっていなかったので、整理しました。

注:今回想定していない環境

目次



最初にまとめ

最終的なディレクトリ構成は以下のようになりました。

f:id:dackdive:20190923032928p:plain

また、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"
  ],
  // ...
}

詳しくはGitHubにサンプルリポジトリを作りました。


1. CommonJS 形式

CommonJSはNode.js環境で採用されているモジュール形式であることと、ブラウザ環境でもwebpackなどのモジュールバンドラーやTypeScriptがこの形式をサポートしていることから、
とりあえずCommonJS形式でパッケージを提供しておけばNode.js環境とブラウザ環境を両方サポートできます。


tsconfig.json

TypeScriptでCommonJS形式でコンパイルする場合、tsconfig.jsonmoduleプロパティに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.jsonextendsというプロパティがあり、これを使うとある設定ファイルの内容を継承して一部だけ上書きできます。
モジュール形式によらないプロパティはベースの設定ファイルに記述し、モジュール形式によって差が出るプロパティはベースを継承した別の設定ファイルを定義するのが良いかと思います。

// 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 a module 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に書くとReactReactDOMグローバル変数に定義され、使えるようになるため、利用者側はモジュールバンドラーを使った環境を構築する必要がありません。
オンラインエディターなどでも簡単に試すことができます。

これを実現するためのモジュール形式が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"]
  }
};

libraryTargetumdを指定することで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
にもあるように、moduleESNextを指定していないと、ソースファイル内に使用していないexportが混ざっていてもTree Shakingが機能せず、バンドルファイルに含まれることになります。
(これはアプリケーションを開発する際は重要ですが、npmパッケージとして提供したいようなライブラリの場合は使っていないexportは基本ないはず...)

また、ベースの設定ファイルでdeclaration: trueにして型定義ファイルを出力するようにしている場合、UMD用の設定では出力をOFF(declaration: false)にしておくのが良いと思います。


package.json

filesumdディレクトリを追加することと、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
f:id:dackdive:20190918030405p:plain:w400

(試していませんが) これを実現したい場合、webpackの
Configuration Types > Exporting multiple configurations
によると、webpack.config.jsで配列を返すようにすれば複数の設定でバンドルした結果を出力してくれます。
modeoutput.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

f:id:dackdive:20190918030605p:plain


4. (おまけ) 型定義ファイル(.d.ts)

モジュール形式とは別の話ですが、TypeScriptで開発したパッケージの場合、本体のスクリプトと一緒に型定義ファイルもパッケージに含めて提供してあげる必要があります。

必要なことは

  • tsconfig.jsondeclaration: trueを追加
  • package.jsontypesフィールドに出力した型定義ファイルのエントリーポイントを指定

の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"
}

となります。

参考:https://www.typescriptlang.org/docs/handbook/declaration-files/publishing.html#including-declarations-in-your-npm-package

※サンプルリポジトリではlib/にもesm/にも型定義ファイルを出力するようになっていますが、どちらかだけで十分のはず


余談

上に書きませんでしたが、調べている過程で見つけたTips的な情報を。


package.jsonfilesフィールドは必ず必要なのか

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.jsonunpkgフィールドとは

UMD形式をサポートしているパッケージをいくつか調べたところ、package.jsonunpkgというフィールドを指定しているパッケージがありました。

これについては unpkg.com のサイトを確認したところ

https://unpkg.com/#examples

If you omit the file path (i.e. use a “bare” URL), unpkg will serve the file specified by the unpkg field in package.json, or fall back to main.

とあるので、UMD形式で出力したファイルのパスを指定しておけば、利用者は

https://unpkg.com/<package name>@<version>

というURLでパッケージを読み込めるというわけですね。
(あんましなさそうですが)


webpack.config.jsのexternalsオプションとは

これもUMD形式をサポートしているパッケージでよく見かけました。

参考:

// 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/

によると、このオプションで指定したモジュールはバンドルしたファイルに含まれなくなり、パッケージ利用者側が別途モジュールを読み込む必要があります。


リファレンス

モジュール形式については以下が参考になりました。

参考にしたライブラリ