デュアルパッケージ開発者のためのtsconfig (Dual Package)
フロントエンドでもバックエンドでもTypeScriptこれ一本!Universal JSという考えがあります。確かにフロントエンドを動的にしたいのであればほぼ避けて通れないJavaScriptと、バックエンドでも使えるようになったJavaScriptで同じコードを使いまわせれば保守の観点でも異なる言語を触る必要がなくなり、統一言語としての価値が大いにあります。
しかしながらフロントエンドとバックエンドではJavaScriptのモジュール解決の方法が異なります。この差異のために同じTypeScriptのコードを別々に分けなければいけないかというとそうではありません。ひとつのモジュールをcommonjs, esmoduleの両方に対応した出力をするDual Packageという考えがあります。
Dual Packageことはじめ
名前が仰々しいですが、やることはcommonjs用のJavaScriptとesmodule用のJavaScriptを出力することです。つまり出力するmoduleの分だけtsconfig.jsonを用意します。
プロジェクトはおおよそ次のような構成になります。
text./ ├── tsconfig.base.json ├── tsconfig.cjs.json ├── tsconfig.esm.json └── tsconfig.json
text./ ├── tsconfig.base.json ├── tsconfig.cjs.json ├── tsconfig.esm.json └── tsconfig.json
- tsconfig.base.json
- 基本となるtsconfig.jsonです
- tsconfig.cjs.json
- tsconfig.base.jsonを継承した
commonjs用のtsconfig.jsonです
- tsconfig.base.jsonを継承した
- tsconfig.esm.json
- tsconfig.base.jsonを継承した
esmodule用のtsconfig.jsonです
- tsconfig.base.jsonを継承した
- tsconfig.json
- IDEはこの名前を優先して探すので、そのためのtsconfig.jsonです
tsconfig.base.jsonとtsconfig.jsonを分けるかどうかについては好みの範疇です。まとめてしまっても問題はありません。
tsconfig.jsonの継承
tsconfig.jsonは他のtsconfig.jsonを継承する機能があります。上記はtsconfig.cjs.json, tsconfig.esm.jsonは次のようにしてtsconfig.base.jsonを継承しています。
json// tsconfig.cjs.json{"extends": "./tsconfig.base.json","compilerOptions": {"module": "commonjs","outDir": "./dist/cjs"// ...}}
json// tsconfig.cjs.json{"extends": "./tsconfig.base.json","compilerOptions": {"module": "commonjs","outDir": "./dist/cjs"// ...}}
json// tsconfig.esm.json{"extends": "./tsconfig.base.json","compilerOptions": {"module": "esnext","outDir": "./dist/esm"// ...}}
json// tsconfig.esm.json{"extends": "./tsconfig.base.json","compilerOptions": {"module": "esnext","outDir": "./dist/esm"// ...}}
outDirはコンパイルしたjsと、型定義ファイルを出力していれば(後述)それを出力するディレクトリを変更するオプションです。
このようなtsconfig.xxx.jsonができていれば、あとは次のようにファイル指定してコンパイルをするだけです。
bashtsc -p tsconfig.cjs.jsontsc -p tsconfig.esm.json
bashtsc -p tsconfig.cjs.jsontsc -p tsconfig.esm.json
Dual Packageのためのpackage.json
package.jsonもDual Packageのための設定が必要です。
main
package.jsonにあるそのパッケージのエントリーポイントとなるファイルを指定する項目です。Dual Packageのときはここにcommonjsのエントリーポイントとなるjsファイルを設定します。
module
Dual Packageのときはここにesmoduleのエントリーポイントとなるjsファイルを設定します。
types
型定義ファイルのエントリーポイントとなるtsファイルを設定します。型定義ファイルを出力するようにしていればcommonjs, esmoduleのどちらのtsconfig.jsonで出力したものでも問題ありません。
package.jsonはこのようになっているでしょう。
json{"name": "YYTS","version": "1.0.0","license": "CC BY-SA 3.0","main": "./cjs/index.js","module": "./esm/index.js","types": "./esm/index.d.ts","scripts": {"build": "yarn build:cjs && yarn build:esm","build:cjs": "tsc -p tsconfig.cjs.json","build:esm": "tsc -p tsconfig.esm.json"}}
json{"name": "YYTS","version": "1.0.0","license": "CC BY-SA 3.0","main": "./cjs/index.js","module": "./esm/index.js","types": "./esm/index.d.ts","scripts": {"build": "yarn build:cjs && yarn build:esm","build:cjs": "tsc -p tsconfig.cjs.json","build:esm": "tsc -p tsconfig.esm.json"}}
コンパイル後のjsのファイルの出力先はあくまでも例です。tsconfig.jsonのoutDirを変更すれば出力先を変更できるのでそちらを設定後、package.jsonでエントリーポイントとなるjsファイルの設定をしてください。
Tree Shaking
module bundlerの登場により、フロントエンドは今までのような<script>でいろいろなjsファイルを読み込む方式に加えてを全部載せjsにしてしまうという選択肢が増えました。この全部載せjsは開発者としては自分ができるすべてをそのまま実行環境であるブラウザに持っていけるので楽になる一方、ひとつのjsファイルの容量が大きくなりすぎるという欠点があります。特にそれがSPA(Single Page Application)だと問題です。SPAは読み込みが完了してから動作するのでユーザーにしばらく何もない画面を見せることになってしまいます。
この事態を避けるためにmodule bundlerは容量削減のための涙ぐましい努力を続けています。その機能のひとつとして題名のTree Shakingを紹介するとともに、開発者にできるTree Shaking対応パッケージの作り方を紹介します。
Tree Shakingとは
Tree Shakingとは使われていない関数、クラスを最終的なjsファイルに含めない機能のことです。使っていないのであれば入れる必要はない。というのは至極当然の結論ですがこのTree Shakingを使うための条件があります。
esmoduleで書かれている- 副作用(side effects)のないコードである
各条件の詳細を見ていきましょう。
esmoduleで書かれている
commonjsとesmoduleでは外部ファイルの解決方法が異なります。
commonjsはrequire()を使用します。require()はファイルのどの行でも使用ができますがesmoduleのimportはファイルの先頭でやらなければならないという決定的な違いがあります。
require()はあるときはこのjsを、それ以外のときはあのjsを、と読み込むファイルをコードで切り替えることができます。つまり、次のようなことができます。
tsletpolice = null;letfirefighter = null;if (shouldCallPolice ()) {police =require ("./police");} else {firefighter =require ("./firefighter");}
tsletpolice = null;letfirefighter = null;if (shouldCallPolice ()) {police =require ("./police");} else {firefighter =require ("./firefighter");}
一方、先述のとおりesmoduleはコードに読み込みロジックを混ぜることはできません。
上記例でshouldCallPolice()が常にtrueを返すように作られていたとしてもmodule bundlerはそれを検知できない可能性があります。本来なら必要のないfirefighterを読み込まないという選択を取ることは難しいでしょう。
最近ではcommonjsでもTree Shakingができるmodule bundlerも登場しています。
副作用のないコードである
ここで言及している副作用とは以下が挙げられます。
exportするだけで効果がある- プロトタイプ汚染のような、既存のものに対して影響を及ぼす
これらが含まれているかもしれないとmodule bundlerが判断するとTree Shakingの効率が落ちます。
副作用がないことを伝える
module bundlerに制作したパッケージに副作用がないことを伝える方法があります。package.jsonにひとつ加えるだけで完了します。
sideEffects
このプロパティをpackage.jsonに加えて、値をfalseとすればそのパッケージには副作用がないことを伝えられます。
json{"name": "YYTS","version": "1.0.0","license": "CC BY-SA 3.0","sideEffects": false,"main": "./cjs/index.js","module": "./esm/index.js","types": "./esm/index.d.ts","scripts": {"build": "yarn build:cjs && yarn build:esm","build:cjs": "tsc -p tsconfig.cjs.json","build:esm": "tsc -p tsconfig.esm.json"}}
json{"name": "YYTS","version": "1.0.0","license": "CC BY-SA 3.0","sideEffects": false,"main": "./cjs/index.js","module": "./esm/index.js","types": "./esm/index.d.ts","scripts": {"build": "yarn build:cjs && yarn build:esm","build:cjs": "tsc -p tsconfig.cjs.json","build:esm": "tsc -p tsconfig.esm.json"}}
副作用があり、そのファイルが判明しているときはそのファイルを指定します。
json{"name": "YYTS","version": "1.0.0","license": "CC BY-SA 3.0","sideEffects": ["./xxx.js", "./yyy.js"],"main": "./cjs/index.js","module": "./esm/index.js","types": "./esm/index.d.ts","scripts": {"build": "yarn build:cjs && yarn build:esm","build:cjs": "tsc -p tsconfig.cjs.json","build:esm": "tsc -p tsconfig.esm.json"}}
json{"name": "YYTS","version": "1.0.0","license": "CC BY-SA 3.0","sideEffects": ["./xxx.js", "./yyy.js"],"main": "./cjs/index.js","module": "./esm/index.js","types": "./esm/index.d.ts","scripts": {"build": "yarn build:cjs && yarn build:esm","build:cjs": "tsc -p tsconfig.cjs.json","build:esm": "tsc -p tsconfig.esm.json"}}