npm 開発で脱 Babel してみる

2017年1月25日 0 開発 , , ,

自作 npm の開発で脱 Babel したときの対応と問題点まとめ。

  • 2017/1/31 訂正
    power-assert の作者、t_wada さんより power-assert は babel-register を通すなどしないと assert 置換が働かず素の assert になってしまうという指摘があったのでユニットテスト関連の記述を訂正

脱 Babel を決めた背景

私はいくつか自作 npm を公開している。これらは ES2015 以降の機能と構文を利用して npm publish の際に Babel で ES5 相当へ transpile している。この運用で特に問題も起きていない。またプリセットに babel-preset-latest を採用することで ES 関係の規格追従を Babel 任せにできる安心感もあり、ずっとこのままでいいと思っていた。

ある日、職場で Node アプリを開発している人から「Babel 依存は怖くないですか?」という質問があった。Babel にバグがあったら調査や修正は困難だし、使わなくてよいならばそうするに越したことはないのでは?と。

これまで C++、C#、Java などコンパイル前提の言語で開発した経験から、よほどのことがない限りコンパイル結果は信頼に足ると判断していた。また、コンパイルされたマシン語や中間言語を人間が直に記述するのは非常にキツイ。そのため脱コンパイラーという選択肢は現実的ではないと認識している。

一方、JavaScript における transpile は高級言語どうしの変換となる。その気になれば人間が書けるのだ。

transpiler への慣れから、この事実をすっかり忘れていた。Node であれば Web フロントエンドと異なり動作環境の分岐は少なく、package.json の engines にて対象環境を限定することも可能である。ならば新しい Node を前提として脱 Babel を検討してみるのもよさそうと考えはじめた。

そんな折、npm-run-all が v4.0.0 で Babel による transpile を廃止。Node は v4 時点で ES Modules を除く大半の ES2015 機能が実装されているため、この範囲で足りるなら transpile せずにそのままリリース可能だ。その前例として普段利用しているツールが脱 Babel したのはインパクトある。

これらを踏まえ、まずは自作 npm のうちダウンロード数の少なくニッチな wpxml2md から脱 Babel を試してみることにした。

脱 Babel への道のり

脱 Babel 対応で実施したことを書く。

Node 環境の明示的な指定

脱 Babel における前提条件として動作環境とする Node のバージョンを決める。npm-run-all は Node v4 を下限としているようだが、wpxml2md では v6 としておく。

開発と検証コストを考慮して自作 npm の動作環境は「最新 + 最新 LTS」としている。2017/1 時点の最新 Node は v7 系、LTS は v6 と v4 があるため対象は「v7 + v6」となる。これまでは Babel による transpile で v4 以下でも動作していたのだが、これを廃止することで明示的な下限の指定が必要となった。これは package.json の engines プロパティに記述する。

{
  "engines": {
    "node": ">= 6"
  }
}

ちなみに

node v6, v7

というバッヂを用意していて前から README へ掲載していた。今回の対応により、ようやくこれが本来の意味をあらわすようになった。

Babel の transpile を廃止

これまでは Babel の transpile を前提として以下のような Babel 設定と npm-scripts を利用していた。

{
  "babel": {
    "presets": [
      "latest"
    ],
    "env": {
      "development": {
        "presets": [
          "power-assert"
        ]
      }
    }
  },
  "scripts": {
    "test": "mocha --timeout 50000 --compilers js:babel-register test/**/*.test.js",
    "watch": "babel src --out-dir ./ --watch",
    "start": "npm run watch",
    "build": "babel src --out-dir ./",
    "prepublish": "npm run build"
  }
}
script 内容
test 予約された npm run のタスク。 mocha と power-assert によるユニット テスト。
watch 開発用。ファイル監視による自動 transpile を実行。
start 予約された npm run のタスク。watch を呼び出すだけ。
build リリース用。現時点のソース コードで transpile を実行。
prepublish 予約された npm publish 時に呼び出されるタスク。build を呼び出しているため npm として公開されるイメージは transpile されたものになる。なお prepublish は現在 deprecated になっていて prepare へ修正すべきなのだが直し忘れていた。

