gulp4再入門 gulpfileの分割とnodeモジュールの利用

先に結論だけ

私のポートフォリオサイトのメンテナンスを行い、タスクランナーをgulp4へ更新しました。その結果、gulpfileが8行になりました。

▼./gulpfile.js/index.js

"use strict";

const revision = require("gulptask-revision")("./dist/");
exports.revision = revision;

const { s3_deploy, s3_staging } = require("./awsPublish");
exports.s3_deploy = s3_deploy;
exports.s3_staging = s3_staging;

gulp4は以前のバージョンと比べ、gulpfileの管理工数を減らすことができます。

はじめに

2018年12月にgulp4が正式リリースされました。

Version 4.0 Now Default

gulp4をしばらく使ってみた結果、gulpfileの管理が簡略化できました。この記事はその手法を共有するためのものです。

この記事が想定する読者

この記事はgulp3以前を利用しているユーザーを想定しています。 そのため、gulpのインストールやタスクの構築などは記事の対象としません。

マイグレーションガイド

gulp3を利用している方は、まずgulp4への移行を行います。

公式ドキュメント Quick Start

こちらの記事でgulp3から4へのマイグレーションの方法が詳しく解説されています。

Gulp4がリリースされたのでgulpfile.js をアップデートした

gulp4の利点

gulp3以前には、

  • gulpfileの肥大化問題
  • gulpプラグインの更新問題

という2つの問題があり、これがgulpfileの管理を困難にしていました。

gulp4へ移行することによって、この2つの問題を解消できます。

前提 : gulp4におけるタスクとは

上記2つの問題を解決するための前提として、gulp4におけるタスクの構造を理解しておく必要があります。この構造が、2つの問題を解くことに繋がります。

もっとも単純な形のgulpタスクを、公式ドキュメントから引用します。

function simpleTask(cb) {
  ...
  cb();
}

gulp4におけるタスクは関数です。処理後に、第一引数に渡されたコールバック関数を実行して処理完了を通知することでタスクとなります。

gulpのタスクは非同期処理を行うことを前提としています。そのため、タスクには処理が完了したことを通知する方法を備えている必要があります。

  • streamをreturnする。
  • Promiseをreturnする。
  • async関数にする。
  • 処理の完了後にコールバック関数を実行する。

などの形にすることで、関数はタスクとして機能します。

gulp.task()について

gulp3までで使われていたgulp.task()関数は非推奨になりました。この関数はgulpfileの互換性維持のために用意されたもので、新規での利用は推奨されません。

gulpfileの肥大化問題

gulp3以前では、gulpfile.jsは巨大な単一ファイルとして扱われてきました。数百行から時には千行を超えるファイルの管理は難しいものでした。

gulp4ではこのgulpfileの肥大化を抑制できます。

パブリックタスク / プライベートタスク

公式ドキュメントから、タスクの作成のサンプルを引用します。

▼gulpfile.js

const { series } = require('gulp');

function clean(cb) {
  ...
  cb();
}

function build(cb) {
  ...
  cb();
}

exports.build = build;
exports.default = series(clean, build);

exportされていない関数がプライベートタスク、exportされている関数がパブリックタスクになります。

これらのタスクをターミナルから実行してみます。

$ gulp clean
Task never defined: clean

$ gulp build
Starting 'build'...

$ gulp
Starting 'default'...

exportしていないcleanタスクは実行できませんでした。プライベートタスクは定義されたjsファイルの外からは呼び出すことができません。

プライベートタスクを利用することで、タスク製作者は外部から呼び出すべきタスクを明示できます。gulp3では、すべてのタスクが外部から呼び出し可能でした。そのため、タスクを細分化してメンテナンスを簡単にしようとすると、利用者がどのタスクを使っていいのかわからなくなるという問題がありました。プライベートタスクはこうした問題を解決します。

exportsとは

exportsはnode.jsにおけるmodule.exportsへのショートカットです。named exportをより短く記述するための機能です。

つまりgulp4におけるパブリックタスクとは「module.exportsされ、非同期処理の完了を通知する関数」と言えます。

タスクファイルの分割

gulp4におけるパブリックタスクはexportされた関数です。そのため、gulpfile.jsの外部に記述された関数もrequireで読み込めばタスクとして利用できます。つまりgulpfile.jsが分割できます。

