gulp で uglify-es を利用する

2017年6月14日 0 開発 , ,

タスクランナーは npm-scripts 派なのだけど、akabekobeko/examples-web-app に公開している front-end-starter には gulp 版も用意してある。かつて gulp を利用していたこと、世間では現在もそれなりに Web フロントエンド開発で gulp が採用されていることから動向チェック用にメンテナンスしている。

front-end-starter は Electron も含む Web フロントエンド開発環境の変更を気が向いたときに反映しているのだが、そういえば uglify-es を gulp で利用したくなったらどうするのだろう?というのが気になったので試してみた。

gulp-uglify

gulp で uglify-js を利用する場合、gulp wrapper となる gulp-uglify を採用するのが一般的だろう。では uglify-es はどうなのか。uglify-js 本家が uglify-es を独立したパッケージとしているように gulp-uglify-es があるとか?と予想したが gulp-uglify として js/es を切り替えられるようになっていた。

README の Using a Different UglifyJS から引用する。

By default, gulp-uglify uses the version of UglifyJS installed as a dependency. It’s possible to configure the use of a different version using the “composer” entry point.

標準では dependency としてインストールされた uglify-js を使用するが、composer を利用することで js/es を分岐できるとのこと。以下は README に併記されているサンプル コード。

var uglifyjs = require('uglify-js'); // can be a git checkout
                                     // or another module (such as `uglify-es` for ES6 support)
var composer = require('gulp-uglify/composer');
var pump = require('pump');

var minify = composer(uglifyjs, console);

gulp.task('compress', function (cb) {
  // the same options as described above
  var options = {};

  pump([
      gulp.src('lib/*.js'),
      minify(options),
      gulp.dest('dist')
    ],
    cb
  );
});

gulp ストリーム (pipe) をそのまま使用せず pump で wrap している点を除けば、割りと簡単に切り替えられるようだ。

gulp タスク修正

gulp-uglify としては pump を推奨しているようだが、以下のように gulp-watchify と組み合わせていると書き換えが多くなるので、

// ...import や gulp-load-plugins の設定など

gulp.task('js', $.watchify((watchify) => {
  const time = process.hrtime()

  return gulp.src([ config.dir.js + 'App.js' ])
    .pipe($.plumber())
    .pipe(watchify({
      watch: config.isWatchify,
      basedir: './',
      debug: true,
      transform: [ 'babelify' ]
    }))
    .pipe(VinylBuffer())
    .pipe($.if(!(config.isRelease), $.sourcemaps.init({ loadMaps: true })))
    .pipe($.if(config.isRelease, $.uglify()))
    .pipe($.rename('bundle.js'))
    .pipe($.if(!(config.isRelease), $.sourcemaps.write('./')))
    .pipe($.if(config.isRelease, gulp.dest(config.dir.dist), gulp.dest(config.dir.assets)))
    .on('end', () => {
      const taskTime = PrettyHRTime(process.hrtime(time))
      $.util.log('Bundled', $.util.colors.green('bundle.js'), 'in', $.util.colors.magenta(taskTime))
    })
}))

gulp ストリームのまま移行する。

// ...import や gulp-load-plugins の設定など

// 追加
import UglifyES from 'uglify-es'
import UglifyComposer from 'gulp-uglify/composer'

gulp.task('js', $.watchify((watchify) => {
  const time = process.hrtime()

  // uglify-es で minify するための関数設定
  const minify = UglifyComposer(UglifyES, console)

  return gulp.src([ config.dir.js + 'App.js' ])
    .pipe($.plumber())
    .pipe(watchify({
      watch: config.isWatchify,
      basedir: './',
      debug: true,
      transform: [ 'babelify' ]
    }))
    .pipe(VinylBuffer())
    .pipe($.if(!(config.isRelease), $.sourcemaps.init({ loadMaps: true })))
    // uglify-es で minify
    .pipe($.if(config.isRelease, minify({})))
    .pipe($.rename('bundle.js'))
    .pipe($.if(!(config.isRelease), $.sourcemaps.write('./')))
    .pipe($.if(config.isRelease, gulp.dest(config.dir.dist), gulp.dest(config.dir.assets)))
    .on('end', () => {
      const taskTime = PrettyHRTime(process.hrtime(time))
      $.util.log('Bundled', $.util.colors.green('bundle.js'), 'in', $.util.colors.magenta(taskTime))
    })
}))

