npm 開発で再び Babel を導入することにした

2017年4月17日 0 開発 , , ,

以前、npm 開発で脱 Babel してみるという記事を書いた。そして二ヶ月ほど特に問題なく運用できていたのだが、babel-preset-env を試してみたら考えが変わった。

脱 Babel を決めた時点では latest で常時 ES5 変換か plugin を細かく組み合わせることを想定していた。しかし babel-preset-env なら明示的に Node のバージョンを指定することで必要最小の変換をおこなえる。Node としては ES Modules 以外の ES.next 仕様へ積極対応しているため、Active な LTS を下限としておけば変換は ES Modules + α ぐらいで済む。

というわけで Babel を再導入することにした。

プロジェクト構成

Babel を再導入した akabekobeko/npm-xlsx-extractor の構成例。

.
├── package.json
├── examples/
├── dist/
└── src/
    ├── bin
    └── lib
名前 内容
package.json プロジェクト設定ファイル。
dist/ リリース用ディレクトリ。ビルドによって動的生成される。.gitignore 対象。
src/ 開発用ディレクトリ。
src/bin npm を CLI として実行した時のコードを格納するディレクトリ。
src/ npm を Node として実行した時のコードを格納するディレクトリ。

よくある構成だとこれに mocha などのユニット テストを格納するため test/ があるものだけど、Babel 再導入にあたりテストは対象となるコードのあるディレクトリに併置した。

プロジェクト設定

プロジェクト設定はすべて package.json に定義。必要最小の内容を抜粋する。

{
  "engines": {
    "node": ">= 6"
  },
  "main": "dist/lib/index.js",
  "bin": "dist/bin/index.js",
  "files": ["dist"],
  "esdoc": {
    "source": "./src",
    "destination": "./esdoc",
    "test": {"type": "mocha", "source": "./src"}
  },
  "babel": {
    "presets": [
      ["env", {"targets": {"node": 6}}]
    ],
    "env": {
      "development": {
        "presets": [
          "power-assert"
        ]
      }
    }
  },
  "scripts": {
    "test": "mocha --timeout 50000 --compilers js:babel-register src/**/*.test.js",
    "start": "npm run watch",
    "esdoc": "esdoc",
    "eslint": "eslint ./src",
    "build": "babel src --out-dir dist --ignore *.test.js,typedef.js",
    "watch": "babel src --out-dir dist --ignore *.test.js,typedef.js --watch",
    "prepare": "npm run build"
  },
  "devDependencies": {
    "babel-cli": "^6.24.1",
    "babel-preset-env": "^1.3.3",
    "babel-preset-power-assert": "^1.0.0",
    "babel-register": "^6.23.0",
    "esdoc": "^0.5.2",
    "eslint": "^3.15.0",
    "eslint-config-standard": "^6.2.1",
    "eslint-plugin-promise": "^3.4.2",
    "eslint-plugin-standard": "^2.0.1",
    "mocha": "^3.2.0",
    "power-assert": "^1.4.2"
  }
}

順に解説する。

ビルド設定

babel-preset-env を "node": 6 にする。2017/4/1 をもって Node v4 LST は Maintenance になったので、Active な v6 LTS を下限としている。すべての dependencies が v4 対応しているなら Babel がよしなに変換してくれるので "node": 4 にしてもよい。

Babel 再導入を機にコードを ES2015 以降の仕様で書き直した。Node は v6 で大半の ES2015 仕様に対応したので大きな変換は Modules と CommonJS 変換ぐらいとなる。ビルド関係の npm-scripts は以下。

{
  "scripts": {
    "build": "babel src --out-dir dist --ignore *.test.js,typedef.js",
    "watch": "babel src --out-dir dist --ignore *.test.js,typedef.js --watch"
  }  
}

ユニット テストは src/ 内で対象となるコードと併置している。また ESDoc 用に class や module として定義されないものを記述した typedef.js がある。これらは Babel 変換する意味がないので bbel-cli の --ignore オプションで除外。コード検証は基本的にユニット テストを利用するが、npm link で CLI の実操作を試しやすくするために watch タスクを定義している。

