`gulp-watch` の第二引数 `options` の `ignore` の挙動について (不完全メモ)

経緯

ひょんなことから gulp.src に第二引数で options としてオブジェクトを渡すことができることを知りまして。

その中の ignore キーを使って、 ! パターンよりも可読性の高そうな書き方に置換できないか、と試してみました。

しかし、 JavaScript の処理が上手く動かなくなってしまいました。

現象

現象としては、以下の状況に陥りました。

  1. gulp でタスクを走らせる初回は正常にトランスパイルとブラウザ起動( Browsersync )が行われる
  2. .js ファイルに何らかの変更を加えて上書き保存をすると、 .js 関連のタスクと Browsersync のリロードが無限に繰り返される

.js ファイルへの処理

この .js ファイルへの処理は以下の処理を gulp.series でまとめたものになります。

  1. jQuery等のライブラリの .js ファイルを gulp-concat で連結し、 ./src/js/concat/ の中に lib.js として中間生成
  2. ./src/js/concat/ の中(サブディレクトリ下も含む)の .js ファイル(1.で生成された lib.js を想定)を gulp-uglify-es で uglify して、結果を ./dist/js/ の下に XXX.min.js として出力
  3. ./src/js/ の中(サブディレクトリ下も含む)の .js ファイルを gulp-uglify-es で uglify して、結果を ./dist/js/ の下に YYY.min.js として出力
    • ただし ./src/js/concat/ は対象から除外

browsersync.js

Browsersync から .js ファイルへ処理をキックするトリガー部分について、以前は以下のように記述されていました。

const gulp        = require('gulp');
const watch       = require('gulp-watch');
const browserSync = require('browser-sync');
const jsBuild     = require('./js');

const browsersync = () => {
    watch(
        [
            './src/js/**/*.js',
            '!./src/js/concat/**/*.js'
        ],
        gulp.series(jsBuild, browserSync.reload)
    );
};

module.exports = browsersync;

これを、以下のように書き換えたところ、上述の現象が発生するようになりました。

const gulp        = require('gulp');
const watch       = require('gulp-watch');
const browserSync = require('browser-sync');
const jsBuild     = require('./js');

const browsersync = () => {
    watch(
        './src/js/**/*.js',
        {
            ignore: './src/js/concat/**/*.js'
        },
        gulp.series(jsBuild, browserSync.reload)
    );
};

module.exports = browsersync;

調査(不完全)

まずgulp-watch – npm(5.0.1) を見ると、 options の項目に以下のような記述があります。

This object is passed to the chokidar options directly. Options for gulp.src are also available.

gulp.srcoptions が使用可能、ということなので gulp.src のドキュメント(src() | gulp.js)を見ます。

ignorestring
array
Globs to exclude from matches. This option is combined with negated globs. Note: These globs are always matched against dot files, regardless of any other settings. This option is passed directly to node-glob.

気になるのは2行目の記述。他の設定に関わらず dot files との照合が常に行われるようになる、とのこと。

また、 This option is passed directly to node-glob. とも書いてあります。ということで、リンク先の node-glob のドキュメントを見ます。

ignore Add a pattern or an array of glob patterns to exclude matches. Note: ignore patterns are always in dot:true mode, regardless of any other settings.

先ほどの「他の設定に関わらず dot files との照合が常に行われるようになる」の正体が書いてあります。 dot オプションが常に true として動作するようになる、とのことです。

それでは、この dot オプションの説明はと言うと……。

dot Include .dot files in normal matches and globstar matches. Note that an explicit dot in a portion of the pattern will always match dot files.

これを読む限り、.gitignore のような dot files を通常のマッチと globstar (/**/)でマッチするように動作する、ということで、普通のファイル(app.js のような普通のファイル名のファイル)は影響を受けなさそうな感じがします。

しかし、 gulp.srcdot キーについて見てみると

dotbooleanfalseIf true, compare globs against dot files, like .gitignore. This option is passed directly to node-glob.

とあるので、 node-globoptions の説明と照合すると gulp.srcignore キーに指定を行うと、 dot キーがデフォルトの false の挙動から、 node-glob 側では true の挙動に置き換わるのではないか、と思われます。