このサンプルには掲載していないが、別のリリース用イメージ生成タスクで config.isReleasetrue にしてから js タスクを実行することで、uglify-es による minify 処理が走ることを確認できた。

まとめ

Electron や Chrome (Chromium) のように先進的な環境を決め打ちで開発したり、babel-preset-env で環境を抽象化しつつ変換を最小におさえると minify 対象となるコードに ES.next 部分があらわれる。その際は minify ツール側も ES.next 対応が求められるため uglify-esbabili を利用することになるだろう。gulp に関しては本記事のように gulp-uglify の機能として uglify-es を採用するとよい。

ところで gulp-uglify の設計は gulp wrapper における問題へのよき回答にもなっており、感心した。

gulp を想定していない npm を gulp ストリームにのせる場合、vinyl-buffer などで頑張るか、そのような処理で wrap した gulp プラグインとする必要がある。前者はエンド ユーザー負担が大きすぎるので後者を選ぶことが多いのだけど、こちらだと gulp プラグインの dependency で wrap 対象 npm の更新へ追従しなければならない。

gulp-uglify/composer 方式なら自身の dependency が古びても、エンド ユーザー側から任意バージョンの npm を指定できる。つまり gulp 化の恩恵を享受しつつ、最新 npm に置き換えられるのだ。対象 npm のインターフェースが維持されていることは前提となるが、うまい方法である。自身の dependency + 同一インターフェース npm をサポートできる。

私がタスクランナーを gulp から npm-scripts へ移行した理由のひとつは gulp プラグインの陳腐化なのだけど、この設計が普及すれば、この問題に関しては概ね解決しそうである。gulp プラグインのガイドラインに含めてもよいのでは、とも思う。

この記事を書いた後に気付いたのだが babili の gulp 版として gulp-babili が提供されている。こちらは Babel/babili 本家なので、Babel ファミリーを好むなら uglify-es の代りに採用してもよいだろう。

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-babili を試す

2017年4月4日 2 開発 , ,

babel-preset-env と minify の続き。前回は ES.next なコードを minify する方法として uglify-js を中心に babel-preset-babili を少しだけ試したところで終わった。今回は後者の使い方を掘り下げる。

Babel における plugin と preset

babel-preset-babili は ES.next な JavaScript を ES5 以降の書式に変換する Babel 関連のツールで minify を担当。

現在の Babel 本体はランタイムに徹し、実際のコード解析や変換はランタイム上で動作する plugin により処理される。開発者は機能ごとに plugin を組み合わせることになるが、これらは膨大である。そのため直に plugin を利用するのではなく、plugin 集となる preset を選ぶほうがよいだろう。

例えば以下のような preset がある。

preset 用途
babel-preset-env 対象とする動作環境 (Web ブラウザーなど) の組み合わせやバージョンを指定することで ECMAScript 6 compatibility table に基き ES.next を ES5 以降の形式へ必要最小に変換する。標準では babel-preset-latest 相当の全変換を実施。latest は deprecated になったので全変換の場合でも env を使用することが望ましい。
babel-preset-babili ES.next を minify する。標準では minify のみ。細かな設定は preset を構成する plugin 単位で指定。
babel-preset-react ReactJSXFlow などを変換する。Flow は babel-preset-flow という単体版もあるが、この preset はそれを組み込んでいる。このように preset は他の preset も含められる。

Babel の設定は .babelrc ファイルや package.jsonbabel プロパティに定義する。env と babili の場合、標準設定であれば

{
  "presets": ["babili"]
}

のように presets Array へ preset 名だけを String として記述。preset に含まれる pluguin を個別に設定するなら