ビルド結果の出力先は dist/ にしている。以前はプロジェクト直下に bin/lib/ を生成していたが、現時点の npm react 構造を参考に変えた。node_modules の中身を気にするユーザーはそれほどいないし package.jsonmainbin を明示できるのだから階層が深くなっても問題ないはず。

注意点がひとつ。過去に Babel で変換していた時もそうしていたのだが、export default class XlsxExtractor が CommonJS 化されると exports.default = XlsxExtractor; になり、これをそのまま require した場合、constructor を利用できなくなる。この問題を回避するためには

import XlsxExtractor from './xlsx-extractor.js'
module.exports = XlsxExtractor

という仲介処理を定義し、それを npm の main にエントリー ポイント指定する。このファイルは以下のように変換され、

'use strict';

var _xlsxExtractor = require('./xlsx-extractor.js');

var _xlsxExtractor2 = _interopRequireDefault(_xlsxExtractor);

function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }

module.exports = _xlsxExtractor2.default;

通常の require で読んだ状態と等しくなる。Babel 変換対象の範囲内であればうまく解決されるのだが、npm としてエントリー ポイントを提供する場合は忘れずに対応しておく。

私はこれを忘れてリリースして examples が動かず冷や汗をかいた。

ユニット テスト、ESLint、ESDoc

wip-testable-js.md の影響でテスト用コードと対象を併置。具体的には以下となる。

.
├── bin
│   ├── cli.js
│   ├── cli.test.js
│   └── index.js
└── lib
    ├── index.js
    ├── xlsx-extractor.js
    ├── xlsx-extractor.test.js
    ├── xlsx-util.js
    └── xlsx-util.test.js

もともと Atom などテキスト エディター上でテストと対象コードを同時にタブ表示しても区別しやすくするため a.js のテストは a.test.js と命名していた。そのため併置によるファイル名の競合は発生しない。またきっかけとなった gist にも解説されているとおり

  • テストから対象への相対参照パスが短縮される
    • 併置されているため '../../src/lib/a.js'./a.js と書ける
    • ディレクトリ構造を変更してもテストと対象の併置を維持すればパス変更しなくて済む
  • テストと対象の距離が近い
    • これ以上ない近さ
    • 往復が容易なのでテストを書いたり内容を確認する負担が軽減される
  • テストの有無を視認しやすい
    • *.test.js の併置を確認すればよい
    • なければテストなしと判断できる

といったメリットがある。あらゆるコードが src 内に集約される。よってユニット テスト、ESLint、ESDoc もこのディレクトリだけを対象にすればよい。

{
  "esdoc": {
    "source": "./src",
    "destination": "./esdoc",
    "test": {
      "type": "mocha",
      "source": "./src"
    }
  },
  "scripts": {
    "test": "mocha --timeout 50000 --compilers js:babel-register src/**/*.test.js",
    "esdoc": "esdoc",
    "eslint": "eslint ./src"
  }
}

あと ESDoc で CommonJS をサポートするためには esdoc-node が必要だが ESDoc 公式 plugin ではないため ESDoc Hosting Service 上で動かせない。この点については作者の @h13i32maru さんと Twitter 上で相談して plugin の公式化を検討していただいている。しかし今回の対応で Babel 再導入を前提にコードを ES.next で書き直した。よって esdoc-node は不要となり、そのまま ESDoc Hosting Service を利用できる。

publish

Babel 再導入により npm を publish する際にビルドが必要となる。これは以前、prepublish で定義していたが npm v4 から deprecated になったので prepare を利用するように修正。

{
  "main": "dist/lib/xlsx-extractor.js",
  "bin": "dist/bin/index.js",
  "files": ["dist"],
  "scripts": {
    "build": "babel src --out-dir dist --ignore *.test.js,typedef.js",
    "prepare": "npm run build"
  }  
}

ビルド結果を dist にしているため、files の内容がスッキリした。

まとめ

対象 Node に応じたコード修正コストを babel-preset-env に丸投げできるのは便利だ。もっと早く知っていれば脱 Babel しなかったかもしれない。依然として変換品質の懸念はあるものの、自前で Node の Release note や ECMAScript 6 compatibility table を調べて対応するよりもはマシなはず。

他の npm も Babel 再導入とユニット テスト併置などを反映させてゆく予定。