これが脱 Babel によりこうなる。

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

transpile 不要となるため関連するタスクが消え、ユニット テストだけが残った。ただし power-assert を利用しているなら標準 assert を置換するための transpile が必要なので、このための設定は維持する。

env.development.presetspower-assert だけ指定することで、Babel 依存がユニット テストに限定されていることが明示されるだろう。必要な Babel 関連の npm も babel-registerbabel-preset-power-assert だけになる。

ES Moduels を CommonJS 化する

Node の ES Modules 対応については以下が詳しい。

なお現時点の最新 Node である v7 においても ES Modules には対応していないため、export/import は CommonJS の exports/require へ修正する必要がある。例えば

export const Options = {
};

export default class CLI {
}

const Options = {
};

class CLI {
}

module.exports = {
  Options: Options,
  CLI: CLI
};

とする。読み込む側は

import CLI from `cli.js`;
import { Options } from `cli.js`;

const CLI = require( `cli.js` ).CLI;
const Options = require( `cli.js` ).Options;

とする。ひとつのモジュールから exportdefault export で複数のインターフェースを公開していると面倒である。しかし ES Modules で書いていたなら import はソース コード冒頭に集約されているから悩む余地なく機会的な作業になる。なんなら正規表現でまとめて変換できる。

ユニット テストについても同様に対応すること。

余談。

CommonJS にした後でも将来の ES Modules 移行を容易にするため require はソース コード冒頭へ書く習慣をつけたほうがよいかもしれない。その場合、読み込み先を camelCase で命名しているとローカル変数と競合する可能性が高くなるため PascalCase にしたくなり、私はそうしている。例えば fsFs と命名している。

Node と CommonJS だとスコープを意識して require を使い分け、なるべく関数ローカルで宣言する派が多数な感じなので ES Modules 対応されたときが気になる。私のようにするか、それともよりよい慣習となるのか?実に楽しみだ。

ESDoc 対応

npm のコード ドキュメント生成に ESDoc を採用している場合、そのままでは CommonJS を解釈できないので対応が必要になる。CommonJS は

上記 issue で紹介されている esdoc-node により対応できる。ESDoc にプラグイン機能があることを初めて知った。これを指定することでコードが ESDoc に解釈される前処理を実行できるらしい。例えば esdoc-node は CommonJS を ES Modules に変換して ESDoc に渡す。

さっそく使ってみよう。まず esdoc-node をプロジェクトに追加。

$ npm install -D esdoc-node

次にこれを ESDoc 設定へ追加する。私は ESDoc の設定を package.json に定義しているので

{
  "esdoc": {
    "source": "./src",
    "destination": "./esdoc",
    "test": {
      "type": "mocha",
      "source": "./test"
    },
    "plugins": [
      { "name": "esdoc-node" }
    ]
  }
}

このようにした。設定後、ESDoc を実行して CommonJS なコードも正しく解析されることを確認。

ただしローカル実行ではなく ESDoc Hosting Service は CommonJS やプラグインに対応していないようだ。試しに wpxml2md の API Document を生成 してみたのだが、2017/1/25 時点では esdoc-node 未指定の状態と同様に CommonJS の絡むものが解釈できていない。

この点について要望 issue を登録してみたのだけど、もしプラグイン対応する場合

  • Hosting Service 側でプラグインを網羅しておき選択実行する
    • プラグインを中央管理する仕組みがないと網羅できない
    • プラグインのバージョンはどうするのか?常に最新?
  • プロジェクトの ESDoc 設定と package.json から動的にプラグインを決定する
    • プラグインのインストールはどうする?
    • ドキュメント生成とごにプラグインをインストールすると Hosting Service の負荷が大きい
    • CI 系サービスのように VM や Docker コンテナで対応するとしても負荷は大きい