例としてtaskA.jsgulpfile.jsの2つのファイルがあるとします。いずれのファイルもpackage.jsonと同じディレクトリにあるとします。

▼taskA.js

const { series } = require('gulp');

function taskA1(cb) {
  ...
  cb();
}
function taskA2(cb) {
  ...
  cb();
}

exports.taskA = series(taskA1, taskA2);

▼gulpfile.js

const { taskA } = require("./taskA");
exports.taskA = taskA;

gulpfile.jsはtaskA.jsからタスクを読み込み、再度exportしてパブリックタスクにしています。この状態でターミナルから$ gulp taskAとタスクを実行できます。 また、gulpfile.jsとターミナルの両方からプライベートタスクtaskA1およびtaskA2は呼び出せません。

分割ファイルの配置方法

gulpでは、コード分割の際のファイルの配置方法に関する仕組みを提供しています。

公式ドキュメント

package.json
/ gulpfile.js
  ┠ index.js
  ┗ taskA.js

直感的に理解しにくいのですが、gulpfile.jsという名前のディレクトリを配置し、その中にindex.jsを配置します。するとgulpは./gulpfile.js/index.jsを従来のgulpfile.jsと同様にルートファイルとして認識します。

このルールに従わないファイルのrequireも現状では問題なく機能します。しかし、関連するファイルが分散してしまうのを避けるため、こうしたルールにしたがってファイルを配置することをオススメします。

ファイル分割の恩恵

gulpfile.jsは肥大化しやすいファイルです。これを分割することでメンテナンス性が向上します。また、プライベートタスクをファイル分割と併用することで、親ファイルからアクセス可能なタスクを制限できます。

タスクのモジュール化

タスクを記述したjsファイルをnodeモジュール化し、そのモジュールをgulpfileに読み込むことも可能です。

例として、このようなパッケージを作成してGitHubにpushします。名前はsample-taskとします。

▼package.json

{
  "name": "sample-task",
  "main": "./index.js",
  ...
  "dependencies": {
    "gulp": "^4.0.1",
    "gulp-rev": "^9.0.0", // <- タスク内で利用するgulpプラグインをdependenciesに追加
    ...
  },
}

▼index.js

"use strict";

const { series, src, dest } = require("gulp");
const rev = require("gulp-rev");
const path = require("path");
const distPath = path.resolve("./dist/");

