npm-scripts で Web フロントエンド開発を管理する

2016年10月7日 0 開発 , , ,

gulp なしの Web フロントエンド開発から 1 年あまり。その間、特に問題もなく npm-scripts で Web フロントエンド開発を管理できているので、この間に得られた運用知見や所感などをまとめてみる。

もくじ

npm-scrips とは?

最近の Web フロントエンド開発では AltJS/AltCSSのビルドやリリース用イメージ作成などに Node.js + npm を利用することが一般化してきている。そのためプロジェクトは package.json で管理することになる。

package.json の提供する代表的な機能として

  • プロジェクト情報の定義
    • プロジェクトの成果物を npm として配布するための情報
    • プロジェクト名、バージョン、作者などのメタデータを定義する
  • 依存モジュール管理
    • プロジェクトが依存する npm とバージョンを管理する
    • この情報へ基づき npm install コマンドにより npm を一括で導入・更新できる
  • タスク管理
    • ビルドやユニットテストの実行などをタスクとして定義できる
    • タスクは Shell スクリプトとして記述
    • 定義されたタスクは Teminal から npm run TASKNAME コマンドで実行可能

などがある。これらのうちタスク管理機能を npm-scripts という。例えば

{
  "scripts": {
    "start": "npm run watch",
    "test": "mocha --compilers js:babel-register test/**/*.test.js",
    "app": "electron --debug=5858 src/"
  }
}

のように定義する。このようにタスクを定義して実行する仕組みをタスクランナーと呼ぶこともある。npm-scripts 以外だと

あたりが普及している。いまは Grunt が廃れ gulp が全盛、webpack は単体もしくは gulp と組み合わせて使われることが多い感じ。

なぜ npm-scripts か?

タスクランナーとして gulp や webpack があるのに npm-scripts を選ぶ理由としては

などで指摘されている問題を避けるため。特にプラグインの寿命は実際に遭遇したこともあり、かなり問題視している。次項で解説するが、npm-scripts の扱いにくさを解決する npm によって私の用途では実用十分になったというのもある。

その他、

  • 基本的に package.json だけで設定と処理が完結する
  • タスクランナー独自のルールを学習しなくて済む

などが気に入って採用している。

npm-scripts の問題とその対応

よいことずくめに感じられる npm-scripts だが、問題もある。

  • 可読性
    • Node として処理を記述する gulp などに比べると、単一行 Shell スクリプトは読みにくい
    • CLI オプションを変更するとき、対象とする位置を見つけにくい
    • 前向きに評価するなら、必要な情報が一行に集約されているとも言えるのだが…
  • 環境依存
    • Shell スクリプトなので実行環境によっては使えない機能がある
    • macOS や Linux などの UNIX 系と Windows は異なる
    • 複数コマンドの連結方法などは Shell 依存である
  • 変数
    • 同じ意味と値を複数箇所へ指定する場合、変数がほしくなる
    • 一応、標準で npm_package_confignpm_config が提供されている
    • ただし、これらの参照記法は Shell に依存する
    • そのためクロスプラットフォームに記述できない

まず可読性だが、これは仕方ない。どうしても CLI に馴染めないなら、タスク処理を Node モジュールとして用意してから

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

のように Node として実行する方法もある。package.json で完結しなくなるけど、特定のタスクランナーに依存することを避けられるメリットは残る。また、Node モジュールなので残りの問題も同時に解決される。

npm-scripts だけでゆく場合は環境依存変数を緩和してくれる npm を利用するとよい。

npm-scripts で使える便利モジュールたち – Qiita が参考になる。この記事と被るものもあるが、私の利用している npm を以下に紹介する。

npm-run-all

npm-run-all は npm-scripts に定義された複数のタスクをまとめて実行してくれる。実行形式も同期・非同期から選べる。厳密な順番が必要なら同期、そうでないなら非同期にするとよい。

例えば Web アプリのリリース用イメージを作成する場合、

  1. イメージ作成用フォルダ dist を用意
  2. HTML や画像などの静的ファイルを dist にコピー
  3. AltJS をコンパイルして dist に出力
  4. AltCSS をコンパイルして dist に出力

のような感じで処理することになるだろう。これらの内、1 と 2 は依存していて 3 以降は 1 より後であればよい。これを踏まえて npm-run-all を利用すると

{
  "scripts": {
    "release:clean": "リリース用フォルダの準備",
    "release:copy": "静的ファイルをコピー",
    "release:js": "JavaScript をリリース用にコンパイル",
    "release:css": "CSS をリリース用にコンパイル",
    "release": "npm-run-all -s release:clean release:copy -p release:js release:css"
  }
}

のような感じになる。-s に続けて記述したものは同期、-p なら非同期に実行される。

Shell スクリプトでコマンドを連結する場合 UNIX 系と Windows で記法が異なるけれど、npm-run-all はそれ自体が連結を担当してくれるので、記法の問題も解決してくれる。

cpx、mkdirp、rimraf

これらはファイルとフォルダ操作系の npm である。

cpx はフォルダ構造を維持したままファイルをコピーするためのツール。gulp だと標準で提供される機能だが、npm-scripts の場合は Shell 依存の cp コマンドなどを利用しないと実現できないので、クロスプラットフォームを目指すなら cpx が役にたつ。

特定の拡張子を持つものをコピーするなら

$ cpx ./src/**/{*.js,*.css} ./dist

という感じで指定する。逆に特定の拡張子を除外したコピーの場合は

$ cpx ./src/**/!(*.js|*.css) ./dist

のようにする。工夫すればワンライナーでも実用十分なコピー処理を書ける。

mkdirp は指定された階層構造も込みでフォルダを作成してくれる。便利だが cpx はコピー先フォルダも作成してくれるので最近は使っていない。cpx の対象とならないフォルダが必要になったら利用するかも。

$ mkdirp ./dist/js ./dist/css

rimraf はフォルダを削除する。UNIX 系の rm -rf に相当し、中身の入っているフォルダも削除可能。

$ rimraf ./dist"

リリース用タスクを繰り返し実行する時、出力先フォルダに前回の結果が残っていると新旧ファイルが混ざって問題になる。これを防ぐため、先に rimraf でフォルダを消してから cpx や mkdirp を実行することでフォルダがクリーンなことを保証できる。

cross-conf-env

