Gulpはタスクランナーである。JSに軸足を置くバンドラー(Webpackなど)やビルドツール(Viteなど)とは異なり、Web制作のさまざまなタスクを並列的に処理できる。

バンドラー(Webpackなど)との違い

バンドラーは、文字通りバンドルをするためのツールであり、タスクランナーはタスクを走らせるためのツールである。

バンドルはタスクの一種だ。タスクランナーからバンドラーを使うことはできるが、その逆はできない(できるかもしれないが、普通はやらない)。

各種トランスパイルに対応したり、テストサーバーを起動したり、タスクランナーと重なる機能をバンドラーも持っている。ただ、その主眼はJavaScriptを使ったアプリ開発にある。

HTML/CSSでゴリゴリWebページを作りたい場合には、タスクランナーの方が適している。

と私は思う。

Gulpは古いのか?

古い。Webpackも古い。CSSも古いし、HTMLはもっと古い。時間が経てばなんだって古くなる。

ゲシュタルト崩壊した。改めて見ると古いって怖い形の漢字だ。呪いに使われそうである。

Gulpは確かに古く、プラグインの中にも、メンテナンスされているのかいないのかわからないものが散見される。時代遅れとまでは言えないが、ピークは去った。

ただ、Gulpの最新バージョンは2024年3月29日にリリースされたばかりである。更新されないプラグインも、現実から目を背ければ機能が成熟したのだと解釈できる(白目)。ともかく、まだまだ使えるツールであることは間違いない。

インストール方法

Gulpを動かすには、コマンドラインツールであるgulp-cliと、本体のgulpというNPMモジュールが必要だ。

まずgulp-cliをグローバルインストールする(気持ちが悪ければ、ローカルでも良い)。

bash
npm i -g gulp-cli

次にgulpを入れる。

bash
npm i -D gulp

準備おしまい。

使い方

Gulpでは、プロジェクトのルートディレクトリにgulpfile.js(あるいはGulpfile.js)というファイルを置き、その中に実行したいタスクを関数として定義する。

js
function myFirstTask(cb) {
  console.log("Hello, Gulp!");
  cb();
}

exports.default = myFirstTask;

JavaScriptには、ファイルをインポートしたりエクスポートしたりする仕組みが大きく2つある。require()関数とexportsオブジェクトを使うCommonJSと、import/exportキーワードを使うES Modulesである。

GulpはJavaScriptで書かれているため、どちらも使える。ただ、GulpのドキュメントはCommonJSで統一されているようなので、本記事でもそれに倣う。

上記のコードは、コマンドライン上のgulpコマンドで実行できる。gulp-cliをローカルに入れた場合はnpx gulpとする。

実行すると、以下のように処理内容が出力されるはずだ。

bash
❯ gulp
[00:00:00] Using gulpfile ~/path/to/gulpfile.js
[00:00:00] Starting 'default'...
Hello, Gulp!
[00:00:00] Finished 'default' after

タスクについて

Gulpのタスクは、JavaScript関数として定義する。この関数は、非同期処理を含んでいなくても、非同期的に処理される(昔は同期関数は同期関数として実行できたが、現在ではすべてのタスクが非同期的に管理される)。

そのため、必要に応じてタスクの完了をGulpに通知せねばらならない。引数に完了通知用のコールバック関数を受け取って実行するか、Promiseやストリーム(やイベントエミッターや子プロセスやObservable)を返すか、そもそもasyncをつけて関数を定義してやる必要がある。

関数をGulpのタスクとして実行できるようにするには、以下のようにexportsオブジェクトのプロパティとして代入する。

js
function defaultTask(cb) {
  console.log("defaultに入れた関数は`gulp`コマンドだけで実行される。");
  cb();
}

function namedTask(cb) {
  console.log("任意のキー名に入れた関数は`gulp タスク名`で実行される。");
  cb();
}

exports.default = defaultTask;
exports.namedTask = namedTask;

defaultに代入したものはgulpコマンドで、それ以外の任意のプロパティに代入したタスクはgulp プロパティ名コマンドで実行できる。

series()とparalell()

複数のタスクをまとめて実行したい時には、series()関数やparalell()関数を使う。

タスクの合成に使う関数 概要
series(task1,task2,...) 引数に渡されたタスク(関数)を連続して実行する。前の関数の処理が終わらないと、次の関数は実行されない。
paralell(task1,task2,...) 引数に渡されたタスク(関数)を並列的に実行する。

両者は組み合わせて使うこともできる。

js
const { series, parallel } = require("gulp");

async function pugToHtml() {
  await new Promise((resolve) => setTimeout(resolve, 500));
  console.log("PugからHTMLへトランスパイルする処理");
}

