自作のnpmパッケージを作りたくて作ってみた。
いや、正確には作ろうとしたものがあったのだが、途中で作るモチベーションがなくなったので作って公開まではしていない。笑
ただ、いつでも作れるようにテンプレート化しておいた。
npmパッケージを作る手順とか必要な設定とか諸々学びが多かったのでちょっと雑だけどメモとして残しておく。
前編と後編に分ける予定だが、今回は前編として以下の設定周りのことを取り扱う
prettierやeslint、huskyなども設定しているがここでは扱わない
この辺は以前記事を書いたのでそちらを参照

後編では開発中の動作確認方法やGitHub Actionsを用いたいい感じの運用方法とかをまとめる
作成するnpmパッケージの要件
今回作成するパッケージの設定は以下の要件を満たすものとする
一応開発に使った環境を記載しておく
最終的なディレクトリ構成は以下のようになる(必要なものだけ抜粋)
npm-library
├ lib
├ scripts
│ └ bundle.ts
├ src
│ └ index.ts
├ .npmrc
├ package.json
├ README.md
└ tsconfig.json
tsconfig.jsonの設定
ビルドはesbuildで行うので、tscの役割としては型チェックと型ファイルの吐き出しのみ
それに関連する部分だけ抜粋したtsconfigの設定は以下の通り
{
"compilerOptions": {
/* Basic Options */
"target": "es2019",
"module": "es6",
"jsx": "react-jsx",
"declaration": true,
"outDir": "./lib",
/* Module Resolution Options */
"moduleResolution": "node",
"esModuleInterop": true,
},
"include": ["src/**/*.ts"]
}
target
どのバージョンにトランスパイルするか
IE対応不要になった現在、ES5にする必要はなさそうなので適当にサポートしたいES6以上のバージョンにすればよさそう。
2022年2月現在、サポートされている中で一番古いNodeのバージョンは12で、Nodeのv12ではES2019をサポートしているので今回はes2019
とした。
今回は記述していないがlib
の項目がtargetに従って自動で設定されるので、もしes2019以上のバージョンで使いたい構文があったら記述する。
module
トランスパイル時にどのモジュールパターンで出力するか
デフォルト値はtarget
がES3かES5ならcommonJS
になり、それ以外だとes6
になる
ECMAScriptのバージョン全てに対応しているわけではなく、es6
の次はes2020
になる。es6
とes2020
の違いはdynamic importに対応しているかどうか。
今回はes6
にしたけどes2020
でもよかったかもしれない。
今回はcommonJSには対応しないが、必要であればmodule
をcommonJS
にしたtsconfigを別で作って、ビルド時にtsconfig.jsonの読み込み設定を分けてビルド仕分けるのが主流っぽい
declaration
tsファイルからexportされているものを.d.ts
ファイルに型定義として吐き出してくれるようになる
outdir
ビルド結果を吐き出す場所dist
とかbuild
が一般的な気がするが、npmライブラリではlib
ディレクトリに吐き出してるのをよく見る気がするのでlib
とした
moduleResolution
module解決の方法を指定できる。
moduleでes6
にした場合はclassic
になるのでnode
を指定している。
基本これから開発するならnode
でよさそう。
(TS 4.5以上でnode12
とnodenext
なるものがあった)
どうやらこのオプションはTSでNative ESMを書けるようにするために導入されたものらしい
ここは別途調査する
esModuleInterop
commonJS形式のモジュールでdefaultをエクスポートしていない場合でも、ESMでデフォルトインポートすることが可能になる
正直ここもちゃんと理解しきれてないが、ESMしかサポートしない場合はあんまり関係ない気がしている。。
これも別途ちゃんと調査しようと思う
pathsの設定
普段ファイル内でimport
を書く時にsrc配下を@/
で絶対パスとして書けるように設定しているのだが、そうすると吐き出した型定義ファイルもそうなるためパスの解決ができなかった
色々調べたがパスの解決に関してはTSの責務ではないとのことで、webpackとかを使ってやるのが一般的らしい
んーまあ確かにそうかって感じだがwebpack使いたくないし今回はとりあえずここは妥協して設定しないことにした
buildの設定
esbuildはcliコマンドでオプションとして色々渡せるのだが今回は設定項目が多いので、スクリプト用のファイルとして別ファイルに切り出してそれをビルド時に実行するようにした
config.json的な設定ファイル用意するのかなって思ったけど、esbuildはそういうの用意していないらしい
jsファイル作ってそれを実行しろとのこと
非公式でconfigファイル読み取れるようにしたライブラリとかあるっぽいけど、今回はとりあえず従うことにする
scripts/bundle.tsファイルにビルド用の設定を書く
参考: https://qiita.com/faunsu/items/487c7157c211bfc739c1
import { build, Message } from "esbuild"
const warningLog = (warning: Message[]) => {
warning.forEach((warn) => {
console.error("warning: ", warn.text)
console.error("detail: ", warn.detail)
console.error("path: ", `${warn.location?.file}:${warn.location?.line}:${warn.location?.column}`)
console.error(" -> ", warn.location?.lineText)
})
}
const errorLog = (errors: Message[]) => {
errors.forEach((err) => {
console.error("error: ", err.text)
console.error("path: ", `${err.location?.file}:${err.location?.line}:${err.location?.column}`)
console.error(" -> ", err.location?.lineText)
})
}
build({
entryPoints: ["./src/index.ts"],
outdir: "lib",
bundle: true,
sourcemap: true,
minify: process.env.NODE_ENV === "production",
external: ["react", "react-dom"],
splitting: true,
format: "esm",
target: "es2019",
...(process.env.NODE_ENV === "production"
? {}
: {
watch: {
onRebuild: (error, result) => {
console.log(error, result)
console.log("-------------------------------")
if (error) {
console.error(new Date().toLocaleString(), " watch build failed ")
if (error.warnings) warningLog(error.warnings)
if (error.errors) errorLog(error.errors)
return
}
if (result) {
console.log(new Date().toLocaleString(), " watch build succeeded ")
if (result.warnings) warningLog(result.warnings)
}
},
},
}),
})
パッと見てわかりそうなものは詳細を省く
- minifyは本番環境でのビルドのみやればいいので環境変数で切り替えている
- externalについては下記に記述
- tsconfigで設定した時と同様、formatをesm, targetをes2019とした
- 開発環境ではwatchモードでビルドするように設定
externalにreact, react-domを設定
パッケージ内でReactを使っているとそのパッケージを使用する際に、プロジェクト内でプロジェクト由来のものとパッケージ由来の2つのreactが混在してしまうためreactがエラーを出す
従ってパッケージの開発時にはreactが必要だが、パッケージを使用する際にはパッケージ由来のreactは必要ないという状況になる
そのためビルド時にexternalにreact, react-domを指定することで、ビルド結果にこれらを含めないようにして上記の問題を解決している
なお、その際にパッケージが使用するreactをパッケージをインストールしたプロジェクト由来のものを使用することを明示するためにpackage.jsonにpeerDependenciesの記載をする必要がある (後述)
package.jsonの設定
配布するにあたって必要そうな部分だけ抜粋したpackage.jsonが以下の通り
{
"name": "npm-library-template",
"version": "0.0.1",
"description": "template for creating npm library",
"type": "module",
"module": "lib/index.js",
"types": "lib/index.d.ts",
"engines": {
"node": ">=12"
},
"files": [
"lib"
],
"scripts": {
"build": "rimraf lib && run-p build:minify build:types",
"build:dev": "rimraf lib && run-p build:watch build:types",
"build:watch": "node --loader ts-node/esm scripts/bundle.ts",
"build:minify": "NODE_ENV=production node --loader ts-node/esm scripts/bundle.ts",
"build:types": "tsc --emitDeclarationOnly",
"prepublishOnly": "npm run build",
},
"repository": {
"type": "git",
"url": "https://github.com/foo/bar"
},
"keywords": [
"react"
],
"author": "suna",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0",
"react-dom": "^16.8.0 || ^17.0.0"
}
}
パッとみてわかりそうなものは詳細を省く
type
ファイルをESMで動かすためにmodule
を設定している
これまでは.jsファイルはcommonjsだったが、それをESMとして扱うためのこのオプションが必要になっているという認識
module
吐き出したモジュールパターンがcommonjsの場合はmain
にエントリーポイントを書くが、今回はESMなのでmodule
にエントリーポイントを書く
もし両方サポートしたい場合は、lib/cjs/index.js
とlib/esm/index.js
のようにビルド結果を別々の場所に吐き出してそれぞれmain
とmodule
で指定するとよさそう
types
型定義ファイルのエントリーポイントを書く
engines
ライブラリ開発に必要なツールのバージョンの設定などを行う
OSSとして貢献する人がここに指定したバージョンのツールを使って開発せざるを得ない設定にする
今回はNodeのバージョンだけ指定した。
npmではなくyarnを使うようにも設定できたりする
この設定だけだとwarningになるだけだが、エラーにして強制させるために別途.npmrc
ファイルを作成して以下の記述をしておく
engine-strict=true
files
実際に配布に必要なファイルを入れる
コンパイル後のコードのみ入れるように設定した。
なお、この設定に関わらずREADMEやpackage.json, LICENSEなどは配布に必要なため必然的に配布される
peerDependencies
buildの設定の時にも言及したが、このライブラリをインストールした際にこのライブラリが使用するreactをプロジェクト由来のものを使うということを明示するためにpeerDependenciesにreact
とreact-dom
を記述している
開発ライブラリの中でreactのhooksを使ったりしている場合はv16.8以上である必要があるのでそれ以上のバージョンであることを指定している
(たまにライブラリのアップデートとかでバージョンが合わないみたいなのに遭遇するけど、ここで指定しているバージョンと実際にプロジェクトで使ってるバージョンで齟齬が出てエラーになったりしてたのかなと思った)
yarn linkでの開発時の設定
resolve: {
alias: {
react: path.resolve('./node_modules/react'),
},
}
ただこれは試していない
というのも、viteで動かしているプロジェクトでyarn linkを使って開発してみたら設定不要で読み込めた
なんならpeerDependenciesすら要らなかった
バンドルせずにファイルをESMとしてそのまま読み込むからokってことなのかな?
「パッケージ配布時にはpeerDependenciesは必要、困るのはwebpackのプロジェクトでyarn link使って動作確認する時のみ」という認識なので、とりあえずここは妥協して放置した。
webpack使う状況になったら再度考えるけど、基本自分はvite使う予定なので
scripts
scriptsの内容を以下に再掲
本当はprettierやeslintのチェックコマンドなどもあるが割愛。
ここではbuild関連のものだけ記載する
"scripts": {
"build": "rimraf lib && run-p build:minify build:types",
"build:dev": "rimraf lib && run-p build:watch build:types",
"build:minify": "NODE_ENV=production node --loader ts-node/esm scripts/bundle.ts",
"build:watch": "node --loader ts-node/esm scripts/bundle.ts",
"build:types": "tsc --emitDeclarationOnly",
"prepublishOnly": "npm run build",
},
中々混沌としている。。。もうちょいいい書き方ありそうな気もするがこれに落ち着いた
まずビルド時には実行ファイルのビルドと型定義ファイル(.d.tsファイル)のビルドを別々で行う
型定義ファイルのビルドはtscによるものでこれをbuild:types
で行っている
tscコマンドの--emitDeclarationOnly
オプションで型定義ファイルのビルドのみ行うようにしている
実際のビルドコマンドは大きく分けてbuild
とbuild:dev
コマンドの2つであり、これらはそれぞれ本番環境と開発環境用のビルドコマンド(build:minify
とbuild:watch
)を呼び分けている。
共通しているのは両方rimraf
で既存のビルド結果を削除した後に、npm-run-all
ライブラリのコマンドrun-p
で実行ファイルのビルドと型定義ファイルのビルドを並列で実行していること
buildのスクリプトはscripts/bundle.tsに記載があるのでそれを実行しており、環境変数でこれらを切り分けている
prepublishOnly
実際にnpmパッケージをpublishする際にはyarn publish
のコマンドを叩くことになるのだが、そのコマンドの前にprepublishOnly
がトリガーされる
ここではpublishする前に最新の内容をbuildし直してから配布するようにnpm run build
を設定した
repository, keywords, author, license
この辺はパッケージの情報として必要なもので、ここに書いた情報が実際にnpmのパッケージのページ見たときに乗ってるような情報として反映される