npm-scritps で変数を利用するためには npm_package_confignpm_config を利用するのだが、これらを参照するときの記法は実行環境に依存する。cross-conf-env はこれをクロスプラットフォームに記述可能とする。

詳しくは以下を参照のこと。

作者は私。Electron アプリ開発時、npm-scripts を複数プロジェクトで流用したくなって作成した。akabekobeko/examples-electron ではビルドに使用する Electron のバージョンやアプリ名などを変数化している。

package.json のルートに定義された version フィールドなんかも参照できるので、リリース用イメージを ZIP するときのファイル名に version を挿入なんてことも可能。akabekobeko/redmine-theme-minimalflat2 でそのようなタスクを組んでいる。

npm-scripts まめちしき

npm-scripts を運用するうえで知っておくと便利な知識をまとめる。

npm run と既定タスク

npm-scripts に定義されたタスクを CLI から呼び出す場合は

$ npm run task

とする。npm-scripts 内から別タスクを呼び出すときも同様に

{
  "scripts": {
    "task:A": "command -option",
    "task:B": "npm run task:A"
  }
}

とすればよい。これを利用するとタスクからタスクを再利用できる。starttest のように標準で意味付けされたものは npm runrun を省略して npm start のように実行可能。詳しくは scripts を参照のこと。

npm のパス

npm-scripts について解説している記事をみると、たまに

{
  "scripts": {
    "task": "$(npm bin)/command -option"
  }
}

としていたりする。これはプロジェクトのローカルにインストールされた npm を直に呼び出すとき node_modules/.bin を表す。npm-scripts であれば dependencies に登録された npm のパスが通っているので、この記法は不要。

{
  "scripts": {
    "task": "command -option"
  }
}

直に npm が CLI として公開している名前を指定するだけで呼び出せる。なお CLI 名は必ずしも npm の名称と一致するわけではない。例えば uglify-js の CLI は uglifyjs だし npm-run-all のように複数の CLI 名を持つこともある。

そのため必ず npm の README や docs から CLI リファレンスを調べる習慣をつけよう。

タスク内の引用符

npm の CLI オプションで引用符が必要になるとする。package.json は JSON で npm-scripts はフィールドの文字列値となるため、引用符がダブル クォーテーションならバックスラッシュでエスケープする。

{
  "scripts": {
    "task": "command --opt=\"options\""
  }
}

UNIX 系であればエスケープせずにシングル クォーテーションでも動作するが、Windows 環境だとエラーになるので注意する。どちらを採用しても動作は変わらないので、より汎用なエスケープをオススメする。

実践!Web フロントエンド環境構築 2016/10 版

実際に npm-scripts を利用した Web フロントエンド開発用のタスク例をまとめる。内容としては本記事の冒頭にあげた「gulp なしの Web フロントエンド開発」の 2016/10 版となる。

完全なプロジェクトの構成は examples-web-app/front-end-starter を参照のこと。

Babel などの仕組みが変更されたときや気が向いたときに npm を最新にするなどして、なるべく現代的であるように運用している。特定の View 系ライブラリや Flux などの依存も避けているため、本記事の知識で理解可能な作りになっているはず。

設計方針

はじめに環境の設計方針をまとめる。

  • 環境はプロジェクトのローカルで完結させる
    • 基本的に最新 Node.js、package.json、ローカル npm だけ使用する
    • 例えば Node 以外で「〜を入れて」という作業は不要
  • JavaScript コンパイルとファイル監視に対応する
    • AltJS には Babel を採用
    • latest プラグインにより常に最新の ECMAScript でコーディング可能
  • CSS コンパイルとファイル監視に対応する
    • AltCSS には Stylus を採用
  • ユニット テストに対応する
    • mocha と power-assert を採用
    • テスト自体も最新の ECMAScript で記述可能とする
  • コード ドキュメントに対応する
    • ESDoc を採用
  • クロスプラットフォーム対応
    • UNIX 系と Windows 環境で動作する
    • 前述の npm-scripts 向け npm 群により実現

AltJS だけ tsify にするとか webpack 管理にしたり、AltCSS を SCSS や PostCSS に変更してもよい。npm-scripts のタスクとして呼び出せるならば、これらは可換である。

プロジェクトのファイル構成と npm-scripts の全体像

プロジェクトのファイル構成は以下となる。ライセンス情報や README などは必須ではないため除外している。

.
├── esdoc.json
├── package.json
├── dist/
├── node_modules/
├── src/
│   ├── assets/
│   ├── js/
│   └── stylus/
└── test/

各ファイル、フォルダの役割をまとめる。

名前 内容
esdoc.json ESDoc 用の設定ファイル。コード ドキュメントを出力するために必要。
package.json プロジェクト情報や開発用タスクを管理するためのファイル。
dist/ リリース用イメージの出力先となるフォルダ。デプロイ対象となる。
node_modules/ インストールされた npm を格納するフォルダ。
src/ 開発用リソースを格納するフォルダ。
src/assets/ HTML、画像、Web Fonts などの静的リソースを格納するフォルダ。
src/js/ JavaScript 関連を格納するフォルダ。コンパイル結果は src/assets/ または dist/ に出力する。
src/stylus/ Stylus 関連を格納するフォルダ。コンパイル結果は src/assets/ または dist/ に出力する。
test/ ユニット テスト関連を格納するフォルダ。

静的リソースを src/ 直下ではなく src/assets/ としているのはリリース用イメージを生成するときのコピー指定を簡略化するため。このあたりは後ほど詳しく解説する。

以降では npm-scripts や設定を小分けに解説するが、それだと分かりにくいかもしれない。よって nameversion などの基本情報を除く、Web フロントエンド開発に関わるものの全体像も掲載しておく。