{
  "presets": [["babili", {
    "mangle": {
      "blacklist": {
        "ParserError": true,
        "NetworkError": false
      }
    },
    "unsafe": {
      "typeConstructors": false
    },
    "keepFnName": true
  }]]
}

のように preset を String から Array へ書き換えて、第二要素の Object へ plugin 単位のプロパティを記述してゆく。

ここからは babili を構成する plugin とその設定についてまとめる。オプションについては README に型や既定値が掲載されていないため GitHub に公開されているコードも参考にした。

babel-plugin-minify-constant-folding

リテラル同士の演算を定数に変換する plugin。babili 標準で有効、オプションなし。

{
  "presets": [["babili", {
    "evaluate": true
  }]]
}

例えば "a" + "b""ab" となり、"b" + a + "c" + "d" のように変数が含まれるなら "b" + a + "cd" という感じでそこをを避けてくれる。

babel-plugin-minify-dead-code-elimination

dead code (到達不能コード) を除去する plugin。babili 標準で有効、オプションは以下。

設定 既定値 内容
optimizeRawSize Boolean false README のサンプルに記載されているものの、設定値については解説されておらず用途不明。実装をみるとスコープと変数 bind に関する最適化の実行フラグになっている。
keepFnName Boolean false 元の関数名を維持する。
keepFnArgs Boolean false 関数の引数を維持する。
keepClassName Boolean false 元のクラス名を維持する。
{
  "presets": [["babili", {
    "deadcode": {
      "optimizeRawSize": false,
      "keepFnName": false,
      "keepFnArgs": false,
      "keepClassName": false
    }
  }]]
}

既定値のまま変換すると、例えば function foo() {var x = 1;}function foo() {} になる。関数のようにインターフェースとして露出している部分は維持しつつ、ローカルの範疇で解析可能な dead code を除去するようだ。

babel-plugin-minify-infinity

JavaScript のグローバルな Object である Infinity1 / 0; に変換する plugin。babili 標準で有効、オプションなし。

{
  "presets": [["babili", {
    "infinity": true
  }]]
}

他のプラットフォームではゼロ除算を致命的なエラーとすることが多い。しかし JavaScript だと結果は Infinity になる。

という仕様を踏まえて Object から演算に変換していると思われるが、なぜこの処理が必要なのかは不明。Object を演算にすることで Infinity への代入を構文エラーとして検出したいとか、そんな目的があるのだろうか?

babel-plugin-minify-mangle-names

コンテキストやスコープを考慮して Name mangling (名前修飾) 処理を実行得する plugin。babili 標準で有効、オプションは以下。

設定 既定値 内容
blacklist Object {} 変換対象から除外する識別子の設定。例えば foo という名前を除外したい場合は {"foo":true} のように指定する。
eval Boolean false eval がアクセス可能な範囲で mangle を有効にする。
keepFnName Boolean false 元の関数名を維持する。
topLevel Boolean false 最上位スコープに対する mangle を有効にする。
keepClassName Boolean false 元のクラス名を維持する。
{
  "presets": [["babili", {
    "mangle": {
      "blacklist": {
        "foo": true
      },
      "eval": false,
      "keepFnName": false,
      "topLevel": false,
      "keepClassName": false
    }
  }]]
}

mangle 処理により変数、関数、クラス名などが aaa といった短い名前に変更される。結果、ファイル サイズの削減や単純な難読化といった効果を得られる。

babel-plugin-minify-numeric-literals

数値リテラルを可能な範囲で短縮する plugin。babili 標準で有効、オプションなし。

{
  "presets": [["babili", {
    "numericLiterals": true
  }]]
}

例えば 10001e3 のように指数表記へ置き換える。実装をみると Number.prototype.toExponential() の結果から /\+/g/e0/ を空文字に置換したものとなり、変換結果が元の表記よりも長い場合はキャンセルされる。

babel-plugin-minify-replace

ユーザーが明示した設定に従って置換を実行する plugin。babili 標準で有効、オプションは以下。

