mizdra's blog

ぽよぐらみんぐ

npm-scripts を書く時の手癖

かれこれ 5 年くらい趣味開発で npm-scripts を書き続けている。長年書き続けているとノウハウが蓄積されてきて、「こう書くとスッキリする」「迷いがなくなる」「後から拡張したくなった時に、簡単に拡張できる」みたいな書き方が身についてきた。自分の型、あるいは手癖のようなものだと思う。

せっかくなので、id:mizdra の今の npm-scripts を書く時の手癖を書き連ねてみる。

基本形

{
  "scripts": {
    "build": "webpack --mode production",
    "dev": "webpack-dev-server --mode development",
    "lint": "eslint .",
    "test": "jest"
  }
}

一番シンプルな npm-scripts を書く時のパターン。以下の 4 つの script を登録している。

  • build: 本番ビルドを実行する
  • dev: 開発時に使うビルドモードを立ち上げる
    • webpack を使ったプロジェクトなら開発サーバを watch ビルドで立ち上げたり、tsc を使ったプロジェクトならtsc --watchしたり。
  • lint: lint や型検査など、静的解析を実行する
    • 静的解析だったらstatic-checkが良い気もするけど、長くて type しづらいので避けた
    • checkにしていた時代もあったが、yarn の組み込みコマンドと競合して辛かったのでやめた
    • 界隈的にも lint と呼んでいることのほうが多そうだったので、そっちに合わせている
  • test: unit テストを実行する
    • unit テストだけを扱う
    • E2E テストは別 script に逃がす (後述)

趣味開発しているプロジェクトには、基本的にこの 4 つの script を登録するようにしている。どのプロジェクトを触っても、npm i && npm run devすれば開発ビルドが即立ち上がるし、npm run lintすれば静的解析が走る。全プロジェクトで script 名を統一することで、「このプロジェクトではなんて名前の script だっけ?」と迷わないようにしている。

これが基本形で、ここからプロジェクトでやりたいことが広がっていくのに対応して、npm-scripts も拡張していく。

lint や dev で複数コマンドを実行する

eslintだけでなくtsc --noEmitも実行したい」など、それぞれの基本 script の中で複数コマンドを実行したくなったときは、以下のように拡張する。

 {
   "scripts": {
     "build": "webpack --mode production",
-    "dev": "webpack-dev-server --mode development",
+    "dev": "run-p dev:*",
+    "dev:webpack": "webpack-dev-server --mode development",
+    "dev:graphql": "graphql-codegen --watch",
+    "dev:tsm": "typed-scss-modules src --nameFormat none --exportType default --watch"
-    "lint": "eslint .",
+    "lint": "run-s -c lint:*",
+    "lint:eslint": "eslint .",
+    "lint:tsc": "tsc --noEmit",
+    "lint:prettier": "prettier --check .",
     "test": "jest"
   }
 }

以下ポイント:

  • コマンド 1 つにつき 1 つの script (lint:eslint/lint:tscなど) を割り当てる
    • 「開発時に eslint だけ実行しつつデバッグしたい」みたいな需要に答えるため
    • lint:<name><name>の部分には、コマンド名とかそれっぽい名前を割り当てておく
  • まとめて実行する script (lint) も用意する
    • lint などに関する script を手軽に全部実行できるよう、用意しておく
    • script をまとめるのには npm-run-all を使う
      • 2024-10-07追記: npm-run-all は現在メンテナンスされていない。代わりにその fork の npm-run-all2 を使うのがオススメ。
      • run-s lint:*と書けば、lint:*にマッチする script を全部実行できる
      • run-s lint:eslint lint:tsc lint:prettierと手動でリストアップする形にすれば実行順を制御できるが、いつか抜け漏れが出るので極力避ける
    • 直列版のrun-sと並列版のrun-pが用意されているけど、基本的にはrun-sを使っている
      • run-pにして並列にすると、複数コマンドの出力が入り乱れてデバッグ困難になるため
      • 直列で実行してストレスに感じる規模のコードは id:mizdra は普段触らないので、特に困っていない
      • devscript など並列で実行する必要のあるものについては、素直にrun-pを使う
    • run-sを使うときは-cオプションを付ける
      • デフォルトでは script を 1 つずつ実行していって、エラーになったら即時中断する挙動になっている
        • lint:eslint => lint:tsc => lint:prettierという順序で実行される時に、lint:tscでエラーになったらlint:prettierは実行されない
      • これだと一部の静的解析の結果が欠損してしまい、効率的にデバッグできない
      • -cを付けたら、エラーが見つかっても全ての script を実行した上で、エラーが返される挙動になるので、全ての静的解析が実行される
    • run-s / run-p-l オプションは付けない
      • -l オプションを付けると、script 名付きの出力が得られる
      • これを使えば run-p で複数コマンドの出力が入り乱れてもある程度見やすくなる
      • ただし、実行されるコマンドの色の出力が抑えられてしまう問題がある
      • 微妙に不便なので使わないようにしてる
  • CI からはまとめ上げられた script を実行するよう整理する