といった問題が予想されるため難しそうだ。しかし要望があることだけは記録しておきたかったので issue 登録することにした。

別の方法として ESDoc 本体が esdoc-node 処理を取り込むという選択肢もある。ただ、いずれ Node が ES Modules へ正式に対応するとしたら CommonJS は過渡期の存在である。

  • そのために対応コストを割くのか?
  • ESDoc と名乗るツールとして ECMAScript とは直に関係しない CommonJS へ対応することは設計思想として望ましくないのでは?

という考えもあるだろう。どのような対応、または非対応のままになったとしても ESDoc 作者の意向を尊重したい。

ESDoc Hosting Service を利用しない場合、対象プロジェクトが GitHub で管理されているならそのリポジトリに対して GitHub Pages を使う手もある。ローカルの ESDoc + esdoc-node で出力したものを自前でアップロードするか、そういうタスクを npm-scripts に定義して CI サービス経由で生成から公開まで自動化するなどの方法が考えられる。

本記事を書いた直後に ESDoc 作者の @h13i32maru さんから見解が。

ESDoc Hosting Service 的にはセキュリティや負荷を考慮し、ESDoc 公式プラグインのみサポートしているとのこと。ただ Node の ES Modules 対応は相当に先となりそうなので、この長い過渡期に Node かつ脱 Babel したい開発者としてどうか?という悩ましい課題がある。

まとめ

一部、問題もあったが transpile 不要となったことで Node のバージョンだけを意識して開発すればよい。記述したコードはそのまま実行されることが保証される。Babel 由来の潜在的なトラブルを考えなくてよいため精神的に楽。

あと npm をユニット テストではなく普通のプログラムとして走らせて検証してみたい場合、いちいち transpile しなくて済むのも助かる。

検証はテストに定義すべきでは?という意見もあるだろうけどリポジトリの examples フォルダに npm として参照したときのサンプルを配置しているとき、その動作検証で npm publish する前の現行コードを試したくなったりする。その場合、transpile なしだとビルド系タスクを実行せず動かせてよい。

以上を踏まえ、他の npm についても気が向いたら脱 Babel してゆく予定。

espower-babel 4.0 対応

2015年11月25日 2 開発 , , ,

私は JavaScript のユニット テストで mocha + espower-babel + power-assert を採用している。詳しくは ES6 コードをテストするを参照のこと。

これらのうち espower-babel を 4.0 に更新したところ ES6 コードの解析で SyntaxError が起きるようになった。Release Notes には Breaking Change が入ったことが明記されている。

espower-babel が Babel 6 を採用したことにより ES6 コードの解析は Babel 本体から Plugin/Presets に分離され、これらのインストールと明示的な設定が必要となった。espower-babel を Node モジュールとして実行する場合はコード上で babelrc オプションを指定すればよい。以下は README サンプルからの引用。

require('espower-babel')({
    babelrc: {
        "presets": ["es2015"],
        "plugins": ["transform-es2015-modules-commonjs"]
    }
})

しかし私は mocha と espower-babel を CLI から実行しているため、この方法は利用できない。

同じく Babel 6 を採用した babelify を Browserify とあわせて CLI から実行する場合、Presets を指定する方法は babelify v7.2 を試すに解説したように -t オプション内で babelify 用に --presets オプション経由で設定すればよい。

{
  "scripts": {
    "build:js": "browserify -t [ babelify --presets [ es2015 react ] ] ./src/js/App.js -d | exorcist ./src/html/bundle.js.map > ./src/html/bundle.js"
  }
}

今回もこの方法でゆけるかと思ったのだが、mocha や espower-babel にはこのような CLI 用オプションは用意されていないようなので .babelrc という設定ファイルを追加する必要がある。

このファイルを mocha と espower-babel の参照を定義した package.json の置かれた階層に作成する。今回は ES6 を採用したコードの解析を実施したいので、設定は以下のようにする。