{
  "babel": {
    "presets": [
      "latest"
    ],
    "env": {
      "development": {
        "presets": [
          "power-assert"
        ]
      }
    }
  },
  "browserify": {
    "transform": [
      "babelify"
    ]
  },
  "scripts": {
    "test": "mocha --compilers js:babel-register test/**/*.test.js",
    "start": "npm run watch",
    "esdoc": "esdoc -c esdoc.json",
    "build:css": "stylus -c --include-css ./src/stylus/App.styl -o ./src/assets/bundle.css -m --sourcemap-base ../stylus",
    "build:js": "browserify ./src/js/App.js -d | exorcist ./src/assets/bundle.js.map > ./src/assets/bundle.js",
    "build": "npm-run-all -p build:css build:js",
    "watch:css": "stylus -c -w --include-css ./src/stylus/App.styl -o ./src/assets/bundle.css -m --sourcemap-base ../stylus",
    "watch:js": "watchify ./src/js/App.js -v -o \"exorcist ./src/assets/bundle.js.map > ./src/assets/bundle.js\" -d",
    "watch:server": "browser-sync start --server ./ --startPath src/assets/",
    "watch": "npm-run-all -p watch:css watch:js watch:server",
    "release:css": "stylus -c --include-css ./src/stylus/App.styl -o ./dist/bundle.css",
    "release:js": "cross-env NODE_ENV=production browserify ./src/js/App.js | uglifyjs -c warnings=false -m > ./dist/bundle.js",
    "release:clean": "rimraf ./dist",
    "release:copy": "cpx \"./src/assets/**/!(*.js|*.css|*.map)\" ./dist",
    "release": "npm-run-all -s release:clean release:copy -p release:css release:js"
  },
  "dependencies": {
    "normalize.css": "^5.0.0"
  },
  "devDependencies": {
    "babel-preset-latest": "^6.16.0",
    "babel-preset-power-assert": "^1.0.0",
    "babel-register": "^6.16.3",
    "babelify": "^7.3.0",
    "browser-sync": "^2.17.0",
    "browserify": "^13.1.0",
    "cpx": "^1.5.0",
    "cross-env": "^3.1.1",
    "esdoc": "^0.4.8",
    "exorcist": "^0.4.0",
    "mocha": "^3.1.0",
    "npm-run-all": "^3.1.0",
    "power-assert": "^1.4.1",
    "rimraf": "^2.5.4",
    "stylus": "^0.54.5",
    "uglify-js": "^2.7.3",
    "watchify": "^3.7.0"
  }
}

dependenciesdevDependencies は 2016/10/7 時点で最新のもの。

JavaScript コンパイルとファイル監視

JavaScript は ES2015 以降の最新規格で記述可能して ES5 に変換する。

2016/10 時点でも ES2015 に 100% 対応した Web ブラウザは WebKit とそれを使用する Safari 10 ぐらいである。そのため ES2015 であってもしばらくは変換が必要であり、以降の規格も考慮すると今後も変換は前提となるだろう。

コンパイルには npm-scripts だけでなく、ES5 変換で使用する Babel や複数 JavaScript を bundle ( 結合 ) する Browserify の設定も必要。Babel の設定は .babelrc というファイルへ記述するのが一般的である。私は設定を package.json に集約するため、意図的に babel フィールドで指定している。

Browserify は変換に噛ませるプラグイン指定を transform にくくり出せる程度なので、npm-scripts 側へ -t [ babelify ] と CLI オプションで指定するほうがよいかもしれない。タスクが長くなってもよいなら CLI オプションにしよう。

{
  "babel": {
    "presets": [
      "latest"
    ],
    "env": {
      "development": {
        "presets": [
          "power-assert"
        ]
      }
    }
  },
  "browserify": {
    "transform": [
      "babelify"
    ]
  },
  "scripts": {
    "build:js": "browserify ./src/js/App.js -d | exorcist ./src/assets/bundle.js.map > ./src/assets/bundle.js",
    "watch:js": "watchify ./src/js/App.js -v -o \"exorcist ./src/assets/bundle.js.map > ./src/assets/bundle.js\" -d",
    "release:js": "cross-env NODE_ENV=production browserify ./src/js/App.js | uglifyjs -c warnings=false -m > ./dist/bundle.js",
  }
}

各タスクの処理内容を解説する。

タスク 処理
build:js 開発用に JavaScript をコンパイルして Source Maps と一緒に src/assets/ へ JavaScript ファイルを出力する。
watch:js 変更監視つきで JavaScript をコンパイルして Source Maps 一緒に src/assets/ へ JavaScript ファイルを出力する。
release:js リリース用に JavaScript をコンパイルして dist/ へ JavaScript ファイルを出力する。

必要な npm をまとめる。

npm 機能
browserify モジュールとして定義された複数のJavaScript 依存を解決して単一ファイルへ bundle する。プラグインを指定することで bundle 前に AltJS の変換も実行可能。
watchify Browserify のファイル監視 & 差分コンパイル版。
babelify ES2015 以降の機能を使用した JavaScript を ES5 に変換する Babel 公式の Browserify 用プラグイン。
babel-preset-latest ES2015 以降の規格に準じた変換用 Babel プリセットをまとめたもの。詳しくは Latest preset を参照のこと。
cross-env リリース用コンパイル時に Node の環境変数へ NODE_ENV=production を追加して、これを判定しているデバッグ用コードを削除するために使用。
exorcist 開発用に JavaScript を変換する際、Web ブラウザの開発者ツールから元コードでデバッグするための Source Maps ファイルを生成する。Browserify/babelify の変換結果をコマンドライン連結で受け取り Source Maps と JavaScript ファイルを出力する。
uglify-js リリース用コンパイル時、コード圧縮、使用されていない変数や到達不能コードの削除、引数や関数名の短縮などを実行する。これも exorcist と同様にコマンドライン連結で受け取った JavaScript を加工してファイルに出力する。

タスクとしては参照しないが IE11 環境で Promise などを利用するなら babel-polyfill も必要。これは言語機能ではなく API なので latest にも含まれない。そのため個別に追加してコード全体の冒頭へ import することになる。

Browserify で webpack のように複数の JavaScript を生成したい場合は Browserify の require オプションでモジュールを外部公開するを参照のこと。npm-scripts のタスクも生成したい数だけ定義して、まとめて呼び出せばよい。

CSS コンパイルとファイル監視

CSS は Stylus で書いて CSS3 に変換。

{
  "scripts": {
    "build:css": "stylus -c --include-css ./src/stylus/App.styl -o ./src/assets/bundle.css -m --sourcemap-base ../stylus",
    "watch:css": "stylus -c -w --include-css ./src/stylus/App.styl -o ./src/assets/bundle.css -m --sourcemap-base ../stylus",
    "release:css": "stylus -c --include-css ./src/stylus/App.styl -o ./dist/bundle.css"
  }
}

