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 版のなりゆきを見て判断したい。


COMMENTS

  • mysticatea
    2017年4月11日 12:53 PM 返信

    何点か。

    – `browserify` にはグローバル トランスフォームのオプションがあり、`node_modules`下を含めてすべてのファイルを変換することができます。例えば `browserify test.js -t babelify -g [ babelify –presets babili –no-babelrc ] > test.min.js`
    ただし、この場合は Browserify が作る “つなぎ” の部分は Minify されません。
    – `babili` は CLI コマンドも提供していますので、次のようにも書けます: `browserify test.js -t babelify | babili > test.min.js`

    • 2017年4月11日 7:49 PM 返信

      -g オプションは知りませんでした。これを前提にすると transpile されていない ES.next 使いまくりの状態で配布されてる npm も babel-preset-env で適切に変換できますね。配布側だけ最新 Node を下限にして利用側は指定なし、ただし browserify -g + babel-preset-env で transpile というのはよいかも。

      つなぎの部分も変換となるとやはり babili 単体実行が必要で、CLI なら uglify-js と同じくパイプになるのですね。

      細かなオプション指定することを想定すると本記事のように babe プロパティ (or babelrc) のほうがよさそうですが、babili が uglify-js 代替として十分になればそのまま CLI をすげ替えられるのはよいですね。

REPLY

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です