どうしても lint を並列で実行したくなったら、npx run-p "lint:*" / yarn run-p "lint:*" とターミナルから打てば良いと思う。

lint や test の亜種を用意する

eslint --fixprettier --write的なことをするlint script の亜種が欲しい」「watch モードで起動するtestscript の亜種が欲しい」みたいな需要が出てきたときは、以下のように拡張する。

 {
   "scripts": {
     "build": "webpack --mode production",
     "dev": "run-p dev:*",
     "dev:webpack": "webpack-dev-server --mode development",
     "dev:graphql": "graphql-codegen --watch",
     "dev:tsm": "typed-scss-modules src --nameFormat none --exportType default --watch"
     "lint": "run-s -c lint:*",
     "lint:eslint": "eslint .",
     "lint:tsc": "tsc --noEmit",
     "lint:prettier": "prettier --check .",
+    "lint-fix": "run-s -c lint-fix:*",
+    "lint-fix:eslint": "npm run lint:eslint -- --fix",
+    "lint-fix:prettier": "prettier --write .",
     "test": "jest",
+    "test-watch": "npm run test -- --watch"
   }
 }

以下ポイント:

  • <拡張したいscript>-<postfix>という名前の script を生やす
    • lintの自動修正版ならlint-fixtestの watch モード版ならtest-watch
    • 界隈ではlint:fixというパターンも一般的だが、"lint": "run-s -c lint:*"に含まれてしまうので避けている
  • 亜種の script とオリジナルの script で共通化できれば共通化する
    • "lint-fix:eslint": "npm run lint:eslint -- --fix"みたいな感じ
    • prettier はオプションの都合上共通化が難しいのでやらない。無理せず簡単なほうに倒す。

余談

亜種を用意すると npm-scripts の数が膨大になって見通しが悪くなったり、管理が行き届かなくなるので、趣味では基本的にやらないようにしている。jestを watch モードで起動するならnpm run test -- --watch、みたいなことは id:mizdra は空で書けるので、別にわざわざ script として用意していない。チームで開発している時などは用意しておくと丁寧だと思う。

コード生成用の script を用意する

GraphQL Code Generator などコード生成系のツールを導入したくなったら、以下のように拡張する。

 {
   "scripts": {
+    "gen": "run-s gen:*"
+    "gen:graphql": "graphql-codegen",
+    "gen:tsm": "typed-scss-modules src --nameFormat none --exportType default",
     "prebuild": "npm run gen"
     "build": "webpack --mode production",
     "dev": "run-p dev:*",
     "dev:webpack": "webpack-dev-server --mode development",
-    "dev:graphql": "graphql-codegen --watch",
-    "dev:tsm": "typed-scss-modules src --nameFormat none --exportType default --watch"
+    "dev:graphql": "npm run gen:graphql -- --watch",
+    "dev:tsm": "npm run gen:tsm -- --watch",
     "lint": "run-s -c lint:*",
     "lint:eslint": "eslint .",
     "lint:tsc": "tsc --noEmit",
     "lint:prettier": "prettier --check .",
     "lint-fix": "run-s -c lint-fix:*",
     "lint-fix:eslint": "npm run lint:eslint -- --fix",
     "lint-fix:prettier": "prettier --write .",
     "test": "jest",
     "test-watch": "npm run test -- --watch"
   }
 }

以下ポイント:

  • genという script を生やす
    • もちろん generate の略
    • 目で見て分かれば何でも良い
  • dev:*と共通化できることが多いので適当に共通化しておく
  • buildする際にgenが実行されるよう、prebuildscript を追加しておく

Unit テスト向けの型検査 script を用意する

