Electron を試す 9 – Babel 変換を最小におさえつつ minify

2017年6月5日 0 開発 , ,

小ネタ。

Electron が採用している Chromium は ECMAScript 対応がかなり進んでいる。よって Babel を使用しつつも変換を最小におさえたくなる。

この点について以前 babel-preset-env と minify という記事を書いたのだが、uglify-js の ES2015 以降への対応が暫定版なため、よりよい選択肢として babel-preset-babili を試してみた。その記事のコメントで mysticatea さんが提案されているように Browserify へ -g オプションをつければ node_modules 部分も含めて minify 可能だが、それでも Browserify の Bundle 処理は minify されない。

よって uglify-js harmony 版が正式リリースされるのを待っていたところ、uglify-es が提供されたので試してみる。

uglify-es

従来 uglify-js で harmony と呼ばれていた ES2015 以降へ対応する仕向け。README によれば uglify-js@3.x 系に対して API と CLI 互換がある。CLI 名も uglifyjs のまま。よって最新の uglify-js を使用しているなら、特に処理を変えず移行可能。

移行は package.jsondevDependencies で uglify-js を uglify-es へ置き換えればよい。uglify-js@3.x から提供となるので、バージョン系は 3.x 以降となる。安全のため npm un -D uglify-js してから npm i -D uglify-es するのがよいだろう。

余談だが Support UglifyJS 3 と関連 issue にて webpack の uglify-es 対応が検討されている。難航しているようだ。

babel-preset-env

uglify-es により ES2015 以降を解析可能となるため、Babel 変換も最小にする。babel-preset-env へ開発で使用している Electron のバージョンを指定。以下は package.json の例。.babelrc なら "babel" プロパティの値をルートにする。

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

この設定で akabekobeko/examples-electron のプロジェクトをビルドして classconstlet といった ES2015 以降の機能はそのままに uglify-es で minify されることを確認できた。

問題点

uglify-es は ES2015 なら対応しているけれど async/await を含むコードはエラーになる。試しに async function – JavaScript | MDN のサンプルを含めてみたところ、