function taskA(){
  return src(...)
    .pipe(...)
    .pipe(dest(...);
};

function taskB(){
  return src(...)
    .pipe(...)
    .pipe(dest(...);
};

exports.sampleTask = series(taskA, taskB);

別のプロジェクトから、sample-taskモジュールをnpmでインストールします。

$ npm install https://github.com/[GitHubのユーザーID]/sample-task.git

sample-taskモジュールはgulpfile.jsから読み込めます。

▼gulpfile.js

const { sampleTask } = require("sample-task");
exports.sampleTask = sampleTask;

requireで読み込んだタスクは以下のように利用します。

  • 再度exportすることでバプリックタスクとして利用する。
  • seriesやparallelに組み込み、プライベートタスクとして利用する。

モジュール化されたタスクは、自身のpackage.jsonで依存するプラグインを管理できます。タスクを読み込む側では、依存プラグインの管理の必要はありません。

タスクモジュールに引数を与える

ファイル分割 / モジュール化したタスクに、タスク実行時に変数を与えることはできません。子タスクに変数を与えたい場合「変数を受け取りタスクを返す関数」を使います。

▼index.js

"use strict";

const { series, src, dest } = require("gulp");

module.exports = (arg1, arg2, arg3) => {
  function taskA(){
    return src(arg1)
      .pipe(...)
      .pipe(dest(arg2);
  };

  function taskB(){
    return src(arg1)
      .pipe(...)
      .pipe(dest(arg3);
  };

  return series(taskA, taskB);
}

このモジュールをgulpfile.jsから読み込みます。

▼gulpfile.js

const sampleTask = require("./index")("./path/to/src", "./path/to/dist1", "./path/to/dist2");
exports.sampleTask = sampleTask;

require(モジュールのパスかID)(引数)の形で変数を渡すことができます。

タスクモジュールの例

Webサイト用のgulpfileでよく利用するタスクを切り出し、モジュール化してみました。皆様がモジュール化を行う場合の参考資料としてご利用ください。

gulptask-revision

GitHub リポジトリ

gulp-revを利用して、出力されたファイルにリビジョンを振る一連のタスクをモジュール化したものです。

$ npm install https://github.com/MasatoMakino/gulptask-revision.git -D

でインストールして

▼gulpfile.js

const rev = require("gulptask-revision")("変換するディレクトリのパス");

と読み込んで利用します。

gulptask-imagemin

GitHub リポジトリ

gulp-imageminを利用して、画像ファイルの最適化を行います。

モジュールの利用方法は

$ npm install https://github.com/MasatoMakino/gulptask-imagemin.git -D

でインストールして

▼gulpfile.js

const images = require("gulptask-imagemin").get("画像ソースのディレクトリ", "出力先ディレクトリ");

と読み込んで利用します。 watchのタスクとしても動作します。

gulpプラグインの更新問題

gulpの維持管理を難しくするもうひとつの要因として、gulpプラグインの更新問題があります。依存するプラグインの更新が途絶えた場合、プラグインをフォークして自力でメンテナンスするか、そのタスクをgulpから切り離すかの選択を迫られます。

gulpというレイヤーが増える分、依存するモジュールは増え、更新停止のリスクは増加します。ならば最初からnodeモジュールを直接実行したほうがメンテナンスのリスクは少なくなります。

nodeモジュールがそのまま走る

gulp4のタスクは前述の通りただの関数です。そのためタスク内でnodeモジュールがそのまま走ります。

公式ドキュメントからサンプルを引用します。

▼gulpfile.js

const { rollup } = require('rollup');

// Rollup's promise API works great in an `async` task
exports.default = async function() {
  const bundle = await rollup.rollup({
    input: 'src/index.js'
  });

  return bundle.write({
    file: 'output/bundle.js',
    format: 'iife'
  });
}

rollup.jsのバンドル処理はasync/awaitに対応しています。そのため、タスクの関数をasyncにすることで簡単に取り込めます。

const del = require('delete');

exports.default = function(cb) {
  // Use the `delete` module directly, instead of using gulp-rimraf
  del(['output/*.js'], cb);
}

async/awaitに対応せず、非同期処理の完了後にコールバックを呼ぶタイプのモジュールもあります。その場合タスクの引数のコールバックをそのままモジュール側の関数に渡してしまうことで取り込めます。

公式ドキュメントでも、ファイル変換を伴わない処理ではgulpプラグインよりもnodeモジュールの使用を推奨しています。

Plugins should always transform files. Use a (non-plugin) Node module or library for any other operations.

依存するモジュールの量と層を減らすことで、プラグインの更新停止リスクは減少します。

個人的な感想

破壊的変更

gulp4は3以前と比べるとまったくの別物です。これだけ大規模かつ破壊的な変更をしながらgulpプラグインの互換性がほぼ維持されていることに驚きました。

gulp4のオススメ度

個人的なgulp4への移行のオススメ度を、状況別にまとめると

  • gulp3を利用している : 文句なしにオススメです。
  • すでに他のツールでワークフローができ上がっている : 既存のプロジェクトに導入する必要はありません。
  • 新規のプロジェクトに導入する : 部分的な導入も可能なので検討の価値ありです。

となります。

タスクモジュールの粒度

タスクをモジュール化した場合、対象とする作業はプラグインよりも巨大になり、その分汎用性は低くなります。タスクをどの程度の粒度で切り出すべきなのかは今後の課題です。

タスクモジュールの副作用

モジュール化されたタスクは、以前のgulpプラグイン同様に依存レイヤーを増やすことになります。更新停止のリスクや、破壊的更新による影響の増大がモジュール化の副作用として考えられます。

参考記事

JavaScriptのモジュールシステムの歴史と現状

exports / requireとそのほかのモジュールシステムの歴史がまとめられています。gulpタスクの構造を理解することができました。

JavaScriptをやっていると npm/yarn/gulp/grunt/webpack など、たくさんのツールがあって混乱したので、それぞれの役割と違いをざっくりとまとめた

現状のgulpの立ち位置を、そのほかのツールと比較して再確認できる素晴らしい記事です。

以上、ありがとうございました。