設定 既定値 内容
replacements Array 置換対象コレクション。
[].identifierName String 置換対象とする識別子の名前。
[].replacement Object 置換方法。
[].replacement.type String 置換対象となる識別子の型。サンプルを見るに Babel types*Literal 系を指定するようだ。
[].replacement.value Any 置換する値。型は replacement.type に指定されたものと対応。
{
  "presets": [["babili", {
    "replace": {
      "replacements": [
        {
          "identifierName": "DEBUG",
          "replacement": {
            "type": "numericLiteral",
            "value": 0
          }
        }
      ]
    }
  }]]
}

識別子を直に置換することからマクロのようなメタ プログラミング、要するに他のプラットフォームでいうところの pre-processing を実現可能。uglify-js の --define オプションによる ifdef DEBUG 処理の代替になる。

babel-plugin-minify-simplify

ステートメントを式に変換してコードを短くする plugin。babili 標準で有効、オプションなし。

{
  "presets": [["babili", {
    "simplify": true
  }]]
}

例えば if (x) a(); は短絡評価を利用して x && a(); になる。if (x) { a(); } else { b(); } は三項演算に展開され x ? a() : b(); となる。ただし圧縮率を高めるため undefinedvoid 0foo['bar']foo.bar に変換する処理はハック的なコードに対して副作用を及ぼすかもしれない。

babel-plugin-transform-merge-sibling-variables

ステートメントの別れた変数の宣言をひとつにまとめる plugin。babili 標準で有効、オプションなし。

{
  "presets": [["babili", {
    "mergeVars": true
  }]]
}

例えば var a = 0; var b = 2;var a = 0, b = 0; に変換する。for 文の外で宣言され for 文内でしか使用されていない変数は for の宣言に移動される。

babel-plugin-transform-minify-booleans

Boolean リテラルをより短い表記に変換する plugin。babili 標準で有効、オプションなし。

{
  "presets": [["babili", {
    "booleans": true
  }]]
}

true!0false!1 になる。短縮される文字数こそ少ないが、リテラルのみを対象とするため副作用の心配もない。

babel-plugin-transform-regexp-constructors

RegExp コンストラクターを正規表現リテラルに変換する plugin。babili 標準で有効、オプションなし。

{
  "presets": [["babili", {
    "regexpConstructors": true
  }]]
}

例えば

const foo = 'ab+';
var a = new RegExp(foo+'c', 'i');

const foo = 'ab+';
var a = /ab+c/i;

になる。演算子 new も含めると RegExp インスタンス生成の表記はかなり長いため、これを使用しているなら相当の短縮が見込める。

babel-plugin-transform-remove-console

console.* の呼び出しを削除する plugin。babili 標準では無効、オプションなし。

{
  "presets": [["babili", {
    "removeConsole": false
  }]]
}

ライブラリーやフレームワークの場合、ユーザーが開発者なので残したほうがよい。アプリ層については plugin を有効にして余計な出力を除去、といった使い分けをする。

babel-plugin-transform-remove-debugger

debugger ステートメントを削除する plugin。babili 標準では無効、オプションなし。

{
  "presets": [["babili", {
    "removeDebugger": false
  }]]
}

私はこれまで使用したことはなかったのだが、この debugger ステートメントは Web ブラウザーの開発者ツールにおいてコード上に記述するブレーク ポイントとして機能するそうだ。コード上なので到達すると常にブレークする。JavaScriptのデバッグ方法 – JSを嫌いにならないためのTips によるとコールバック関数まわりのデバッグに役立つらしい。

同記事で「当然、本番用のコードに残しておきたいものではありません。」とあるとおり開発用の機能なので、アプリ層ならば babili で除去したほうがよい。

babel-plugin-transform-remove-undefined

変数への代入や戻り値の指定された undefined を削除する plugin。babili 標準で有効、オプションなし。

{
  "presets": [["babili", {
    "removeUndefined": true
  }]]
}

例えば var b = undefined;var b;return undefined;return; になる。変数の場合、対象は関数内に限定されるため副作用の心配はない。

babel-plugin-transform-undefined-to-void

undefined 参照を void 0 に変換する plugin。babili 標準で有効、オプションなし。

{
  "presets": [["babili", {
    "undefinedToVoid": true
  }]]
}