アプリケーションコードと Unit テストのコードを、それぞれ別々のtsconfig.jsonを使って型検査したくなった時の話。つまりlint:tscを分割したい。id:mizdra の場合は以下のように拡張している。

 {
   "scripts": {
     "gen": "run-s gen:*"
     "gen:graphql": "graphql-codegen",
     "gen:tsm": "typed-scss-modules src --nameFormat none --exportType default",
     "prebuild": "npm run gen"
     "build": "webpack --mode production",
     "dev": "run-p dev:*",
     "dev:webpack": "webpack-dev-server --mode development",
     "dev:graphql": "npm run gen:graphql -- --watch",
     "dev:tsm": "npm run gen:tsm -- --watch",
     "lint": "run-s -c lint:*",
     "lint:eslint": "eslint .",
-    "lint:tsc": "tsc --noEmit",
+    "lint:tsc": "run-s -c lint:tsc:*",
+    "lint:tsc:src": "tsc -p tsconfig.src.json --noEmit",
+    "lint:tsc:test": "tsc -p tsconfig.test.json --noEmit",
     "lint:prettier": "prettier --check .",
     "lint-fix": "run-s -c lint-fix:*",
     "lint-fix:eslint": "npm run lint:eslint -- --fix",
     "lint-fix:prettier": "prettier --write .",
     "test": "jest",
     "test-watch": "npm run test -- --watch"
   }
 }

以下ポイント:

  • lint:tsc:srclint:tsc:testに分割する
  • lint:tscでまとめる

余談

tsconfig.jsonではなくtsconfig.src.json/tsconfig.test.jsonといったファイル名にしてしまうと、tsserver (VSCode で利用されている TypeScript の Language Server) から設定ファイルが検出できなくなり、エディタ上のリアルタイムの型検査がデフォルトの設定で実行されてしまうので注意すること。補完がいつの間にかおかしくなったら、これを疑うと良い。tsserver に設定ファイルが読み込まれているのかは、VSCode の下部にあるツールバーから確認できる。

じゃあtsconfig.jsonにしないといけないのかというと、そうでもない。Solution Style tsconfig.json というものを用意すれば、好きな名前の設定ファイルを tsserver に読み込ませることができる。

{
  // files は必ず空にする
  "files": [],
  "references": [
    // ここに tsserver から参照してほしい設定ファイルのパスを書いていく
    { "path": "./tsconfig.src.json" },
    { "path": "./tsconfig.test.json" }
  ]
}

便利なテクニックだなと思ってよく使ってるけど、id:mizdra 以外で使っている人見たことない。知られていないだけなのか、そもそもこんな複雑なこと皆やらないだけなのか… 皆さん使ってますか?

E2E テスト用の script を用意する

E2E テストを導入したくなったら、以下のように拡張する。

 {
   "scripts": {
     "gen": "run-s gen:*"
     "gen:graphql": "graphql-codegen",
     "gen:tsm": "typed-scss-modules src --nameFormat none --exportType default",
     "prebuild": "npm run gen"
     "build": "webpack --mode production",
     "dev": "run-p dev:*",
     "dev:webpack": "webpack-dev-server --mode development",
     "dev:graphql": "npm run gen:graphql -- --watch",
     "dev:tsm": "npm run gen:tsm -- --watch",
     "lint": "run-s -c lint:*",
     "lint:eslint": "eslint .",
     "lint:tsc": "run-s -c lint:tsc:*",
     "lint:tsc:src": "tsc -p tsconfig.src.json --noEmit",
     "lint:tsc:test": "tsc -p tsconfig.test.json --noEmit",
     "lint:prettier": "prettier --check .",
     "lint-fix": "run-s -c lint-fix:*",
     "lint-fix:eslint": "npm run lint:eslint -- --fix",
     "lint-fix:prettier": "prettier --write .",
-    "test": "jest",
+    "test": "jest -c jest.test.js",
     "test-watch": "npm run test -- --watch",
+    "e2e-test": "jest -c jest.e2e-test.js",
+    "e2e-test-watch": "jest -c jest.e2e-test.js --watch",
   }
 }

以下ポイント:

  • testとは別にe2-testを用意する
    • testtest-watchで時間の掛かるテストが走ってほしくないため
    • E2E テストは結構重いので、これがtest-watchなどで走ってしまうと、デバッグ体験が悪い
    • オプトアウトしておいて、必要に応じてe2e-teste2e-test-watchしてもらう、という世界観
    • vscode-jest と組み合わせるときも、このように分かれていたほうが扱いやすいという話もある

事前にビルドしてからでないと実行できない script を用意する