各タスクの処理内容を解説する。

タスク 処理
build:css 開発用に Stylus ファイルをコンパイルして Source Maps と一緒に src/assets/ へ CSS ファイルを出力する。 
watch:css 開発用に Stylus ファイル変更を監視しながら Source Maps と一緒に src/assets/ へ CSS ファイルを出力する。
release:css リリース用に Stylus ファイルをコンパイルして Source Maps と一緒に src/assets/ へ CSS ファイルを出力する。

必要な npm をまとめる。

npm 機能
stylus Stylus ファイルを CSS にコンパイル。Sourcde Maps 生成、圧縮、ファイル監視機能も搭載している。

最近の AltCSS では JavaScript における Babel のように将来標準を先取りする目的で postcss/postcss が台頭してきており、その CLI 版である postcss-cli も Stylus のように単体で開発に必要な機能を網羅している。そのため将来は PostCSS へ移行するかもしれない。

Web サーバー起動

Web フロントエンド部分を動作確認するとき、ローカル ファイルを直に Web ブラウザで表示するとセキュリティ上の問題を引き起こすことがある。これを防ぐため Chrome ではローカル ページ上では Ajax や Web Storage などの利用を抑止する対策が取られている。

しかし開発環境であることを理解して表示する分には、この制限は不便である。そのためローカルに簡易 Web サーバーを起動して Web ページをホストさせる。

{
  "scripts": {
    "watch:server": "browser-sync start --server ./ --startPath src/assets/"
  }
}

各タスクの処理内容を解説する。

タスク 処理
watch:server 指定されたフォルダをルートにして Web サーバー起動、OS 標準の Web ブラウザで表示する。この例ではプロジェクトのルートを指定、表示する初期ページは src/assets/ 内の index.html になる。

必要な npm をまとめる。

npm 機能
browser-sync Web サーバーと Web ブラウザ表示を担当。

browser-sync は非常に多機能。npm-scritps から利用するための CLI については Browsersync Command Line Usage を参照のこと。

特に指定されたファイルの変更を検出して Web ブラウザを自動更新する機能は便利だ。しかし私は変更前の状態を確認しつつ任意のタイミングで手動更新するほうが好みなので使用していない。

自動更新を有効にしたい場合は browser-sync の CLI オプションに reload --files=\"src/**/*\" を追加すれば src/ 配下のファイルが更新されるたびに Web ブラウザに読み込まれたページを自動更新してくれる。

browser-sync の CLI を実行すると Terminal に localhost と IP アドレスの 2 種類、URL が表示される。実行マシンの Web ブラウザには前者が表示される。同一ネットワーク上の別 PC やモバイル端末から動作確認したいなら後者へアクセスすればよい。

レスポンシブデザインを試すだけなら PC 上でもよいが、タッチ操作なども含めた実際の操作感はモバイル端末の実機でチェックしたほうがよい。またオフィス内で他社に Web ページを公開するときもにも、この機能は便利だ。

ユニット テスト

ユニット テストには mocha と power-assert を採用。テストも最新の ECMASCript で記述可能とする。

{
  "babel": {
    "presets": [
      "latest"
    ],
    "env": {
      "development": {
        "presets": [
          "power-assert"
        ]
      }
    }
  },
  "scripts": {
    "test": "mocha --compilers js:babel-register test/**/*.test.js"
  }
}

テスト コードは test/ 配下に置き、テスト対象となる機能を含む JavaScript ファイルの拡張子を .test.js に変えたものとする。このようにすることで

  • テスト対象とテストの対応が分かりやすい
  • Atom などのエディタでテスト対象とテストを並べても名前が衝突しない
  • test/ 配下にテストを含まない補助コードを置いても、それらとテストを区別できる

というメリットがある。

Babel を使用するので .babelrc か package.json の babel フィールドに設定が必要。これは JavaScript コンパイルと共有されるので latest による変換は共通、power-assert は env フィールドで開発時のみ有効にしておく。

こうするとリリース時のコンパイルで NODE_ENV=production が指定されていたら power-assert 処理を除外してくれる。Web フロントエンド開発なのでアプリ側コードに Node の assert が露出することはないけど安全のため指定しておいたほうがよい。

npm を自作するとき、この設定を流用できる利点もある。npm は Node 環境で実行されるため普通に assert を呼べるので。

各タスクの処理内容を解説する。

タスク 処理
test ユニット テストを実行する。

必要な npm をまとめる。

npm 機能
mocha テスト コードを実行するための環境。
babel-preset-latest ES2015 以降の規格に準じた変換用 Babel プリセットをまとめたもの。
babel-preset-power-assert 現在の Babel で power-assert を利用するための Babel prest。
babel-register Babel の JavaScript 変換処理を捕捉して間に処理をはさみこむためのツール。power-assert による Node 標準 assert の置換に使用。

Babel 本体はどこへ?と疑問に思われるかもしれないが、アプリ開発で使用する babelify の依存で確実にインストールされるため問題ない。

ユニット テストは基本的に DOM の絡まないものを対象とする。もし DOM 操作を含むコードをテストしたいなら jsdom などをオプションで追加してもよい。

mocha はひとつのテストにつき標準で 2 秒の制限時間を設けている。jsdom は初期化に時間がかかるため、この制限に引っかかって警告される可能性がある。その場合は mocha の CLI オプションへ --timeout 50000 のように大きめの制限時間を指定することで回避できる。時間の単位はミリ秒なので、この例だと 5 秒になる。

注意点がある。power-assert の置換対象は assert なので

import assert from 'assert';

ならよいが

import Assert from 'assert'

にすると Assert は置換されず、Node 標準の assert になってしまう。そのため import または require 先は必ず assert にすること。

コードド キュメント

最新の ECMAScript で記述されたアプリ実装コードとコメントからコード ドキュメントを生成する。その際、コメント記述のカバレッジや機能とユニット テストの関連付けもおこないたいので ESDoc を利用する。

{
  "scripts": {
    "esdoc": "esdoc -c esdoc.json"
  }
}

コード ドキュメントがあると第三者に設計や実装を説明する時のよい参考資料になる。また、ドキュメント生成を前提とすることで読まれるコメントを書こうと意識するようにもなるだろう。

コードに最も近い場所へ記述される仕様ともいえる。あるクラスや関数が実現したい機能やインターフェースについて書くようにすると、それは自然と現状にそった自己言及的なドキュメントになるはず。