他に挙動が変化する、という説明はざっとドキュメントを眺めた感じでは見付けられなかったので、現状ではここの挙動の変化が「 ./src/js/ の中(サブディレクトリ下も含む)の .js ファイルを gulp-uglify-es で uglify して、結果を ./dist/js/ の下に YYY.min.js として出力 (ただし ./src/js/concat/ は対象から除外)」の挙動に影響を及ぼしているのではないか……と考えています。

しかし、一見すると影響はなさそうなオプションなので断定できず、かといって今後また同じところで嵌まるかもしれないので「不完全メモ」とさせていただきます。

備考1

gulp-watch は上述の通りダメでしたが、 gulp.srcoptions として ignore があるならば、 gulp デフォルトで持っている gulp.watch ならばどうでしょうか。

ignoredarray
string
RegExp
function
array
Defines globs to be ignored. If a function is provided, it will be called twice per path – once with just the path, then with the path and the fs.Stats object of that file. This option is passed directly to chokidar..

……こちらは ignore ではなく ignored というキーのようです。紛らわしい。

これを使って以下のように書き換えます。

const gulp        = require('gulp');
const browserSync = require('browser-sync');
const jsBuild     = require('./js');

const browsersync = () => {
    gulp.watch(
        '.src/js/**/*.js',
        {
            ignored: './src/js/concat/**/*.js'
        },
        gulp.series(jsBuild, browserSync.reload)
    );
};

module.exports = browsersync;

これで直った……かと思ったら、初回実行、1度目の上書きまでは動作しますが、2回目以降の上書きでは動作しませんでした。……惜しい!

ただし、無限ループは止まったので ignored キーはしっかりと効いているようです(最終結果は末尾に付記します)。

最初に立ち戻って gulp-watchoptions を見ると

This object is passed to the chokidar options directly. Options for gulp.src are also available.

とありました。 gulp.watchThis option is passed directly to chokidar. とあるように、使用しているのは同じ chokidar のはずです。

バージョンの差異なのか、あるいは呼び出し元のプログラムの挙動の違いなのか……この挙動の違いを見るために、2つのコードを見比べみます。

gulp-watch

まずは gulp-watch

// 略

function watch(globs, opts, cb) {
    var originalGlobs = globs;
    globs = normalizeGlobs(globs);

// 略

    var baseForced = Boolean(opts.base);

// 略

    var watcher = chokidar.watch(globs, opts);

// 略

}

chokidar に対して引数で渡された globsopts を渡しています。

双方ともに多少の処理が入っていますが、 opts の方を見ると cwd, base, events 等が対象になっています。が、 ignored については触れていません。

気になるのは先ほどのキー名で、 gulp-watchgulp.srcoptions も使用可能ということで ignore キーとして指定(これは引き渡し先が node-globsoptions のため)していましたが、 chokidarignored なのですよね。

試しに

const gulp        = require('gulp');
const watch       = require('gulp-watch');
const browserSync = require('browser-sync');
const jsBuild     = require('./js');

const browsersync = () => {
    watch(
        '.src/js/**/*.js',
        {
            ignored: './src/js/concat/**/*.js'
        },
        gulp.series(jsBuild, browserSync.reload)
    );
};

module.exports = browsersync;

と書き換えても現象は変化しませんでした。やはり上手く効いていない模様。

gulp.watch

続いて gulp.watch

// 略

var defaultOpts = {
  delay: 200,
  events: ['add', 'change', 'unlink'],
  ignored: [],
  ignoreInitial: true,
  queue: true,
};

// 略

function watch(glob, options, cb) {
  if (typeof options === 'function') {
    cb = options;
    options = {};
  }

  var opt = defaults(options, defaultOpts);

// 略

  // We only do add our custom `ignored` if there are some negative globs
  // TODO: I'm not sure how to test this
  if (negatives.some(exists)) {
    var normalizedPositives = positives.map(joinCwd);
    var normalizedNegatives = negatives.map(joinCwd);
    var shouldBeIgnored = function(path) {
      var positiveMatch = anymatch(normalizedPositives, path, true);
      var negativeMatch = anymatch(normalizedNegatives, path, true);
      // If negativeMatch is -1, that means it was never negated
      if (negativeMatch === -1) {
        return false;
      }

      // If the negative is "less than" the positive, that means
      // it came later in the glob array before we reversed them
      return negativeMatch < positiveMatch;
    };

    opt.ignored = [].concat(opt.ignored, shouldBeIgnored);
  }
  var watcher = chokidar.watch(toWatch, opt);

// 略

}