{
  "presets": [ "es2015" ]
}

次に Plugin/Presets の npm をインストール。babel-preset-es2015 が必要となる。ユニット テスト系と一緒に最新版を入れておく。

$ npm i -D mocha espower-babel power-assert
$ npm i -D babel-preset-es2015

テスト用コマンドは package.json へ以下のように定義しているので、

$ npm i -D mocha espower-babel power-assert
$ npm i -D babel-preset-es2015

以下のように実行する。結果、今回の対応により SyntaxError の起きていたテストが正しく実行されることを確認できた。

$ npm test

> es6-unit-test@1.0.0 test .../examples-web-app/es6-unit-test
> mocha --compilers js:espower-babel/guess test/**/*.js

  Sample.sum()
    ✓ 合計
    ✓ 不正な型による例外発行

  Floor()
    ✓ 整数化

  3 passing (8ms)

ユニット テストも含めた実際のプロジェクト構成については、以下のサンプルを参照のこと。

余談だが Babel 6 は変更が大きいためか、バグや従来バージョンとの挙動の違いが結構あるようで新しい発見も多い。新しもの好きにはたまらないはず。

babelify v7.2 を試す

2015年11月4日 0 開発 , , , , ,

2015/10/29 に JavaScript の Transpiler である Babel の 6.0.0 がリリースされた。

これまでの Babel はデフォルトで ES6 と React JSX 変換に対応していたが、これからはプラグイン化されたものを指定する形式になるのだという。Browserify 用 Babel の babelify も同様で、これを最新に更新してコンパイルを実行すると ES6 部分が SyntaxError になる。

$ npm run build:js-main

> electron-starter@1.0.4 build:js-main .../examples-electron/electron-starter
> browserify -t babelify ./src/js/main/Main.js --im --no-detect-globals --node -d | exorcist ./src/main.js.map > ./src/main.js

.../examples-electron/electron-starter/src/js/main/Main.js:1
import App from 'app';
^
ParseError: 'import' and 'export' may appear only with 'sourceType: module'
The code that you piped into exorcist contains no source map!
Therefore it was piped through as is and no external map file generated.

babel/babelify の README に記載されている対応方法を electron-starter で試す。このプロジェクトでは Browserify、watchify を package.json の npm-scripts から CLI で実行し、そのなかで babelify を指定している。

関係する部分を抜粋。

{
  "scripts": {
    "build:js-main": "browserify -t babelify ./src/js/main/Main.js --im --no-detect-globals --node -d | exorcist ./src/main.js.map > ./src/main.js",
    "build:js-renderer": "browserify -t babelify ./src/js/renderer/App.js -d | exorcist ./src/bundle.js.map > ./src/bundle.js",
    "watch:js-main": "watchify -v -t babelify ./src/js/main/Main.js --im --no-detect-globals --node -o \"exorcist ./src/main.js.map > ./src/main.js\" -d",
    "watch:js-renderer": "watchify -v -t babelify ./src/js/renderer/App.js -o \"exorcist ./src/bundle.js.map > ./src/bundle.js\" -d",
    "release:js-main": "browserify -t babelify ./src/js/main/Main.js --im --no-detect-globals --node | uglifyjs -c warnings=false -d DEBUG=false > ./dist/src/main.js",
    "release:js-renderer": "browserify -t babelify ./src/js/renderer/App.js | uglifyjs -c warnings=false -d DEBUG=false > ./dist/src/bundle.js"
  }
}

環境構築にあたり、まずは babelify も含めたすべての npm を最新にする。この作業は npm-check-updates で実行した。

$ ncu -a

babelify     ^6.3.0  →   ^7.2.0
browserify  ^11.2.0  →  ^12.0.1

The following dependencies are satisfied by their declared version range, but the installed versions are behind. You can install the latest versions without modifying your package file by using npm update. If you want to update the dependencies in your package file anyway, use --upgradeAll.

