もう gulp で憔悴しない - 低依存 gulpfile のレシピ
【追記 150805】さらに憔悴しないための有用な記事『アカベコマイリ | gulp なしの Web フロントエンド開発』が掲載されましたので、こちらもお勧めします。
こんにちは、@armorik83 です。皆さん、Grunt / gulp使ってますか。おなじみなので、ここでは説明はしません。
この記事の要点
- なぜ Grunt / gulp は憔悴に至るのか、経緯と問題点
- npm run-script の仕組みについて
-
package.json
にscript を羅列することに対する是非 - シンプルな gulpfile についての提言
経緯
さて、先日このような記事が界隈で広まっていました。
この記事については同意できるところと、そうでもないところと、両方有りました。ただ、Grunt / gulp を使っていて色々歯がゆさを感じている方は昨今増えているだろうと感じます。
私は長らく Grunt ユーザで、プロジェクトによってはそのまま gulp を使うという程度でしたが、一応両方触って流れは把握しています。Gruntfile.json
は毎回一から書かず、頻繁に使う部分はコピペ+修正だったのですっかり秘伝のタレ化し、devDependencies
も増えたまま無自覚にインストールしている状態でした。
ここで一旦、現状の何が問題なのかまとめておきます。
問題点
プラグイン開発と時代の流れに乖離が起こっている
@teppeis さんの記事『grunt-parallelize v1.1.0 リリースおよび零細 OSS の継続性について』を読みました。
零細 OSS の継続性についてという点が重要で、大抵のビルドツール用プラグインは「それ、元のやつでできるよ」となるため、大元のライブラリの更新速度とプラグインの更新頻度の差や、それをメンテナがどうフォローするかが問題となります。
具体的な例として、私が愛用しているTypeScriptのコンパイラtsc
は執筆時点で1.5.0-alpha
がリリースされていますが、Grunt / gulp 向けの tsc ツールは1.4.1
となっており、もしここでツールを使いながら最新版を試したいとなると、自分で Fork する必要があります(オプションにカスタムコンパイラを許容している設計なら賢い)。
ただ、Grunt も gulp もコンパイラオプションを{}
Object で渡すため、もし新しいオプションに対するプラグイン側のサポートが後手後手になれば、そのオプションはしばらく使えないことになります。
プラグイン開発者がbabel などのように大元と同じならばまだ信頼度は残っていますが、「零細」となると体力を適切に判断すべきです。
普通に元のコマンド叩いたらいいじゃんって思うんです。
―― Grunt/Gulp で憔悴したおっさんの話
Browserify と babel の二大巨塔が凄まじい
Browserifyもbabelもおなじみになりました。Web デザイン制作の現場は分かりませんが、フロントエンドで「まだ Grunt? もう gulp でしょ」みたいに言われた時代はもはや過去のもので、この二大巨塔前提のビルドが増えました(webpackは個人の観測範囲ではあまり見てない)。
watchifyやbabelifyといった関連ツールの登場を見ると、Grunt|gulp して更に watchify で babelify して…というのは、2 段構えで冗長に感じます。
npm run-script には課題がある
今回記事を書こうと思った理由。package.json
のscripts
→npm run
に全幅の信頼を寄せてはいけません。
冒頭に引用した記事にはこうあります。
ということで、npm で元のコマンド叩いたら皆しあわせってことで npm run-script 使おうぜって話。そんな難しいことはないです。基本的には各コマンドを package.json に記述していくだけです。
―― Grunt/Gulp で憔悴したおっさんの話
この点が本題なので次に進みます。
npm run-script 完結には弱さがある
その出力、黒い画面と一緒と思っていませんか
今回 run-script に弱さを感じたのは、OS X Terminal.app での出力と run-script の出力に差異が出た点でした(そんなの当たり前だよと思われた方は、読み飛ばしてください)。
そもそも run-script って何をしているんでしょう。
npm run-script の実装
npm
も JavaScript で書かれた 1 ライブラリですから、読んでしまうのが早いです。読んだものはnpm 2.7.5 29039e1241です。検証環境はio.js 1.6.2
。
// ...
var _spawn = require("child_process").spawn
var EventEmitter = require("events").EventEmitter
function spawn (cmd, args, options) {
var raw = _spawn(cmd, args, options)
var cooked = new EventEmitter()
// ...
}
どうやら、生のspawn
を叩いてるのはspawn.js#L7です。caller は誰でしょう。
// ...
var spawn = require("./spawn")
// ...
lifecycle.js#L6にrequire
がいました。さらに親を辿るとスタックトレースを読む限り、次のようになっています。詳細は読み飛ばしてます。
-
_tickCallback
(たぶんこの辺) - run-script.js#L73
- read-json.jsがなんか色々やってる
- run-script.js#L140
- lifecycle.js#L26
- lifecycle.js がかなり色々やってる
- lifecycle.js#L70
- lifecycle.js#L180
- lifecycle.js#L207
- spawn.js#L7
到着。お疲れ様でした。
SHELL は sh に固定
lifecycle.jsL198-L207が面白いです。
// ...
var sh = "sh"
var shFlag = "-c"
if (process.platform === "win32") {
sh = process.env.comspec || "cmd"
shFlag = "/c"
conf.windowsVerbatimArguments = true
}
var proc = spawn(sh, [shFlag, cmd], conf)
// ...
var sh = "sh"
で決め打ちなんですね。Windows 環境下のみsh = process.env.comspec || "cmd"
となっていますが、Mac 環境下ではsh
を変える方法は見つかりませんでした。
結論として、npm run-script
は常にsh
で実行されるため、私のように Terminal.app ではzsh
を使っていると出力が異なることが稀に起こるようです。
何が問題だったか
具体的には、私の場合/**/*.js
というアスタリスク 2 つのワイルドカード[1]を用いて、この解釈がsh
とzsh
で異なることで Browserify に渡るソースに違いが出たため、何度やっても必要なソースが結合されないという現象が起こりました。(これだけで 2 時間くらいハマった)
child_process について補足
- node.js child_process.exec
- node.js child_process.spawn
- io.js child_process.exec
- io.js child_process.spawn
どうやらexec
ではoptions
引数に{shell: '/bin/zsh'}
とすることで変えられるようです。しかし、なぜかspawn
のオプション仕様にはshell
が含まれていません。
var exec = require('child_process').exec;
exec('echo {a..z}', {shell: '/bin/sh'}, function(error, stdout, stderr) {
console.log('sh');
console.log(stdout);
});
exec('echo {a..z}', {shell: '/bin/bash'}, function(error, stdout, stderr) {
console.log('bash');
console.log(stdout);
});
exec('echo {a..z}', {shell: '/bin/zsh'}, function(error, stdout, stderr) {
console.log('zsh');
console.log(stdout);
});
$ node exec.js
bash
a b c d e f g h i j k l m n o p q r s t u v w x y z
sh
a b c d e f g h i j k l m n o p q r s t u v w x y z
zsh
{a..z}
default shell と揃える? いや、やめておこう
これらのことから、直接child_process.exec
して options を渡せばたしかに Terminal.app と node 上の処理を揃えることはできます。しかしzsh
が入っていることを前提に書くのはどうも抵抗が大きく、明らかに JavaScript ビルドの守備範囲を超えています。
bash 依存で Windows を放置している点は周りを見てもやむを得ない感じですが、zsh 依存は不採用とします。
npm run-script はインタフェースである
以前、@Jxck*さんの記事『npm で依存もタスクも一元化する』を読み、run-script は有用だがタスク自体は書かないという話にとても賛同していました。これを読んだ頃にちょうど Twitter でやりとりをしていました。
<blockquote class="twitter-tweet" lang="en"><p><a href="https://twitter.com/armorik83">@armorik83</a> <a href="https://twitter.com/Jxck_">@Jxck_</a> さきほどの Tweet は言葉足らずでしたね。正確には "npm run の弊害" ではなく "npm run のみでやるときの弊害" でした。 Jxck さんのやり方は統一的なインターフェイスになるので良いと思います。</p>— Takuto Wada (@t_wada) <a href="https://twitter.com/t_wada/status/555910181721079809">January 16, 2015</a></blockquote>
<script async src="//platform.twitter.com/widgets.js" charset="utf-8"></script>
この@t_wada さんの「統一的なインターフェイス」という表現がとても印象的で、冒頭の記事を読んだときに持った違和感もこの感覚の差からきていました。
ビルド途中の個別処理にアクセスさせるべきではない
冒頭の記事では 18 行に及ぶscripts
が定義されていますが、
"build:js": "browserify assets/scripts/app.js > public/files/js/app.js",
"build:css": "bin/build-css.sh",
"build": "npm run build:js && npm run build:css",
のように、幾つかはnpm run
を叩くハイレベル、幾つかはコマンドを直接叩くローレベルの処理を定義しています。.sh
も実行しています。
個人で管理する分にはまだしも、見ず知らずの人間が Fork や PR を検討したときを考えるとこれはあまり美しいとは感じません。ここは処理の玄関として、"build": "gulp build"
やはりこうしておきたいものです。$(npm bin)/gulp build:js
すれば途中を実行できてしまいますが、大玄関としてのpackage.json
は整頓しておきたい、という意図です。
個人管理の面でも、ディレクトリ操作の多いビルド処理では json は変数が使えないため、DRY の観点から不安が残ります。
憔悴しないシンプルな gulpfile を作ろう
以上のことから、sh
の種類で微妙な差異が生まれることを回避し、package.json
玄関に大量のscripts
を並べずに「シンプルな gulpfile を作ろう」というのが今回の提言です。
gulpfile.js
今回 Grunt 卒業から gulp 本格導入を決めて最初に書いたgulpfile.js
です。
import
var del = require('del');
var espower = require('gulp-espower');
var glob = require('glob');
var gulp = require('gulp');
var seq = require('run-sequence');
var shell = require('gulp-shell');
ほぼ gulp プラグインを使っていません。gulp-espower
とgulp-shell
のみ、あとは関連ツールとしてdel
, glob
, run-sequence
です。
opt
var opt = {
example: './example',
lib: './lib',
test: './test',
testEs5: './test-es5',
testEspowered: './test-espowered'
};
ディレクトリを定義しています。こういうのはpackage.json
内ではできません。
clean
/* clean */
gulp.task('clean', del.bind(null, [
opt.example + '/**/*.js',
opt.example + '/**/*.js.map',
opt.lib + '/**/*.js',
opt.lib + '/**/*.js.map',
opt.testEs5,
opt.testEspowered
]));
clean にはdel
が便利。『ファイル削除には gulp プラグインを使わない』を参考にしました。
compile, convert
/* ts */
var tsc = 'tsc -m commonjs -t es6 --noImplicitAny';
gulp.task('ts:example_', shell.task([`find ${opt.example} -name *.ts | xargs ${tsc}`]));
gulp.task('ts:lib_', shell.task([`find ${opt.lib} -name *.ts | xargs ${tsc}`]));
gulp.task('ts:example', function(done) {seq('clean', 'ts:example_', done)});
gulp.task('ts:lib', function(done) {seq('clean', 'ts:lib_', done)});
gulp.task('ts', function(done) {seq('clean', ['ts:lib_', 'ts:example_'], done)});
/* babel */
gulp.task('babel:example', shell.task([`babel ${opt.example} --out-dir ${opt.example}`]));
gulp.task('babel:lib', shell.task([`babel ${opt.lib} --out-dir ${opt.lib}`]));
gulp.task('babel:test', shell.task([`babel ${opt.test} --out-dir ${opt.testEs5}`]));
gulp.task('babel', ['babel:example', 'babel:lib']);
動作例とe2e
のために使う/example
と、ライブラリ本体の/lib
は分けて定義してから、ラッパー・タスクを用意しています。順序が前後してts
したあとclean
が実行されないようにrun-sequence
(ここではseq()
)を利用します。
現行の io.js ならばTemplate Stringsが使えるので、今回は自分用としてお構いなしに使っていますが、node を使う事情がある場合は先にgulpfile.js
をバベってしまうか、'おとなしく' + '結合を使ったほうが'
いいでしょう。
Browserify
/* browserify */
function globToBrowserify(bundler) {
var verbose = (bundler === 'watchify')? '-v' : '';
return function(done) {
var p = new Promise(function(resolve) {
glob(`${opt.example}/**/*.js`, function(er, files) {
resolve(files.join(' '));
});
});
p.then(function(names) {
shell.task([`${bundler} ${names} -p licensify > ${opt.example}${opt.bundle} ${verbose}`])();
done();
});
};
}
gulp.task('watchify_', globToBrowserify('watchify'));
gulp.task('browserify_', globToBrowserify('browserify'));
gulp.task('watchify', function(done) {seq('ts', 'babel', 'watchify_', done)});
gulp.task('browserify', function(done) {seq('ts', 'babel', 'browserify_', done)});
watchify
もここで定義しています。zsh
とsh
の/**
の解釈の違いはglob
で埋めています。該当したファイル名の配列を文字列にまとめて Browserify|watchify に与えます。Browserify と watchify のコマンドの微妙な差はglobToBrowserify()
関数を作って解決しました。Browserify plugin は、なぜかどう頑張っても動かなかったので今回は除外しています。(150405 追記: Browserify 側の仕様変更に対応されたので復活しました。)
あとはglob()
が thenable なら最高だった。
watch
/* watch */
gulp.task('watch', ['watchify', 'ts'], function() {
gulp.watch([`${opt.example}/**/*.ts`, `${opt.lib}/**/*.ts`], ['ts']);
});
watch
もシンプルにいきます。watchify
は前のタスクですでに起動しているので、gulp.watch
対象はts
のみです。sass
やless
があればここに追記するといいでしょう(実際 WebStorm がts
を逐一コンパイルしてくれるので、本当はこれすらいらない)
test
/* test */
gulp.task('espower', function() {
return gulp.src(`${opt.testEs5}/**/*.js`)
.pipe(espower())
.pipe(gulp.dest(opt.testEspowered));
});
gulp.task('test', function(done) {seq('ts:lib_', ['babel:lib', 'babel:test'], 'espower', done)});
ここではテスト前ビルドに留めています。テストは全て ES6 で書き、power-assertのために、babel, espower を通しています。espower
出力はmocha
にやらせる手もあるのですが、色々自分には馴染まなかったのでこうしました。
この例は規模の小さめな単品ライブラリに対してなので、express
とangular
両方の面倒を見る Web アプリケーション…となると膨れますが、「プラグイン依存と更新頻度を警戒しながらシンプルに」という理念は変えずにやりたいです。
package.json
{
"name": "cw-modal",
"dependencies": {
"angular": "1.3.15",
"bluebird": "^2.9.24"
},
"devDependencies": {
"angular-route": "1.3.15",
"babel": "^5.0.8",
"browserify": "^9.0.7",
"del": "^1.1.1",
"dtsm": "^0.9.1",
"glob": "^5.0.3",
"gulp": "^3.8.11",
"gulp-espower": "^0.10.1",
"gulp-shell": "^0.4.0",
"licensify": "^1.1.0",
"mocha": "^2.1.0",
"power-assert": "^0.10.1",
"run-sequence": "^1.0.2",
"superstatic": "^2.0.2",
"testium": "^2.6.0",
"typescript": "1.4.1",
"watchify": "^3.1.0"
},
"scripts": {
"browserify": "gulp browserify",
"build": "gulp build",
"dtsm": "dtsm install",
"e2e": "gulp test && mocha ./test-espowered/e2e/",
"start": "cd example && ss --port 8080 --debug true",
"test": "gulp test && mocha ./test-espowered/unit/",
"watch": "gulp watch"
}
}
見本用に簡略化しています。何をするにも用意していたgrunt-*
は 12 個減り、代わりに増えたgulp-*
は 2 個だけです。
scripts
はテスト用の 2 種だけ&& mocha
して、開発用サーバの起動にcd example && ss
しているものの、残りは gulp に投げました。mocha は gulp で実行するとログ出力に一切色が付かなかったため妥協。
結果としてdevDependencies
の一つ一つの役割は明確になり、今後の取捨選択もしやすいスリムさに。他のプロジェクトも徐々に Grunt から gulp に移行していけると確信を持ちました。
(追記)stream を活かせてない件と釈明
Grunt を使っていた時間が長いので、gulp の利点である stream を活かしきれておらずtsc -> es6 -> babel -> js -> Browserify
なことになっている点は疑問に思われた方もいるかもしれません。
これについての釈明としては、WebStorm が.ts
の保存時に勝手にコンパイルしてしまうので、出力された.js
を拾う必要があったのと、テスト時に sourcemap を出していない理由で中間ファイルの行番号を確認する必要があるからです。(…本当は gulp の良さに気付いてないだけです)
あとがき
ここまでお疲れさまです。余すところなく出すつもりで書いたので、またしても長文記事になりました。正直 npm run-script の挙動を調べている最中は私も憔悴していましたが、この gulpfile にしてから顔は引き締まって血色もよく、お肌はツヤツヤに!
こういった手法は何かと物議を醸しますが、常に時代に合ったシンプルさでやっていきたいものです。最後まで読んでいただき、ありがとうございました。
おわり!
参考
- MOL - Grunt/Gulp で憔悴したおっさんの話
- from scratch - オレ的 Grunt に対する最新の気持ち
- teppeis blog - grunt-parallelize v1.1.0 リリースおよび零細 OSS の継続性について
- Qiita Jxck_ - npm で依存もタスクも一元化する
- Qiita shinnn - ファイル削除には gulp プラグインを使わない
- アカベコマイリ - watchify を試す
- GitHub - gulp recipes
-
globstar というようです。(thx @laco0416 さん) ↩︎
Discussion