TypeScript のプロジェクト分割だけでは辛いので npm workspaces を使用する
はじめに
過去のコードが TypeScript の ProjectReferences により分割されていました。
分割自体はいいのですが、数が多く管理の手間が多いので、 npm の workspaces でまずは管理できるようにしようと思いました。
TypeScript の ProjectReferences とは
{
"compilerOptions": {
// The usual
},
"references": [
{ "path": "../src" }
]
}
参照したいプロジェクトの tsconfig.json へのパスを指定します。{ "path": "../src/tsconfig.build.json" }
のように違う tsconfig の指定も可能です。
これにより参照先の outDir で指定した出力先の .d.ts を読み込み、プロジェクトで利用できます。
Project References の設定を有効にする場合は、 compilerOption の composite と declarationMap を true にする必要があります。
本設定により参照先が未ビルドの際にビルドしたり、エディターで定義元への参照リンクがつながるようになります。
composite を有効にすると declarationMap だけでなく、 incremental のデフォルト値も有効になります。これにより差分ビルドを行うため、必要なプロジェクトのみのビルドになります。
.tsbuildinfo
というファイルがデフォルトでは outDir 直下に出力されます。
-b
と -p
の違いについて
tsc の -b
or --build
オプションは参照先のビルドも自動的に行います。
$ tsc -b # Use the tsconfig.json in the current directory
$ tsc -b src # Use src/tsconfig.json
$ tsc -b foo/prd.tsconfig.json bar # Use foo/prd.tsconfig.json and bar/tsconfig.json
-p
or --project
オプションは自身のプロジェクトのコンパイルのみとなるので、参照プロジェクトがある場合はあらかじめビルドが必要です。
$ tsc -p foo/tsconfig.json
npm の workspaces とは
$ npm init -w packages/a
を実行すると package.json の workspaces に追加されます。
{
"name": "my-workspaces-powered-project",
"workspaces": ["packages/a"]
}
ルート直下に packages/a/package.json が作られます。
さらに npm install をすることで、ルート直下の node_modules にシンボリックリンクが作られます。
.
├── node_modules
| └── a -> ../packages/a // npm install によりシンボリックリンクが作られる
├── package-lock.json
├── package.json
└── packages
└── a
└── package.json
これにより $ npm run test -w packages/a
と指定するだけで対象のパッケージのコマンドを実行できます。
workspace に対するコマンド実行
オプション指定の前提
-workspace
or -w
は workspace を単体で実行する。
$ npm run test -workspace packages/app
$ npm run test -w packages/app -w packages/core
-workspaces
or -ws
は workspace を全てにおいて実行する。
$ npm run test -workspaces
$ npm run test -ws
--if-present
でコマンドが存在する workspace でのみ実行できる。
$ npm run test --workspaces --if-present
npm install
$ npm install xxx -w packages/app
# package も指定が可能
$ npm install project_name/core -w packages/app
workspace の package は node_modules にシンボリックリンクを作る
./node_modules
├── project_name
│ ├── app -> ../../packages/app
│ └── core -> ../../packages/core
└── typescript
NG設定例 : tsconfig の paths を利用する
フォルダ構成は以下とします。
./packages
├── app
│ ├── dist
│ └── src
└── core
├── dist
└── src
{
"paths": {
"@core/*": ["../../node_modules/package_name/dist/*"]
// または、直接指定する
// "@core/*": ["../../packages/core/dist/*"]
},
}
元々 workspaces を使わず、references で指定したプロジェクトに対して paths を設定していました。 import 文の記述量を減らし、わかりやすさを担保するためです。
しかし、そもそも node_modules にパッケージとして入るため、 package_name
を指定してインポートができるため、設定自体が不要になるはずです。
tsconfig の paths とは
import 時のパス解決に対してマッピングを定義できます。 これを指定することで相対パスによる記述をしなくて良くなります。
{
"compilerOptions": {
"module": "esnext",
"moduleResolution": "bundler",
"paths": {
"@app/*": ["./src/*"]
}
}
}
注意点としては、TypeScript のコンパイルとしては指定したパスで出力します。 そのため、Bundler 側にもパス解決のための設定が必要です。
{
"compilerOptions": {
"module": "nodenext",
"paths": {
"node-has-no-idea-what-this-is": ["./oops.ts"]
}
}
}
バンドルの設定がない場合、以下は TypeScript としてはエラーにはなりませんが、実行時エラーになります。
// TypeScript: ✅
// Node.js: 💥
import {} from "node-has-no-idea-what-this-is";
paths には node_modules を指定してはいけない
package.json の exports をサポートしており、paths とのバッティングは実行時に期待通りの動作をしない可能性があります。
{
"compilerOptions": {
"paths": {
"pkg": ["./node_modules/pkg/dist/index.d.ts"],
"pkg/*": ["./node_modules/pkg/*"]
}
}
}
package.json と tsconfig.json の設定例
フォルダ構成は以下とします。
./packages
├── app
│ ├── dist
│ └── src
└── core
├── dist
└── src
packages/core の設定は以下とします。
{
"name": "project_name/core",
"version": "1.0.0",
"exports": {
"./*": "./dist/*.js"
},
"scripts": {
"build": "tsc -b",
},
}
{
"compilerOptions": {
"composite": true,
"target": "es2016",
"module": "NodeNext",
"declaration": true,
"outDir": "./dist",
"tsBuildInfoFile": "./dist/.tsbuildinfo",
"rootDir": "./src",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
"moduleResolution": "NodeNext"
},
"include": ["./src/**/*.ts"]
}
packages/app の設定は以下とします。
{
"name": "project_name/app",
"scripts": {
"build": "tsc -b",
},
}
モノレポとしての構成として考えているので、 references の指定は行います。 TypeScript に依存関係があることを明示し、IDE で参照先を解決するためです。
{
"compilerOptions": {
"composite": true,
"target": "es2016",
"module": "NodeNext",
"declaration": true,
"outDir": "./dist",
"tsBuildInfoFile": "./dist/.tsbuildinfo",
"rootDir": "./src",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
"moduleResolution": "NodeNext"
},
"references": [{
"path": "../core"
}],
"include": ["./src/**/*.ts"]
}
今回、package.json では exports の設定をしました。 package.json の exports の設定を TypeScript 側でも正しく読み込むためには module / moduleResolution に NodeNext の指定が必要です。
パッケージのエントリーポイント とは
main
{
"main" : "./src/index.js"
}
import * from "project_name"
という指定で index.js のモジュールを単一モジュールとして読み込みます。
exports
以下のようなファイルが作成されているとします。
packages/core/src
├── index.js
└── other.js
例えば、ワイルドカードで指定できます。
{
"exports": {
"./*": "./src/*.js"
},
}
複数のエントリーポイントを指定しているので、それぞれインポートが可能になります。
import * from "package_name/core/index" // OK
import * from "package_name/core/test" // OK
複数のエントリーポイントだけでなく、 CommonJS や ESM 向けにファイル指定も可能です。
// package.json
{
"exports": {
"import": "./index-module.js",
"require": "./index-require.cjs",
"node": "xxx.js" // node で実行する
"default": "xxx.js"
},
"type": "module"
}
module と moduleResolution の指定
moduleResolution の設定を node16
、 nodenext
、または bundler
にすることで package.json の exports の仕様に沿います。
上記の設定をするとデフォルト値として resolvePackageJsonExports も true に設定されます。
さらに、 moduleResolution で nodenext
を使用すると、 module でも nodenext
を指定しなければなりません。
パッケージを module として扱うにあたり、拡張子によって異なる解釈が行われます。
-
.mts / .mjs / .d.mts
- ES modules として扱われる。つまり、 import / export を使用する。
-
.cts / .cjs / .d.cts
- CommonJS modules として扱われる。つまり、 require / module.exports を使用する。
-
.ts / .tsx / .js / .jsx / .d.ts
- package.json に
"type": "module"
を指定している場合は ES modules、それ以外は CommonJS modules として扱われる。
- package.json に
node16
と nodenext
の指定は CommonJS または ESM 形式のどちらかで出力します。検出されたファイルのモジュールの指定によって決まります。
おわりに
一旦、ここまで移行した後に、今度は Lerna とかも導入できないか考えたいと思います。
Discussion