chokidartoWatchglobs, optoptions を渡しています。

特に ignored についての処理がありますが、これは negative globs (! で始まる globs )がある場合にそれを ignored キーで指定したオブジェクトと連結しているようです。

そのため、 negative globs を使用していない今回では影響はなさそう。

……そう考えると、決定的な違いが分からなくなってきました。

コードの中身が全く別物なので挙動として異なる挙動をしてもおかしくはなさそう、とは言えそうですが。

備考2

備考1であと一歩のところまで来た感じがあります。

ただ、2回目以降のイベントが上手く拾えない……というところで chokidar のドキュメント等を見た結果、 on というイベントハンドラーがあることに気付きました。

これを利用した結果が以下。

const gulp        = require('gulp');
const watch       = require('gulp-watch');
const browserSync = require('browser-sync');
const jsBuild     = require('./js');

const browsersync = () => {
    gulp.watch(
        '.src/js/**/*.js',
        {
            ignored: './src/js/concat/**/*.js'
        }
    )
        .on('add'   , gulp.series(jsBuild, browserSync.reload))
        .on('change', gulp.series(jsBuild, browserSync.reload))
        .on('unlink', gulp.series(jsBuild, browserSync.reload));
};

module.exports = browsersync;

これで試した結果、ようやく意図した挙動になりました。……長かった。

余談1

gulp-watch でも gulp.watch でも登場した chokidar について見てみると、これは fs のファイル監視をラップしたパッケージとのことです。

名前がよく分からない感じでしたが、 chokidar のドキュメントの最後に

Why was chokidar named this way? What’s the meaning behind it?

Chowkidar is a transliteration of a Hindi word meaning ‘watchman, gatekeeper’, चौकीदार. This ultimately comes from Sanskrit _ चतुष्क_ (crossway, quadrangle, consisting-of-four).

とありました。サンスクリットで「警備員・夜警・番人・門番(英語の watchman, gatekeeper )」の意味だそうです。

確かにファイル監視ですからね。個人的にはこういうネタは大好きです。

余談2

Ususama がいつから gulp-watch を使用していたかも気になったので洗い出してみました。

詳細な記録は残っていなかったのですが、

ver.3.6.1-4.1.3 ( Gulp のバージョンは3.9.1)では今回の最終結果と同様 gulp.watch の書き方をしていました。

※ただし、現在の gulp.watchchokidar ですが、Gulp 3.9.1 は vinyl-fs の中の watch を使用していました

しかし、以下の理由のいずれか、または複数に該当したために gulp-watch に移行した……のかもしれません(うろ覚え)。

  • Gulp 4.0.0 以降の gulp.seriesgulp.parallel を使用した書き方や Browsersync との兼ね合い
  • リリースしたばかりで変更点に追いつけず、ドキュメントの読み込みなどもできなかった
  • 上述の gulp.watch のコールバック指定だと1回しか watch されない現象に遭遇した?(記憶がない)

まとめ

  • gulp-watch で第一引数の globs! (negative globs) で除外するのではなく、第二引数 optionsignore キー( gulp.src と同じものが使える、とあったので)で指定しようとしたら、一部タスクが正常に動作しなくなった
  • Gulp デフォルトの gulp.watch に切り替えたところ、上述の症状は発生しなかったが、別の現象が発生した
    • gulp-watchgulp.watch のコードを見比べたが、挙動の違いの原因は特定できなかった
  • gulp.watch のイベントハンドラを使用することで、意図した挙動にすることができた

長くなりましたが、現状を記録として残すためにメモとさせていただきます。

参考

この記事を書いた人

アルム=バンド

フロントエンド・バックエンド・サーバエンジニア。LAMPやNodeからWP、Gulpを使ってejs,Scss,JSのコーディングまで一通り。たまにRasPiで遊んだり、趣味で開発したり。