function sassToCss(cb) {
  console.log("SASSからCSSへトランスパイルする処理");
  cb();
}

function tsToJs(cb) {
  console.log("TSからJSへトランスパイルする処理");
  cb();
}

function minify(cb) {
  console.log("圧縮処理");
  cb();
}

exports.build = series(parallel(pugToHtml, sassToCss, tsToJs), minify);

これを実行すると、以下のような結果が出力されるはずである。

bash
[14:20:38] Starting 'build'...
[14:20:38] Starting 'pugToHtml'...
[14:20:38] Starting 'sassToCss'...
[14:20:38] Starting 'tsToJs'...
SASSからCSSへトランスパイルする処理
[14:20:38] Finished 'sassToCss' after 1 ms
TSからJSへトランスパイルする処理
[14:20:38] Finished 'tsToJs' after 1 ms
PugからHTMLへトランスパイルする処理
[14:20:38] Finished 'pugToHtml' after 507 ms
[14:20:38] Starting 'minify'...
圧縮処理
[14:20:38] Finished 'minify' after 2 ms
[14:20:38] Finished 'build' after 511 ms

並列処理と逐次処理が適切に実行されていることがわかる。

src()とdest()とpipe()

Gulpでは、Vinylという仮想的なファイルフォーマットを使ってファイルを操作する。

操作の軸となるのは、Gulpのsrc()dest()、それからNode.jsのpipe()という3つの関数である。

ファイル操作に使う関数 概要
src(globs[, options]) 第一引数に渡されたパスにマッチするファイルをVinylオブジェクトとして読み込み、そのオブジェクトを保持する読み取り可能なストリームオブジェクトを返す。
dest(folder[, options]) 第一引数に渡されたディレクトリにVinylオブジェクトを出力する書き込み可能なストリームオブジェクトを返す。
readable.pipe(destination[, options]) 書き込み可能な(あるいは読み書き可能な)ストリームを受け取り、読み取り可能な(あるいは読み書き可能な)ストリームに転送する。

"glob"(グロブ)は、文字列特定の記号を用いて文字列のパターンを表現する方法である。大元であるUnixの記法が踏襲されるものの、細部はglobを採用するツールによって異なる。ちなみにglobはglobalの略語だそうである。

Gulpの場合、以下のような記号を使う。

記号の種類 意味
/ セグメントの区切り文字。
* 1つのセグメントに含まれる0文字以上の文字列。
** 複数のセグメントに跨る0文字以上の文字列。
! そのglobの否定(マッチしないものにマッチ)。

例えば、静的ファイルをソースディレクトリからpublicディレクトリにコピーしたい時は、以下のように書く。

js
const gulp = require("gulp");

function copyStaticAssets() {
  return gulp.src("src/assets/**/*").pipe(gulp.dest("dist/assets"));
}

exports.copyStaticAssets = copyStaticAssets;

実際には、src()とpipe(dest(‘xxx’))の間にプラグインの処理を挟んで、望む処理を形にしていく。

watch()

ディレクトリやファイルを監視して、任意のタスクを実行したい時には、watch()関数を使う。

監視に使う関数 概要
watch(globs[, options, task]) 指定されたglobにマッチするファイル、あるいはディレクトリを監視し、対応するタスクを実行するよう設定する。その上で、設定を細かく制御するためのchokidarオブジェクトを返す。

第2引数のoptionsには、以下のような属性値を指定できる(値はデフォルト値)。

js
{
	ingnoreinitial:true,  //起動時のスキャンでタスクを実行する
  delay:200,            //検知からタスク実行までのラグ
  queue:true,           //別タスクの実行中にファイル変更を検知したらそのタスクをキューへ入れる
  events:['add, change', 'unlink'],//タスクを実行するイベント(一覧は後述)
  persistent:true,      //プロセスを実行し続けるか。基本true
  ignore:'',            //無視するglob
  followSymlinks:true,  //シンボリックリンクの変更でイベントをトリガーする
  cwd:'',               //相対パスとくっつけて絶対パスを作るためのパス
  disableGlobbing:false,//globをただの文字列として扱う
  usePolling:false,     //fs.watch()の代わりにfs.watchFile()を使う
  interval:100,         //usePollingがtrueの時の、ファイルシステムのポーリングの間隔
  binaryInterval:300,   //usePollingがtrueの時の、バイナリファイルのポーリングの間隔
  useFsEvents:true,     //fseventsが利用可能な場合、それを監視に使用する
  alwaysStat:false,     //変更されたファイルに対して常にfs.stat()を呼び出す
  //depth:,             監視するディレクトリのネスト数
  awaitWriteFinish:false,//使わない。代わりにdelayを使う
  ignorePermissionErrors:false,//読み取り権限のないファイルを監視する
  atomic:100//謎。
}