さっきさらっと E2E テストの script こんな感じですと紹介したけど、普通は事前にビルドが必要なので、あのままだと動かないと思う。他にもビルドした結果を元に lint したいといった、事前にビルドしてからでないと実行できない script が出てくる状況がたまにある。もしそのようなものが出てきたら、以下のように拡張する。

 {
   "scripts": {
     "gen": "run-s gen:*"
     "gen:graphql": "graphql-codegen",
     "gen:tsm": "typed-scss-modules src --nameFormat none --exportType default",
     "prebuild": "npm run gen"
     "build": "webpack --mode production",
+    "postbuild": "run-s -c postbuild:*",
+    "postbuild:e2e-test": "jest -c jest.e2e-test.js",
+    "postbuild:lint": "bundlesize",
     "dev": "run-p dev:*",
     "dev:webpack": "webpack-dev-server --mode development",
     "dev:graphql": "npm run gen:graphql -- --watch",
     "dev:tsm": "npm run gen:tsm -- --watch",
     "lint": "run-s -c lint:*",
     "lint:eslint": "eslint .",
     "lint:tsc": "run-s -c lint:tsc:*",
     "lint:tsc:src": "tsc -p tsconfig.src.json --noEmit",
     "lint:tsc:test": "tsc -p tsconfig.test.json --noEmit",
     "lint:prettier": "prettier --check .",
     "lint-fix": "run-s -c lint-fix:*",
     "lint-fix:eslint": "npm run lint:eslint -- --fix",
     "lint-fix:prettier": "prettier --write .",
     "test": "jest -c jest.test.js",
     "test-watch": "jest --watch",
-    "e2e-test": "jest -c jest.e2e-test.js",
-    "e2e-test-watch": "jest -c jest.e2e-test.js --watch",
   }
 }

以下ポイント:

  • postbuild:という postfix を付けた script を生やす
    • npm run build && npm run postbuild:e2e-testでワンセットという世界観
  • postbuildも用意しておく
    • npm run buildした時に自動で実行される

今の所こういう手癖で書いてるけど、npm run buildで自動で実行されてしまうのがちょっとイマイチだなと思っている。postbuild:lintはまあ許容できるけど、postbuild:e2e-testも毎回走るのはどうなのだろう、みたいな。デプロイ用と lint/test 用の GitHub Workflow がそれぞれあった時に、本来であれば lint/test 用の workflow でだけ postbuild:e2e-test が実行されてほしいのに、デプロイ用の workflow でも buildする都合上 postbuild:e2e-test が実行されてしまうはず。

事前ビルドが必須だとよわかる名前になっていれば良いので、require-build:e2e-testみたいな名前にしたほうが良いのかも。良いアイデア持ってる人が居たら教えてほしい。

コマンドの繋ぎ合わせでは表現が難しい script を用意する

プログラマブルなことしたくなったら、以下のようにスクリプトファイルに追いやれば良い。

{
  "scripts": {
    "dev": "node scripts/dev.js"
  }
}

難しいことをしたくなったら歯を食いしばってスクリプトファイルを書いていけば良いと思う。銀の弾丸はない。

実例

せっかくなのでいくつか実例も紹介。歴史的経緯により、これまで紹介した手癖に沿っていない書き方が多々あるのが気になるかも。目を瞑ってください。

  • mizdra/eslint-interactive
    • Node.js で動く CLI ツール
    • postbuild:testpostbuild:benchmarkがあるのが見どころ
  • mizdra/now-playing-for-google-play-music
    • Youtube Music で NowPlaying ツイートを可能にするツール
    • デスクトップ向けのブラウザ拡張機能と、Android 向けの PWA が用意されていて、同じリポジトリで開発してる
    • ブラウザ拡張機能向けの script と PWA 向けの script が混在しているのが見どころ
      • monorepo にして npm/yarn workspace 導入したほうがスッキリするとは思う (けど面倒だった)
      • まあ趣味なのでこれくらい雑で良いという感覚
    • あとブラウザ拡張機能のストアにアップロードする用の zip を作る script があるのも見どころ
  • mizdra/scrapbox-userscript-icon-suggestion
    • Scrapbox で動く UserScript
    • devscript をスクリプトファイルに切り出しているのが見どころ

皆さんも自慢の手癖があれば教えて下さい。

ポケットモンスター・ポケモン・Pokémon・は任天堂・クリーチャーズ・ゲームフリークの登録商標です.

当ブログは @mizdra 個人により運営されており, 株式会社ポケモン及びその関連会社とは一切関係ありません.