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 の代りに採用してもよいだろう。

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 における動作環境や技術の分離に関する問題は解決されつつあり、より本質的な関心事にもとづいて設計できる時代になったと感じる。