第3引数のtaskには、任意のタスク関数か、series()あるいはparallel()で作成した複合タスクを指定する。

なお、watch()関数が返すchokidarインスタンスは、Gulpが内部で使っているファイル監視用ライブラリのオブジェクトである。chokidarという名称は、ヒンディー語で門番とか番人とかいう意味を持つ単語らしい。グーチョキパーのチョキは関係ない。

先のoptionsは、厳密にはwacth()関数ではなくchokidarインスタンスためのオプションだったりする。

chokidar

watch()関数を使うと、ファイルやディレクトリの変化に応じて任意のタスクを実行できる。ただ、変化したファイルやディレクトリに絞って任意のタスクを実行するには、それらのパスを取得し、別途src()で読み込んでやる必要がある。

パスは、chokidarインスタンスのon()メソッドで監視用のイベントリスナを登録し、その引数として受け取ることができる。

js
const { watch, src, dest } = require("gulp");
const ts = require("gulp-typescript"); //このプラグインについては後述。

function tsToJs(path) {
  return src(path).pipe(ts()).pipe(dest("dist/"));
}

exports.default = function () {
  watch("src/ts/*.ts").on("change", (path) => tsToJs(path));
};

onメソッドでは、以下のようなイベントをリッスンできる。

イベント名 概要
add ファイルの追加。
addDir ディレクトリの追加。
change ファイルの変更。
unlink ファイルの削除。
unlinkDir ディレクトリの削除。
ready 初期スキャン完了。
error エラー。
all ぜんぶ。

また、イベントリスナは以下の2つの引数を受け取る。

引数 概要
path 変更されたファイルのパス。
stats 読み込んだファイルのfs.Statオブジェクト。

また、唐突に出てきたts = require("gulp-typescript");はGulpのプラグインである。ここでのプラグインは、有志によって作られた、使用頻度の高い普遍的なタスクを指す。

プラグイン

Gulpのプラグインは、Gulpとは独立したNode.jsパッケージである。多くはgulp-という接頭辞がついている。

必要に応じてパッケージをインストールし、gulpfile.jsにインポートして使う。代表的なプラグインをいくつかピックアップしてみる。

gulp-pug PugをHTMLにトランスパイルするプラグイン。
gulp-sass SASSをCSSにトランスパイルするプラグイン。
gulp-typescript TypeScriptをJavaScriptにトランスパイルするプラグイン。
browser-sync Gulpプラグインではないが、開発サーバとしてGulpと組み合わせてよく使われる。

gulp-pugの使い方

まず必要なパッケージをインストールする。

bash
npm i -D gulp-pug pug

必要な関数をインポートして使う。

js
const { src, dest, watch } = require("gulp");
const pug = require("gulp-pug");

function pugToHtml(path) {
  return src(path).pipe(pug()).pipe(dest("dist/"));
}

exports.default = function () {
  watch("src/pug/*.pug").on("change", (path) => pugToHtml(path));
};

どのプラグインも、この流れはおおよそ共通している。オプションはがんばってドキュメントを読み解いて使う。

gulp-sassの使い方

以下同文。と思ったけど単純に関数をインポートしただけでは使えないものもある。やっぱり、個々にドキュメントを読んでがんばるしかない。

bash
npm i -D gulp-sass sass
js
const { watch, src, dest } = require("gulp");
const sass = require("gulp-sass")(require("sass"));

function sassToCss(path) {
  return src(path).pipe(sass()).pipe(dest("dist/"));
}

exports.default = function () {
  watch("src/sass/*.scss").on("change", (path) => sassToCss(path));
};

gulp-typescirptの使い方

以下同文。

bash
npm i -D gulp-typescript typescript
js
const { watch, src, dest } = require("gulp");
const ts = require("gulp-typescript");

function tsToJs(path) {
  return src(path).pipe(ts()).pipe(dest("dist/"));
}

exports.default = function () {
  watch("src/ts/*.ts").on("change", (path) => tsToJs(path));
};

browser-syncの使い方(ブラウザの自動リロード)

GulpのタスクはただのJavaScript関数であるので、Gulpプラグインとして作られていないものであってもタスクとして指定できる。

bash
npm i -D browser-sync

このケースではストリームを返さないので、コールバックを実行してタスクの完了を通知する必要がある。

js
const { watch } = require("gulp");
const browserSync = require("browser-sync").create();

function reloadBrowser(cb) {
  browserSync.reload();
  cb();
}

exports.default = function () {
  browserSync.init({
    server: "./public",
  });
  watch("dist/", reloadBrowser);
};

また、このケースに限らないが、ファイルやディレクトリを指定するのに、globを使うものと普通の相対パスを使うものとで混乱しやすい点にも注意が必要である。

参考資料