併置といえば Web フロント エンド開発だと CSS Modules により View コンポーネントと CSS の関係も見直されている。これも面白い潮流だ。React の JSX において関心と技術の分離は異なるという話があった。ユニット テストや CSS も対象と密なら近接するのが自然である。

Transpiler や Bundler を前提とすることで JavaScript における動作環境や技術の分離に関する問題は解決されつつあり、より本質的な関心事にもとづいて設計できる時代になったと感じる。

babel-preset-env を試す

2017年3月29日 0 開発 , , ,

npm として配布するものは純粋な Node 機能のみで構成したいため脱 Babelしたが、Web フロントエンドや Electron では最新の ECMAScript 機能を利用したい。

というわけで、これまでは Babel + babel-preset-latest で JavaScript を変換してきた。しかし latest だと Web ブラウザーや Electron が最新規格に対応しても個別に変換を無効化するのが難しい。

例えば ES2015 Classes は大半の Web ブラウザーが対応済みにも関わらず

var Sample = function () {
  function Sample() {
    _classCallCheck(this, Sample);
  }
}

のように変換されてしまう。一方、機能単位で変換を無効にできるとしても Web ブラウザー毎の対応状況を調べるのは実に面倒。ECMAScript 6 compatibility table をマメにチェックしながら下限となる環境を決める必要がある。

こうした悩みを解決するツールとして babel-preset-env が提供されている。用途を latest と比較すると以下のようになる。

preset 用途
latest 最新 ECMAScript を常時 ES5 相当に変換。
env 最新 ECMAScript を指定された環境に基づき最小限 ES5 に変換。

対象環境を考えるのが面倒で変換コストや品質を気にしないなら latest、なるべく無駄な変換をなくしたいなら env を採用することになるだろう。

試してみる

babel-preset-env を試してみる。

Web フロントエンドや Electron だと Babel 単体よりも Browserify + babelifywebpack + babel-loader のように bundler と組み合わせて利用する機会が多い。しかし bundler 部分も含めると Babel が変換した結果に限定してチェックするのが難しいため実行には babel-cli を採用することにした。

変換対象として ES2015 と ES2016 の代表的な機能を使用した 2 種類の JavaScript を定義。まずは sample.js

// ES2015
export default class Sample {
  message (text) {
    console.log(text)
  }

  async asyncFunc () {
    const wait = (n) => {
      return new Promise((resolve) => setTimeout(() => resolve(n), n))
    }

    await wait(1000)
    console.log('finish!!')
  }

  static func (text = 'sample') {
    console.log(text)
  }
}

// ES2016: Exponentiation Operator
export function pow (a = 0, b = 0) {
  return a ** b
}

// ES2016: Array.prototype.includes
export function includes (arr = [], value) {
  return Array.isArray(arr) ? arr.includes(value) : false
}

sample.js を参照する index.js

import Sample, { pow, includes } from './sample.js'

{
  const sample = new Sample()
  sample.message('Message1')
}

Sample.func('Message2')

console.log(pow(2, 3))
console.log(includes([1, 2, 3], 2))

これらを babel-preset-env の README に掲載されている Examples と現時点で最新の Electron v1.6 向け設定で変換する npm-scripts を定義した。Babel の設定も含めて package.json で完結しているため長いけれど全掲載する。