各タスクの処理内容を解説する。

タスク 処理
esdoc アプリの実装コードとテストを解析して HTML の資料を生成する。出力先は esdoc/

必要な npm をまとめる。

npm 機能
esdoc コード ドキュメント生成ツール。

ESDoc の設定は CLI オプションや package.json ではなく esdoc.json に記述する。このファイルはプロジェクト全般の設定に属するため、package.json と同じ階層に保存するとよい。

設定内容については ESDoc – A Documentation Generator For JavaScript(ES6) を参照のこと。

{
  "source": "./src/js",
  "destination": "./esdoc",
  "test": {
    "type": "mocha",
    "source": "./test"
  }
}

ESDoc が出力する HTML はこんな感じになる。ESDoc Hosting Service というサービス ( beta 版 ) も運用されている。ここに登録されたプロジェクトの GitHub 上のリポジトリから esdoc.json を検出すると HTML 生成して Web に公開してくれる。

README.md などにバッジを貼り付けてホストされた HTML へリンクできるので、GitHub でリポジトリ運用している OSS なプロジェクトなら採用をオススメする。

複数のタスクを組み合わせる

JavaScript や CSS 用のタスクを定義したら、これらを組み合わせたくなる。例えば開発時はファイルの自動監視とコンパイルを走らせつつ、動作確認用に Web サーバーを起動したい。このような連結は前述の npm-run-all を利用することでクロスプラットフォームにできる。

{
  "scripts": {
    "build": "npm-run-all -p build:css build:js",
    "watch": "npm-run-all -p watch:css watch:js watch:server",
    "release": "npm-run-all -s release:clean release:copy -p release:css release:js"
  }
}

npm-run-all を前提に完結した小さなタスクを定義してから組み合わせて実行すると管理しやすくなる。組み合わせられるものはタスク名で分類を明示するとよい。私は

タスク 処理
build:XXXX 開発ビルド。XXXX には js や css など処理対象を付与する。
build build:XXXX を組み合わせて実行するタスク。
watch:XXXX ファイル変更の監視つき開発ビルドや Web サーバー起動など。XXXX には js や css など処理対象を付与する。
watch watch:XXXX を組み合わせて実行するタスク。
release:XXXX リリース用ビルド。XXXX には js や css など処理対象を付与する。
release release:XXXX を組み合わせて実行するタスク。

という感じで命名している。

リリース用イメージ生成

Web フロントエンド開発の成果物をリリースするためのイメージを生成する。

{
  "scripts": {
    "release:clean": "rimraf ./dist",
    "release:copy": "cpx \"./src/assets/**/!(*.js|*.css|*.map)\" ./dist",
    "release": "npm-run-all -s release:clean release:copy -p release:css release:js"
  }
}

release:jsrelease:css はそれぞれの項に解説したので割愛。各タスクの処理内容を解説する。

タスク 処理
release:clean リリース用イメージの生成先となる dist/ フォルダを削除。前回の生成結果が残っていないことを保証する。
release:copy src/assets/ 内の静的リリースを対象にフォルダ階層を維持して dist/ にコピー。
release リリース用イメージ生成タスクを連結実行。生成先フォルダの削除と静的リソースのコピーを直列実行することで基本的な dist/ を準備、その後にここへリリース用の JavaScript と CSS を出力する。

必要な npm をまとめる。

npm 機能
rimraf 指定されたフォルダを中身ごと削除する。
cpx glob 形式で指定された条件にあったファイルやフォルダを、所定のフォルダへコピーする。
npm-run-all npm-scripts の複数タスクを連結して直列または並列実行する。

release:copy について補足する。「gulp なしの〜」を書いたときのコピー関連処理は

{
  "scripts": {
    "release:mkdir": "mkdirp ./dist && npm run release:clean && mkdirp ./dist",
    "release:copyfiles": "copyfiles -f ./src/*.html ./dist",
    "release:copydirs": "ncp ./src/fonts ./dist/fonts",
    "release:copy": "npm run release:copyfiles && npm run release:copydirs",
  }
}

のようになっていて非常に複雑だった。あの記事のはてブでもやり過ぎ感あると指摘されていた。これを解決するために

  • 静的リソースは src/ ではなく src/assets/ に置く
    • 静的リソースと動的リソース系の src/js/src/stylus/ を区別しやすくなる
    • src/assets/ に格納されているものは、そのままリリース用にコピー可能とする
    • src/assets/ は開発用ビルドで生成された JavaScript、CSS、Source Maps が動作確認のため出力される
  • cpx でブラック リスト式コピー
    • src/assets/ に出力された動的リソースをブラック リスト化して、それ以外をコピーする
    • 動的リソースはそれらのリリース用ビルドで dist/ へ出力するためコピー不要
    • 不要ファイルのブラック リスト化は glob の否定機能を利用する
    • cpx はコピー先フォルダを生成してくれるので mkdir も不要になる

上記で対応した。npm-scripts が大幅に簡素化され、運用としても静的リリースの置き場所が明確なので管理しやすい。

タスクを利用した開発スタイル

はじめにプロジェクトを構築する。Terminal は Windows の場合 cmd.exePowerShell などになる。

  1. プロジェクト開発フォルダを用意
  2. 開発フォルダのルートに package.json を作成、または既存のものを流用
  3. Terminal から npm i コマンドを実行して npm をインストール
  4. src/ 内に JavaScript、CSS、HTML を実装してゆく
  5. あわせて test/ にテストを書く

開発時に実行すること。

  1. Terminal から npm start コマンドを実行、JavaScript と CSS のファイル監視と Web サーバー起動
    1. この記事の設定なら http://localhost:3000/src/assets/ が Web ブラウザに表示される
    2. Terminal 上に IP アドレス版の URL も表示される
    3. 同一ネットワーク上の PC やスマートフォンで表示する場合は IP アドレス版の URL で OK
  2. JavaScript や Stylus ファイルを編集して保存
    1. Babel や Stylus の自動コンパイルが実行される
    2. Terminal にコンパイル終了が表示されるので Web ブラウザをリロードするときの目安になる
    3. 必要ならテストを書いて別 Terminal または別タブから npm test でチェックする
  3. Web ブラウザをリロードして変更をチェック
    1. http://localhost:3000/src/assets/ をリロード
    2. browser-syncreload オプションで実行しているなら自動化される
  4. 2 〜 3 を繰り返し、終了したくなったら Ctrl + C で中断する

