📝

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

2021/07/04に公開

【追記 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

// ...
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#L6requireがいました。さらに親を辿るとスタックトレースを読む限り、次のようになっています。詳細は読み飛ばしてます。

到着。お疲れ様でした。

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

child_process について補足

どうやら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);
});
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 でやりとりをしていました。

<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が定義されていますが、

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

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

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もここで定義しています。zshsh/**の解釈の違いは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のみです。sasslessがあればここに追記するといいでしょう(実際 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にやらせる手もあるのですが、色々自分には馴染まなかったのでこうしました。


この例は規模の小さめな単品ライブラリに対してなので、expressangular両方の面倒を見る 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 にしてから顔は引き締まって血色もよく、お肌はツヤツヤに!

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

おわり!

参考

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

Discussion