Parse error at 0:2932,6
async function add1(x) {
      ^
ERROR: Unexpected token: keyword (function)
    at JS_Parse_Error.get (eval at <anonymous> (.../examples-electron/audio-player/node_modules/uglify-es/tools/node.js:21:1), <anonymous>:86:23)
    at fatal (.../examples-electron/audio-player/node_modules/uglify-es/bin/uglifyjs:286:52)
    at run (.../examples-electron/audio-player/node_modules/uglify-es/bin/uglifyjs:241:9)
    at Socket.<anonymous> (.../examples-electron/audio-player/node_modules/uglify-es/bin/uglifyjs:179:9)
    at emitNone (events.js:110:20)
    at Socket.emit (events.js:207:7)
    at endReadableNT (_stream_readable.js:1045:12)
    at _combinedTickCallback (internal/process/next_tick.js:102:11)
    at process._tickCallback (internal/process/next_tick.js:161:9)

となった。ちなみに Browserify も以前は es6 async class function fails to parse? という問題があったが修正済み。uglify-es の issue [ES8] async/await not supported also in harmony を読むに、近く修正されそうな気がする。

まとめ

async/await 対応の問題はあるものの、babel-preset-env + uglify-es の組み合わせで Electron 向けの Babel 変換を最小におさえられた。

Chrome Canary 60 はフラグ付きで ES Modules が有効になるそうで、これが Electron に採用されたら Bundle と minify 事情も変わりそう。しかし node_modules も含めたサイズ圧縮の観点から Bundle と minify はしばらく必要な処理なので、今後も動向は継続的にチェックしたい。

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 と minify

2017年3月31日 0 開発 , ,

babel-preset-env を試す で試した Electron 用の設定を実際に akabekobeko/examples-electron へ適用してみたところ、いろいろと問題があったので記録しておく。

JavaScript のビルド設定

この記事を読むための前提条件として必要な情報を先にまとめておく。

私は Electron 用 JavaScript ビルドを package.json へ定義している。必要最小の npm は以下。

npm 用途
browserify Bundler。JavaScript 間のファイル参照を解決して単一ファイルを出力する。
babelify Transpiler である Babel の Browserify 用プラグイン。ES.next で書かれた JavaScript を ES5 などに変換してくれる。
babel-preset-env Babel が ES.next を解析するためのプリセット。対象環境を設定することで、それにあわせた変換を実行してくれる。従来、環境を指定せず最新 ES.next を常に ES5 化する場合は babel-preset-latest を使用していたが、現在これをインストールすると deprecated と共に env を使用せよと警告される。
babel-preset-react Babel が React の JSX を解析するためのプリセット。
cross-env npm-scripts 上で環境変数 NODE_ENV の値を設定するツール。npm の中にはこの値によって処理を分岐するものがあり、例えば React は production だとリリース用となりログ出力処理などが無効化される。
exorcist Web ブラウザーの開発者ツールで変換前後の JavaScript を紐付けて解析するための Source Maps を生成するツール。これがあると実行は変換後、デバッガー表示は変換前のファイルという運用ができて便利。
uglify-js JavaScript を minify するツール。非常に高機能でインデント削除だけでなく参照を解析したうえで変数名などを短縮するなどの最適化や難読化もおこなえる。

これらを使用した package.json 定義は以下。私は Babel の設定も独立したファイルではなく npm-scripts などと共に package.json へ記述する派。

{
  "babel": {
    "presets": [
      [
        "env",
        {
          "targets": {
            "electron": 1.6
          }
        }
      ],
      "react"
    ]
  },
  "scripts": {
    "build:js-main": "browserify -t [ babelify ] ./src/js/main/Main.js --exclude electron --im --no-detect-globals --node -d | exorcist ./src/assets/main.js.map > ./src/assets/main.js",
    "build:js-renderer": "browserify -t [ babelify ] ./src/js/renderer/App.js --exclude electron -d | exorcist ./src/assets/renderer.js.map > ./src/assets/renderer.js",
    "release:js-main": "cross-env NODE_ENV=production browserify -t [ babelify ] ./src/js/main/Main.js --exclude electron --im --no-detect-globals --node | uglifyjs -c warnings=false -m -d DEBUG=false > ./dist/src/assets/main.js",
    "release:js-renderer": "cross-env NODE_ENV=production browserify -t [ babelify ] ./src/js/renderer/App.js --exclude electron | uglifyjs -c warnings=false -m -d DEBUG=false > ./dist/src/assets/renderer.js",
  },
  "devDependencies": {
    "babel-preset-env": "^1.3.2",
    "babel-preset-react": "^6.23.0",
    "babelify": "^7.3.0",
    "browserify": "^14.1.0",
    "cross-env": "^4.0.0",
    "exorcist": "^0.4.0",
    "uglify-js": "^2.8.19"
  }
}

Electron は Main/Renderer プロセスのエントリー ポイントが別れているため、個別にビルドする。build:js-* は開発用、release:js-* がリリース用の定義。babel-preset-env の設定は現時点で最新の Electron v1.6 を対象としている。

この状態で開発用ビルドを実行すると Electron v1.6 は Chromium 56.0.2924.87 を採用しているため、ES2015 Classes など対応されているものはそのままに、Modules など未対応のものだけ変換される。もちろん、変換されたコードはちゃんと Electron アプリとして実行可能。

しかしリリース用ビルドは問題がある。開発用と同様に Browserify + babelify 部分まではよいのだが、uglify-js は ES2015 以降に対応していないためエラーになる。というわけで、この問題へ対応してみる。

uglify-js の harmony 版

ES2015 が標準化されてひさしい。そのため当然 uglify-js にも対応が要望されている。ただし ES2015 を含む ES.next へ対応するのはかなり難しい。そのため uglify-js は実験的な ES.next 対応として harmony 版を提供、README の Harmony 欄にはこう書かれている。

If you wish to use the experimental harmony branch to minify ES2015+ (ES6+) code please use the following in your package.json file:

"uglify-js": "git+https://github.com/mishoo/UglifyJS2.git#harmony"

or to directly install the experimental harmony version of uglify:

npm install --save-dev uglify-js@github:mishoo/UglifyJS2#harmony

See #448 for additional details.

これを踏まえて uglify-js を harmony 版へ差し替える。npm un -D uglify-js してから npm i -D uglify-js@github:mishoo/UglifyJS2#harmony して devDependencies 上のバージョン表記が npm 管轄から特別なホストへ切り替わることを確認。

{
  "devDependencies": {
    "uglify-js": "github:mishoo/UglifyJS2#harmony"
  }
}

この状態で再びリリース用ビルドを実行すると正常に終了した。出力されたファイルを見ると class などはそのままに uglify-js へ指定された設定どおりの minify が実行されている。ただし問題もある。

harmony は実験的なものでありバグも多い。uglify-js の issue を ES6ES2015 で検索すると対応に苦慮しているようだ。これまでの流れと現状を把握するには README にも掲載されている issue Harmony support を読むとよい。2014 年から始まり今も活況である。

Releases についても harmony は Pre-release となり npmjs としてのバージョン管理下にはない。よって現時点ではプロダクトに採用するのを避けたほうがよいだろう。

babel-preset-babili

uglify-js がダメなら ES.next へ正式対応している minify ツールを使えばよい。というわけで Babel ファミリーの babel-preset-babili を試す。npm i -D babel-preset-babili してから Babel と npm-scripts を書き換える。

{
  "babel": {
    "presets": [
      [
        "env",
        {
          "targets": {
            "electron": 1.6
          }
        }
      ],
      "react"
    ],
    "env": {
      "production": {
        "presets": [
          "babili"
        ]
      }
    }
  },
  "scripts": {
    "release:js-main": "cross-env NODE_ENV=production browserify -t [ babelify ] ./src/js/main/Main.js --exclude electron --im --no-detect-globals --node -o ./dist/src/assets/main.js",
    "release:js-renderer": "cross-env NODE_ENV=production browserify -t [ babelify ] ./src/js/renderer/App.js --exclude electron -o ./dist/src/assets/renderer.js",
  }
}

リリース版だけ minify したいので env.production.presets に babili を指定する。この状態でリリース用ビルドを実行すると確かに env も考慮しつつ ES.next 部分が minify された。しかし標準では dependencies の npm は minify されず、細かなカスタマイズはプリセットを構成する各種プラグインについて学習する必要がある。

uglify-js と同等の minify を再現できるかは分からないが babel-preset-env と組み合わせるなら、同じ Babel ファミリーであり ES.next 対応の知見も共有されそうな babili がよさそう。

というわけで babili を掘り下げようと思ったのだが README をざっと読んだだけでも重量級っぽい雰囲気がプンプンただよってるため、後で独立した記事としてまとめる予定。