リリース時に実行すること。

  1. Terminal から npm run release コマンドを実行
  2. Terminal をチェックして全タスクが終了するまで見守る
  3. 全タスクが終了したら dist/ の中身をリリース

JenkinsTravis CI などと組み合わせてもよい。例えば全テストを通過したときだけリリース ( デプロイ ) するとか。

まとめ

ここ 1 年ほどの npm-scripts 運用知見をまとめてみた。

クロスプラットフォーム対応も済んでおり、この記事にまとめた内容とほぼ同等の構成で業務プロジェクトも運用している。静的リソースを src/assets/ に集約する設計はデザイナーや HTML コーダーにも好評であった。

Browserify、Babel、Stylus が存続する限り今回の構成で完成形と考えている。もしこれらがなくなったとしても依存は npm だけなので十分に可換である。例えば JavaScript だけ webpack、CSS を PostCSS というのもありだ。

最後に本記事で紹介した npm-run-allcpx の作者であり、npm-scripts 運用についてのアドバイスもいただいた mysticatea (Toru Nagashima) さんに感謝したい。いつもありがとうございます。

npm_package_config と npm_config

2016年9月14日 2 開発 , , , ,

npm-run-allSupport npm config params という issue から npm_config の存在を知った。以前、npm-scripts 内で変数を展開したくなって cross-conf-env を開発した時に npm_package_config は調べたが、この npm_config も外部から npm-scripts にパラメータを渡す仕組みのようである。

これらの性質や用途について、簡単にまとめてみる。

npm_package_config

package.json の config 欄に解説されている。

A “config” object can be used to set configuration parameters used in package scripts that persist across upgrades. For instance, if a package had the following:

{ "name" : "foo"
, "config" : { "port" : "8080" } }

and then had a “start” command that then referenced the npm_package_config_port environment variable, then the user could override that by doing npm config set foo:port 8001.

package.json の config プロパティに key/value を定義することで npm-scripts 内へ npm_package_config_key と展開する機能。

公式資料では言及されていないのだが、npm-scripts は Shell になるため、macOS などの bash 系は $npm_package_config_key、Windows のコマンドプロンプトや PowerShell では %npm_package_config_key% のように定義する。

Node プログラム上からは process.env.npm_package_config_key として参照可能。

私の作成した cross-conf-env では、これらと修飾文字なしの npm_package_config をサポートしており、混在させても展開するところが特徴。つまり Shell を問わず好きな書式で記述できるようにしてある。

この機能を利用すると npm-scripts 内のパラメータを直値から変数にできる。npm-scripts 間で重複する値があるとか、頻繁に更新される値があるなら変数化して config プロパティ側の編集だけで済ませられるようにしておくと便利だ。

実例は akabekobeko/examples-electron を参照のこと。このリポジトリは複数の Electron プロジェクトを管理しているが、これらの npm-scripts は共通化され config プロパティでアプリ名などを分岐している。

npm_config

config に解説されている。この資料は npm_package_config に対しても言及あり。

npm_package_config と異なり、こちらは npm run される時の引数として渡されたパラメータを展開する。例えば

{
  "scripts": {
    "task": "echo npm_config_foo npm_config_bar"
  }
}

のように npm-scripts を定義して、以下のようにパラメータを渡す。

$ npm run task --foo=Foo --bar=Bar

Foo Bar

npm run されるスクリプトのオプションに --key=value を指定すると、スクリプト内の npm_config_key に展開される。Node プログラム上からは process.env.npm_config_key として参照可能。

package.json 外からパラメータを渡すとか、npm-scripts を多段実行する時に npm_package_config 代わりにするとかで役立つのかもしれない。本記事のきっかけとなった npm-run-all は npm-scripts を同期・非同期で多段実行するための npm なので、これに対応する必要があったのだろう。

npm_config が展開される側は Shell なので前述のとおり にプラットフォームごとの修飾文字が必要。cross-conf-env は v1.0.6 で対応した。

まとめ

私はプロジェクトに関する情報をなるべく package.json へ静的に定義したいので、npm_package_config のほうが好みだ。しかしこちらは config の定義が必要なうえ記述も長い。よって npm-scripts の多段実行は必要になるけれど、より短い npm_config も便利な場面があるかもしれない。

package.json で確定不能な設定については npm_config に頼らざるを得ない。npm_scripts を実行するのは主に開発者なので、このようなケースはあまり考えられないのだが、npm から取得できない環境情報なんかを渡すときによいのだろうか。

ほぼ共通の設定で一部だけ異なる npm-scripts があるときに便利かもしれない。そういえば Browserify の require オプションでモジュールを外部公開するを書いた後に業務で

  • 共通処理を定義した main.js
  • サブフォルダ単位で個別のデータを定義した data.js
  • HTML 上で main.js と組み合わせる data.js を変更することで、ページ内容が切り替えられる

という感じの Web サイトをビルドする機会があって、サブフォルダが増えるたびにその名前だけ変更した npm-scripts を追加する運用を考えていた。

しかしこれは面倒なので npm_config によりフォルダ名を部分展開するほうがスマートな気がする。複数同時に生成するとしても、npm-scripts の多段実行を前提とすれば package.json で完結できる。

記事のまとめに軽く考察でも、と雑に書いていたら業務の問題がひとつ解決してしまった。

jsdoc-to-assert を試す

2016年8月23日 0 開発 , , ,

JavaScript の型チェックといえば FlowTypeScript だが、前者は型の定義ファイルが必要で、後者は言語自体を拡張しているため導入コストが少々、高い。もっと手軽に

  • 追加の外部ファイルは不要
  • JavaScript の組み込み型ぐらいをチェックできればよい

ぐらいのものがあれば、と探してみたら azu/jsdoc-to-assert がそんな感じなので試してみる。

2016/8/24
「省略可能な引数」と「null 許容型」の項を追記。

jsdoc-to-assert

jsdoc-to-assert は JSDoc 形式で書かれた関数のコメントを元に型チェックする。開発の経緯や詳細については JSDocをランタイムassertに変換するBabelプラグインを書いた | Web Scratch を参照のこと。おこなわれる処理は単純で、

  • JSDoc の @param から引数の型と名前を取得
  • 関数の冒頭に引数の型チェックを console.assert 形式で埋め込む