前述の simplify でも同様の処理がおこなわれるのでは?と思ったが、あちらはステートメントを対象としている。こちらは foo === undefined;foo === void 0 のように参照を変換する。plugin の説明を読みと simplify で予想したとおり undefined の上書き事故を防ぐことが目的とのこと。

Electron 向け minify を uglify-js から babili に変更する

本記事の冒頭に紹介した前回記事では

  • babili の細かなカスタマイズには plugin 学習が必要なこと
  • package.json における dependencies の npm が minify されないこと

を課題として残した。これらのうち前者は今回の記事で対応してみた。基本、babili 標準で問題ないだろう。一方、後者については babili というより browserify + babelify という組み合わせの問題である。状況を把握しやすくするため処理遷移をまとめる。

  1. Browserify 実行
  2. Browserify が transpile のために babelify を実行
  3. babelify が presets の順番に従い JavaScript を transpile
  4. presets の babel-preset-env により ES.next が ES5 + α な JavaScript へ transpile
  5. presets の babel-preset-babili により ES5 + α な JavaScript を minify
  6. Browserify が transpile された JavaScript 内の require を解決して単一ファイルに bundle (結合)

uglify-js と異なり babili は babelify から実行されるため、この時点では dependencies の npm は対象外である。よってアプリ側のファイルだけ transpile + minify され、その後に dependencies 側がそのまま bundle されてしまう。React のように巨大な npm だとファイル サイズ的に問題となるし NODE_ENV=production を経ていないためデバッグ用のコードも残る。

そのため

  1. Browserify 以降に minify を実行する
  2. babelify 時点でなんとか dependencies も対象とする

として 1 はビルド プロセスが複雑になる。これをおこなうぐらいなら babel-preset-env を全変換で実行して uglify-js するほうがよい。または前回記事で触れた uglify-js harmony 版を使用するとか。2 については babili の対象を node_modules まで広げる必要がある。しかし Babel は bundler にあらず、参照まで辿って処理はしない。

詰んだかも?しかし React に限定すれば npm に minify 版も付属するため babili を利用して参照を書き換えてしまえばよさそう。というわけで

{
  "presets": [["babili", {
    "replace": {
      "replacements": [
        {
          "identifierName": "react",
          "replacement": {
            "type": "stringLiteral",
            "value": "react/dist/react.min"
          }
        },
        {
          "identifierName": "DEBUG",
          "replacement": {
            "type": "numericLiteral",
            "value": 0
          }
        }
      ]
    }
  }]]
}

この設定により reactreact/dist/react.min に書き換えようとしたのだが失敗した。identifierNamevalue の内容を ' で囲って typeidentifier を指定してもダメだった。replace のテストは文字列リテラルの書き換えがないため、そもそもサポートしていないのだろう。

Browserify の exclude/require

では env や babili とは別に import 対象を変更する plugin はないか?と探してみたら babel-plugin-transform-rename-import を見つけた。

"plugins": [
  [
    "transform-rename-import",
    {
      "original": "react",
      "replacement": "react/dist/react.min"
    }
  ]
]

そして上記のように設定して実行してみたが、置換元と同じ名前が置換先に含まれていると重複変換されるようで

Error: Cannot find module 'react/dist/react/dist/react' from '.../examples-electron/audio-player/src/js/renderer/main'

というエラーになってしまった。仕方ないので Browserify として exclude + require することに。以下はそのコマンド。

cross-env NODE_ENV=production browserify -t [ babelify ] ./src/js/renderer/App.js --exclude electron --exclude react --exclude react-dom --require ./node_modules/react/dist/react.min.js:react --require ./node_modules/react-dom/dist/react-dom.min.js:react-dom -o ./dist/src/assets/renderer.js

めちゃくちゃ長くなってしまった。この処理で生成した renderer.js について

  • class など ES2015 以降の予約語が維持されること
  • Electron アプリとして動作すること

を確認したのだが、当然ながら Browserify の bundle 用コードは minify されない。そのためファイル サイズが肥大化した。