{
  "name": "using-babel-preset-env",
  "version": "1.0.0",
  "description": "",
  "author": "akabeko",
  "license": "MIT",
  "main": "index.js",
  "keywords": [
    "babel-preset-env"
  ],
  "repository": {
    "type": "git",
    "url": "https://github.com/akabekobeko/examples-web-app.git"
  },
  "babel": {
    "env": {
      "default": {
        "presets": [["env"]]
      },
      "chrome52": {
        "presets": [
          ["env", {
            "targets": {
              "chrome": 52
            }
          }]
        ]
      },
      "chrome52webpack_loose": {
        "presets": [
          ["env", {
            "targets": {
              "chrome": 52
            },
            "modules": false,
            "loose": true
          }]
        ]
      },
      "browserslist": {
        "presets": [
          ["env", {
            "targets": {
              "chrome": 52,
              "browsers": ["last 2 versions", "safari 7"]
            }
          }]
        ]
      },
      "node_current": {
        "presets": [
          ["env", {
            "targets": {
              "node": "current"
            }
          }]
        ]
      },
      "debug_output": {
        "presets": [
          ["env", {
            "targets": {
              "safari": 10
            },
            "modules": false,
            "useBuiltIns": true,
            "debug": true
          }]
        ]
      },
      "include_exclude_plugins_buildin": {
        "presets": [
          ["env", {
            "targets": {
              "browsers": ["last 2 versions", "safari >= 7"]
            },
            "include": ["transform-es2015-arrow-functions", "es6.map"],
            "exclude": ["transform-regenerator", "es6.set"]
          }]
        ]
      },
      "electron": {
        "presets": [
          ["env", {
            "targets": {
              "electron": 1.6
            }
          }]
        ]
      }
    }
  },
  "scripts": {
    "start": "run-s build",
    "build:default": "cross-env NODE_ENV=default babel ./src --out-dir ./dist/default",
    "build:chrome52": "cross-env NODE_ENV=chrome52 babel ./src --out-dir ./dist/chrome52",
    "build:chrome52webpack_loose": "cross-env NODE_ENV=chrome52webpack_loose babel ./src --out-dir ./dist/chrome52webpack_loose",
    "build:browserslist": "cross-env NODE_ENV=browserslist babel ./src --out-dir ./dist/browserslist",
    "build:node_current": "cross-env NODE_ENV=node_current babel ./src --out-dir ./dist/node_current",
    "build:debug_output": "cross-env NODE_ENV=debug_output babel ./src --out-dir ./dist/debug_output",
    "build:include_exclude_plugins_buildin": "cross-env NODE_ENV=include_exclude_plugins_buildin babel ./src --out-dir ./dist/include_exclude_plugins_buildin",
    "build:electron": "cross-env NODE_ENV=electron babel ./src --out-dir ./dist/electron",
    "build": "run-s build:*"
  },
  "devDependencies": {
    "babel-cli": "^6.24.0",
    "babel-preset-env": "^1.2.2",
    "cross-env": "^3.2.4",
    "npm-run-all": "^4.0.2"
  }
}

Babel の設定は NODE_ENV 単位に定義できることを利用して cross-env により分岐している。npm start を実行すると dist/ 配下に設定ごとの変換結果が出力される。普通に指定するなら presets 配下の内容を Babel 設定のルートに記述すればよい。例えば .babelrc に Electron 用の設定を定義するなら以下のようにする。

{
  "presets": [
    ["env", {
      "targets": {
        "electron": 1.6
      }
    }]
  ]
}

サンプルの出力結果を比較すると、例えば default なら全変換され chrome52webpack_loose だと async/await 以外はそのままであることが分かる。また chrome52 では async/await 変換があり electron だと v1.6.0 から Chromium 56.0.2924.87 を採用しているため async/await はそのままに Modules は共通して変換されていた。

babel-preset-env の README によれば、変換の基準は前述の ECMAScript 6 compatibility table を基準として判断しているのだという。つまり機能と対象環境の組み合わせ管理を babel-preset-env に丸投げできるわけだ。本当に ECMAScript 6 compatibility table や Electron のバージョンをチェックしているのか?と targets にデタラメな値を設定してみたら実行時にエラーとなった。Electron に関しては 1.0 以降が対象で最新の 1.6 系まで指定可能。

ただし targets のバージョンは JSON の number になるため小数点第一位までしか対応しておらず 1.6.2 のように semver 形式は受け付けていない。また "node": "current" は可能だが "electron": "current" を指定したらエラーになった。Electron も browsers のように相対値で指定したいものだ。

とはいえ babel-preset-latest を使用しつつ「将来 ES2015 変換をやめたくなったらどうしよう?」などと考えていたので、そのような処理を動作環境の指定へ抽象化して変換してくれる babel-preset-env は実にありがたい。

babel-preset-env 設定により想定している動作環境が明示される点もメリット。これはアプリの仕様を外部へ説明するのに役立つ。また設定を Git リポジトリなどで管理しているなら動作環境の変遷も記録される。例えば IE サポートを打ち切るとして、その履歴をきちんと残せるのだ。

最後に動作確認で使用したサンプル プロジェクトを公開しておく。

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) さんに感謝したい。いつもありがとうございます。