というもの。例えば

/**
 * Output log.
 *
 * @param {String} message Message text.
 */
function func( message ) {
  console.log( message );
}

というコードを jsdoc-to-assert に渡すと

/**
 * Output log.
 *
 * @param {String} message Message text.
 */
function func(message) {
  console.assert(typeof message === "string", 'Invalid JSDoc: typeof message === "string"');

  console.log(message);
}

に変換される。型チェックが偽ならば、その情報を assert として出力する。Firefox や Chrome の開発者ツールであれば関数の呼び出しと assert 箇所をコンソールから確認できるので、不正な値を指定した処理を修正するためのヒントになる。

環境構築と注意点

jsdoc-to-assert は本体と Babel plugin/preset 版が提供されている。plugin が Babel 用の機能拡張で、preset はそれをセットでパッケージ化したものになる。

いまのところ preset には babel-plugin-jsdoc-to-assert しか含まれていないので、どちらを選んでも機能差はないはず。とはいえ preset にしておけば依存が増えた時にも対応されるだろうから、今回は babel-preset-jsdoc-to-assert を採用。

以上を踏まえて package.json を定義。

{
  "babel": {
    "passPerPreset": true,
    "presets": [
      "es2015"
    ],
    "env": {
      "development": {
        "presets": [
          "power-assert",
          "jsdoc-to-assert"
        ]
      }
    }
  },
  "browserify": {
    "transform": [
      "babelify"
    ]
  },
  "scripts": {
    "test": "mocha --compilers js:babel-register test/**/*.test.js",
    "build:": "browserify ./src/js/App.js -d | exorcist ./src/assets/bundle.js.map > ./src/assets/bundle.js",
    "watch": "watchify ./src/js/App.js -v -o \"exorcist ./src/assets/bundle.js.map > ./src/assets/bundle.js\" -d",
    "release": "cross-env NODE_ENV=production browserify ./src/js/App.js | uglifyjs -c warnings=false -m > ./dist/bundle.js"
  },
  "devDependencies": {
    "babel-preset-es2015": "^6.13.2",
    "babel-preset-jsdoc-to-assert": "^3.0.2",
    "babel-preset-power-assert": "^1.0.0",
    "babel-register": "^6.11.6",
    "babelify": "^7.3.0",
    "browserify": "^13.1.0",
    "cross-env": "^2.0.0",
    "exorcist": "^0.4.0",
    "mocha": "^3.0.2",
    "npm-run-all": "^3.0.0",
    "power-assert": "^1.4.1",
    "uglify-js": "^2.7.3",
    "watchify": "^3.7.0"
  }
}

Babel の設定は .babelrc に書いてもよい。というか、そのほうが一般的だと思う。私はなるべく package.json にプロジェクト設定を集約したい派なのでこちらへ定義している。Browserify 設定も同様。

Babel 関連の npm をまとめる。

npm 用途
babel-preset-es2015 ES2015 変換用プリセット。
babel-preset-jsdoc-to-assert jsdoc-to-assert を利用するためのプリセット。
babel-preset-power-assert 単体テストで assert を power-assert に置換するためのプリセット。
babel-register Babel 変換時、import/require を補足するためのモジュール。assert の power-assert 置換などに必要。
babelify Browserify 用 Babel プラグイン。

今回は開発用のコードと単体テストの両方で jsdoc-to-assert を試したいので power-assert 関連も利用する。JavaScript 全体の bundle 化用に Browserify を使いたいので、Babel 本体は babelify を採用。Browserify が不要なら babel-cli を選んでもよい。

Babel の設定について。

{
  "babel": {
    "passPerPreset": true,
    "presets": [
      "es2015"
    ],
    "env": {
      "development": {
        "presets": [
          "power-assert",
          "jsdoc-to-assert"
        ]
      }
    }
  }
}

冒頭の passPerPreset が重要で、これを true にするとプラグイン単位で変換をハンドリング可能となる。プラグインの実行順で変換結果に問題が起きたときに指定するオプションである。

jsdoc-to-assert を試したとき Object の property として定義された関数などが無視されたので報告したところ、修正報告にこの設定を有効にしてほしいと回答されていたので、そのようにしている。

もうひとつ、jsdoc-to-assert の処理が開発版でだけ動作するように envdevelopment 側に定義している。こうすると NODE_ENV=production のときは除外される。npm-scripts の

{
  "scripts": {
    "release": "cross-env NODE_ENV=production browserify ./src/js/App.js | uglifyjs -c warnings=false -m > ./dist/bundle.js"
  },
}

がその指定。通常、npm-scripts から NODE_ENV は操作できないので cross-env を利用した。この方法は React をリリース用ビルドして余計な開発用コードを除去するなどでも必要なので、個人的に定石となっている。

実行してみる

package.json の定義と型チェック用の JavaScript を用意したら、実行してみよう。

$ npm run build

出力された bundle.js をテキスト エディタで開いてみると、console.assert が挿入されることを確認できるはず。わざと間違った型を指定子したスクリプトを実装して、それを読み込んだ HTML を Web ブラウザの開発者ツールで見てみると

jsdoc-to-assert による assert

こんな感じで assert が出力されている。なお、jsdoc-to-assert v2.4.2 時点では以下の関数が assert 対象となる。

  • キーワード function で定義される通常の関数
  • Object の property として定義された関数
  • ES2015 class の constructor
  • ES2015 class のメソッド
  • ES2015 class の static メソッド
  • ES2015 class の property setter

ES2015 の Arrow Function は対応していないようだが、これはコールバックなどで局所的に使用されることが多く、それに対して JSDoc を書く機会は少ないだろうし、なくてもさほど困らない。

また、型チェックの対象となるのは JavaScript の組み込み型になる。ES2015 class を定義し、それを型として JSDoc の param に指定しても無視される。

もうひとつ注意。jsdoc-to-assert は param が正しく書かれていることを前提としている。よって変数名が間違っていると常に assert がヒットすることになるので、気をつけること。引数の名前を修正するとき JSDoc に反映し忘れるとこの問題に遭遇する。というか、した。

単体テストの assert