env minify size
標準 (latest 相当) uglify-js 約 320KB
Electron babili 約 400KB

dependencies をまったく使わないか React のように重量級の npm を使用しないなら babili でもよさそう。Electron は Node として require できるから bundler なしにする手もある。ただし私が bundler を使用するにはアプリの配布イメージから node_modules を除外する目的もあるため、この案は却下せざるをえない。

babel-cli + babili

そもそも transpile と bundle が分離しているのだから、uglify-js のようにビルド処理の最後に minify を実装するのはどうか?ということで処理を組み替えてみた。package.json から必要最小の抜粋。babili の設定は長くなるのでとりあえず標準。

{
  "babel": {
    "presets": [
      ["env", {"targets": {"electron": 1.6}}],
      "react"
    ],
    "env": {
      "production": {
        "presets": ["babili"]
      }
    }
  },
  "scripts": {
    "bundle": "browserify -t [ babelify ] ./src/js/renderer/App.js --exclude electron --exclude react --exclude react-dom --require ./node_modules/react/dist/react.min.js:react --require ./node_modules/react-dom/dist/react-dom.min.js:react-dom -o ./dist/src/assets/renderer.js",
    "minify": "cross-env NODE_ENV=production babel ./dist/src/assets/renderer.js -d ./",
    "build": "run-s bundle minify"
  },
  "dependencies": {
    "react": "^15.4.2",
    "react-dom": "^15.4.2"
  }
  "devDependencies": {
    "babel-cli": "^6.24.0",
    "babel-preset-babili": "0.0.12",
    "babel-preset-env": "^1.3.2",
    "babel-preset-react": "^6.23.0",
    "babel-register": "^6.24.0",
    "babelify": "^7.3.0",
    "browserify": "^14.1.0",
    "cross-env": "^3.2.4",
    "npm-run-all": "^4.0.2"
  }
}

bundle は env による ES.next の transpile と Browserify の bundle だけを実行。

minify で bundle 結果となるファイルを minify して上書き保存。中間ファイルは不要。bundle 対象に dependencies が含まれていた場合を考慮して NODE_ENV=production を指定、npm によってはこの判定によりデバッグ処理を dead code化して除去されることに期待した設計となっている。

これらを buildnpm-run-all により直列実行する。結果、400KB まで肥大化したファイル サイズは約 318KB になって uglify-js より縮小された。Browserify で React 系を exclude/require しない、つまり babili で minify するようにしてみたのだが、めちゃくちゃ時間がかかったのとサイズが 400KB 超えしたので却下。

この方法で minify したファイルでアプリが正常に動作することも確認済み。

まとめ

ES.next に対応した minify ツールとして babili を試した。現時点のバージョンは v0.0.12 なので本番プロダクトに採用するのは不安があるものの、Babel ファミリーということもあって uglify-js 代替となることを期待している。

ES.next の transpiler としては babel-preset-env が本命となるだろう。しかし代表的な Web ブラウザー全種と Node が Modules 対応するのは相当に先の話となり、特に Web ブラウザー向けには Browserify や webpack などの bundler を採用することになるだろう。よって完全な minify を実行するためには transpile/bundle を経たコードを対象としなければならない。そのため uglify-js、babili のどちらも bundler と組み合わせるならビルドの最終工程で実行したほうがよい。

最後に用途別 minify 戦略をまとめる。

  • 常に最新 ES.next を利用してあらゆる環境に対応したい
    • env は標準設定、つまり latest にする
    • ES.next はすべて ES5 相当に変換
    • 環境によっては無駄な変換も含まれるが許容する
    • 変換結果に ES.next が含まれないため minify は実績のある uglify-js 通常版を採用
  • 対象環境を明示してなるべく無駄な変換をなくしたい
    • env に対象環境を設定
    • ES.next は設定に応じて最小の ES5 変換
    • 変換結果に ES.next が含まれるため minify は babili か uglify-js harmony 版の二択

私としては現時点なら env 標準 + uglify-js を選ぶ。まだ babili は実績と品質面で不安がある。それと uglify-js harmony 版のなりゆきを見て判断したい。