JavaScript
Node.js
npm
grunt
gulp

もうgulpで憔悴しない - 低依存gulpfileのレシピ

More than 3 years have passed since last update.

【追記150805】さらに憔悴しないための有用な記事『アカベコマイリ | gulp なしの Web フロントエンド開発』が掲載されましたので、こちらもお勧めします。


こんにちは、@armorik83です。皆さん、Grunt / gulp使ってますか。おなじみなので、ここでは説明はしません。

この記事の要点


経緯

さて、先日このような記事が界隈で広まっていました。


Grunt/Gulpで憔悴したおっさんの話


この記事については同意できるところと、そうでもないところと、両方有りました。ただ、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の二大巨塔が凄まじい

Browserifybabelもおなじみになりました。Webデザイン制作の現場は分かりませんが、フロントエンドで「まだGrunt? もうgulpでしょ」みたいに言われた時代はもはや過去のもので、この二大巨塔前提のビルドが増えました(webpackは個人の観測範囲ではあまり見てない)。

watchifybabelifyといった関連ツールの登場を見ると、Grunt|gulpして更にwatchifyでbabelifyして…というのは、2段構えで冗長に感じます。


npm run-scriptには課題がある

今回記事を書こうと思った理由。package.jsonscriptsnpm 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


spawn.js

// ...

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は誰でしょう。


lifecycle.js

// ...

var spawn = require("./spawn")
// ...

lifecycle.js#L6requireがいました。さらに親を辿るとスタックトレースを読む限り、次のようになっています。詳細は読み飛ばしてます。

到着。お疲れ様でした。


SHELLはshに固定

lifecycle.jsL198-L207が面白いです。


lifecycle.js

  // ... 

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を用いて、この解釈がshzshで異なることでBrowserifyに渡るソースに違いが出たため、何度やっても必要なソースが結合されないという現象が起こりました。(これだけで2時間くらいハマった)


child_processについて補足

どうやらexecではoptions引数に{shell: '/bin/zsh'}とすることで変えられるようです。しかし、なぜかspawnのオプション仕様にはshellが含まれていません。


exec.js

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);
});



Terminal.app

$ 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でやりとりをしていました。

この@t_wadaさんの「統一的なインターフェイス」という表現がとても印象的で、冒頭の記事を読んだときに持った違和感もこの感覚の差からきていました。


ビルド途中の個別処理にアクセスさせるべきではない

冒頭の記事では18行に及ぶscriptsが定義されていますが、


package.json

"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


gulpfile.js

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-espowergulp-shellのみ、あとは関連ツールとしてdel, glob, run-sequenceです。


opt


gulpfile.js

var opt = {

example: './example',
lib: './lib',
test: './test',
testEs5: './test-es5',
testEspowered: './test-espowered'
};

ディレクトリを定義しています。こういうのはpackage.json内ではできません。


clean


gulpfile.js

/* 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


gulpfile.js

/* 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


gulpfile.js

/* 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もここで定義しています。zshsh/**の解釈の違いはglobで埋めています。該当したファイル名の配列を文字列にまとめてBrowserify|watchifyに与えます。Browserifyとwatchifyのコマンドの微妙な差はglobToBrowserify()関数を作って解決しました。Browserify pluginは、なぜかどう頑張っても動かなかったので今回は除外しています。(150405追記: Browserify側の仕様変更に対応されたので復活しました。)

あとはglob()がthenableなら最高だった。


watch


gulpfile.js

/* watch */

gulp.task('watch', ['watchify', 'ts'], function() {
gulp.watch([`${opt.example}/**/*.ts`, `${opt.lib}/**/*.ts`], ['ts']);
});

watchもシンプルにいきます。watchifyは前のタスクですでに起動しているので、gulp.watch対象はtsのみです。sasslessがあればここに追記するといいでしょう(実際WebStormがtsを逐一コンパイルしてくれるので、本当はこれすらいらない)


test


gulpfile.js

/* 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にやらせる手もあるのですが、色々自分には馴染まなかったのでこうしました。


この例は規模の小さめな単品ライブラリに対してなので、expressangular両方の面倒を見るWebアプリケーション…となると膨れますが、「プラグイン依存と更新頻度を警戒しながらシンプルに」という理念は変えずにやりたいです。


package.json


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にしてから顔は引き締まって血色もよく、お肌はツヤツヤに!

こういった手法は何かと物議を醸しますが、常に時代に合ったシンプルさでやっていきたいものです。最後まで読んでいただき、ありがとうございました。

おわり!


参考





  1. globstarというようです。(thx @laco0416さん)