単体テストの対象となるコードに jsdoc-to-assert が適用されるとどうなるのか。私の利用している mocha + power-assert の組み合わせで試してみた。

describe( 'Valid', () => {
  describe( 'Sample', () => {
    it( 'constructor', () => {
      const sample = new Sample( 'message' );
      assert( sample.message === 'message' );
    } );
  } );
} );

のような正常系と

describe( 'Invalid', () => {
  /** @test {Sample} */
  describe( 'Sample', () => {
    /** @test {Sample#constructor} */
    it( 'constructor', () => {
      const sample = new Sample( 7 );
      assert( sample.message === 'message' );
    } );
  } );
} );

という異常系を実装してテストを走らせてみる。

$ npm test

> using-jsdoc-to-assert@1.0.0 test .../jsdoc-to-assert
> mocha --compilers js:babel-register test/**/*.test.js



  Valid
    Sample
      ✓ constructor

  Invalid
    Sample
      1) constructor


  1 passing (2ms)
  1 failing

  1) Invalid Sample constructor:

      AssertionError: Invalid JSDoc: typeof message === "string"
      + expected - actual

      -false
      +true

      at Console.assert (console.js:95:23)
      at new Sample (Sample.js:52:26)
      at Context.<anonymous> (Sample.test.js:96:22)

jsdoc-to-assert の埋め込むものは console.assert なので mocha 上の扱いが気になっていたが、普通に failing となった。power-assert のように値の詳細は表示されないものの、位置はわかるので型を修正するヒントとしては十分である。

余談だが、単体テストで実行される関数がどのように変換されたかを知りたい場合は

describe( 'Invalid', () => {
  it( 'logStrStatic', () => {
    Sample.logStrStatic( 'message' );
    console.log( String( Sample.logStrStatic ) );
  } );
} );

のように関数自体を String で文字列化して console.log するとよい。出力結果は

function logStrStatic(message) {
      console.assert(typeof message === "string", 'Invalid JSDoc: typeof message === "string"');

      console.log(message);
    }

となる。この例だと jsdoc-to-assert により埋め込まれたコードを確認できる。

省略可能な引数

関数を設計する際、オプション扱いの設定などを省略可能にすることがある。例えば

/**
 * 指定されたファイルにデータを書き込みます。
 *
 * @param {String} path 書き込み対象となるファイルのパス情報。
 * @param {Buffer} data 書き込むデータ。
 * @param {String} mode 書き込みモード。省略時はファイルを新規作成します。
 *
 * @return {Boolean} 成功時は true。
 */
function writeFile( path, data, mode = 'w' ) {
}

という関数があったとする。引数のうち pathdata は必須だが mode は省略可能で、その場合はデフォルトの動作をする仕様。これをそのまま jsdoc-to-assert すると mode の型チェックが挿入されるため、省略したときに assert されてしまう。

Use JSDoc: @param の Optional parameters and default values を読むと省略可能な引数は {String=} のように記述するようだ。しかし jsdoc-to-assert では型として認識されず assert が生成されない。

よって型の列挙を代替案とする。

/**
 * 指定されたファイルにデータを書き込みます。
 *
 * @param {String} path 書き込み対象となるファイルのパス情報。
 * @param {Buffer} data 書き込むデータ。
 * @param {String|undefined} mode 書き込みモード。省略時はファイルを新規作成します。
 *
 * @return {Boolean} 成功時は true。
 */
function writeFile( path, data, mode = 'w' ) {
}

mode の型を String から String|undefined に変更することで、mode が undefined にもなり得ることを示す。これを jsdoc-to-assert すると

function writeFile( path, data, mode ) {
  // ...略
  console.assert(typeof mode === "string" || typeof undefined === "undefined" || mode instanceof undefined, 'Invalid JSDoc: typeof mode === "string" || (\ntypeof undefined === "undefined" || mode instanceof undefined\n)');
}

というコードが生成される。param の型に undefined が登場すると assert 内で常に true となる箇所が埋め込まれる。そのため評価がここに達すれば常に assert を通るわけだ。結果として省略可能な引数の指定に使える。

後続に到達できない instanceof が登場することから、もしかするとバグなのかもしれない。なお undefined のかわりに null を指定したときも同様の assert が生成される。

null の型は object なので、これを列挙される型として判定するなら value !== null のように値そのものが null であることを調べなくてはいけない。

null 許容型

省略可能な引数の扱いを調べていて、そういえば JSDoc 的に null 許容型はどういう扱いなんだろう?とググッてみたら Use JSDoc: @type に説明されていた。

Number 型があるとして、それを null 許容型にするなら {?Number}、許容しないなら {!Number} とする。試しにこれを jsdoc-to-assert に渡してみたら

/**
 * Output log.
 *
 * @param {String|?String} message Message text.
 * @param {String|!String} message2 Message text.
 */
function func(message, message2) {
  console.assert(typeof message === "string" || message == null || typeof message === "string", 'Invalid JSDoc: typeof message === "string" || (message == null || typeof message === "string")');
  console.assert(typeof message2 === "string" || message2 != null && typeof message2 === "string", 'Invalid JSDoc: typeof message2 === "string" || (message2 != null && typeof message2 === "string")');

  console.log(message);
}

というコードが生成された値をちゃんと null 判定しており、しかも許容と非許容も区別されている。判定の演算子を ===!== にしないのは null 判定においてを厳密に型チェックする意味がないからだろうか。

なお、null 許容だけだと型チェックが抜けるので、本来の型も列挙しておいたほうがよい。

あと ESDoc もこの記法に対応していた。null 許容と非許容を適切に判定し、元の型へのリンクを生成しつつ Attribute 欄に nullable: false/true を記述してくれる。

この情報を知れたのは収穫だった。

まとめ

JavaScritp 組み込み型に限定されるが、JSDoc さえキッチリ書いておけば、それだけで型チェックできるというのは便利ではなかろうか。独自の型を使用するような場所ではダック タイピングすればよく、その場合は assert ではなくユーザー向けコードでも明示的にインターフェースのチェックとエラー処理を実行したくなるだろう。

そういう意味でも今の私なら jsdoc-to-assert ぐらいで実用十分である。

今回の記事で作成したサンプル プロジェクトを以下に公開した。実際に動かして、assert がどのように出力されるか、コンパイルされたコードはどうなっているかを確認できる。