react              ^0.14.0  →  ^0.14.2
react-dom          ^0.14.0  →  ^0.14.2
electron-packager   ^5.1.0  →   ^5.1.1
electron-prebuilt  ^0.34.0  →  ^0.34.2
esdoc               ^0.4.1  →   ^0.4.3
watchify            ^3.4.0  →   ^3.6.0

Upgraded .../examples-electron/electron-starter/package.json

ncu -a で package.json の dependencies に設定された全 npm の更新をチェックし、最新版に書き換えてくれる。この後に npm i を実行することで node_modules 側も更新される。

次に ES6 と React JSX をコンパイルするための Babel プラグインをインストールする。

$ npm i -D babel-preset-es2015 babel-preset-react

ややこしいのだが babelify は Babel の Browserify 用インターフェースで実体は Babel になる。そのため機能を追加する場合も babelify プラグインがあるわけではなく Babel プラグインを用意することになる。

コンパイルするための npm が揃ったので npm-scripts を修正。babelify の README にある CLI サンプルを元に以下のようにした。

{
  "scripts": {
    "build:js-main": "browserify -t [ babelify --presets [ es2015 react ] ] ./src/js/main/Main.js --im --no-detect-globals --node -d | exorcist ./src/main.js.map > ./src/main.js",
    "build:js-renderer": "browserify -t [ babelify --presets [ es2015 react ] ] ./src/js/renderer/App.js -d | exorcist ./src/bundle.js.map > ./src/bundle.js",
    "watch:js-main": "watchify -v -t [ babelify --presets [ es2015 react ] ] ./src/js/main/Main.js --im --no-detect-globals --node -o \"exorcist ./src/main.js.map > ./src/main.js\" -d",
    "watch:js-renderer": "watchify -v -t [ babelify --presets [ es2015 react ] ] ./src/js/renderer/App.js -o \"exorcist ./src/bundle.js.map > ./src/bundle.js\" -d",
    "release:js-main": "browserify -t [ babelify --presets [ es2015 react ] ] ./src/js/main/Main.js --im --no-detect-globals --node | uglifyjs -c warnings=false -d DEBUG=false > ./dist/src/main.js",
    "release:js-renderer": "browserify -t [ babelify --presets [ es2015 react ] ] ./src/js/renderer/App.js | uglifyjs -c warnings=false -d DEBUG=false > ./dist/src/bundle.js"
  }
}

改めてコンパイルを実行してみると、無事にコンパイルが通った。

$ npm run build:js-main

> electron-starter@1.0.4 build:js-main .../examples-electron/electron-starter
> browserify -t [ babelify --presets [ es2015 react ] ] ./src/js/main/Main.js --im --no-detect-globals --node -d | exorcist ./src/main.js.map > ./src/main.js

この対応が Windows 環境で有効なことも確認済み。

watchify に関する朗報

今回の確認で気づいたのだが、watchify の -o オプションによる pipe 処理が Windows でも動作するようになっていた。関連しそうな issue と commit は以下だろうか。

従来は -o オプションの問題により Windows 環境を考慮するなら exorcist による Source Maps 生成を諦めるか gulp を採用しなければならなかった。しかし最新の watchify であれば -o がそのまま通る。

今回の npm-scripts で Source Maps が生成されること、Developer Tools で Source Maps 参照とブレーク ポイントが機能することを確認できた。

watchify & exorcist が生成した Source Maps

すばらしい!

この対応により前に書いた gulp なしの Web フロントエンド開発 における Windows 対応の課題がすべて解決した。喉につかえた小骨が取れた気分。動作を確認できたとき思わずガッツポーズをとってしまった。

watchify の README を読むと -o オプションの説明にはまだ not available on Windows と書いてあるけど、これもいずれ無くなるだろうか。

サンプル プロジェクト

今回の変更を以下のサンプル プロジェクトに反映した。

npm-scripts の修正と一緒に Windows 用 npm-scripts 変更が不要になったので README の説明も訂正しておいた。