実際にpublishする
ここまで設定したらあとはsrc配下にindex.ts
を配置して適当なコードを書く
import { useEffect, useState } from "react"
export const useHelloWorld = () => {
const [state, setState] = useState("hello")
useEffect(() => {
setState("world")
}, [])
return state
}
npmにアカウント登録をしてyarn publish
を叩く
これだけで配布が完了する。
もちろん別プロジェクトでインストールもできる
注意点としては、パッケージの名前はユニークなものでなければならないのと、すでにpublishしているバージョンと同じバージョンをpublishしようとするとエラーになる。
そのため、内容を変えて再publishする際にはpackage.json
のversion
を変更する必要がある
この辺の運用方法は次の記事でもう少し詳細にまとめる
まとめ
publish自体はすごく簡単にできた
ただ今回見てきたようにtsconfig.json, package.jsonの設定、ビルド関連の設定などは普段開発していてもあまり深くまで調べていないことだったので非常に勉強になった
ES Modules関連の設定項目が至る所で出てきて分かってるようで完全に理解しきれてないなーってのが課題として浮き彫りになってきたので、ここら辺は別途調査しようかと思う。
ES ModulesにTypeScriptが絡んでくるとかなり複雑になる印象がある
(浅い理解ではあるが、commonJSとES Modulesの互換性の問題として現状ではdefault export / importする際の挙動で齟齬が出うる気がしているので、基本的にnamed export / importするように書いていればそこまで問題にならない気がした)
次回は実際にライブラリを運用する際に便利なGitHub Actionsの設定だったり、開発中の動作確認の方法などをまとめる予定
この記事が参考になったからコーヒーくらいおごってもいいぜという方は、以下からサポートいただけると次の記事書くモチベになりますのでよろしくお願いします

参考


コメント