Beim Wechsel zwischen vielen Frontend-Projekten muss es schnell gehen. Dabei möchte ich mich nicht im Code wiederholen und build-Tasks für gulp zwischen den Projekten einfach austauschen können. Dazu ist es nötig, die Konfiguration vom Code zu trennen und grunt-ähnlich die Konfiguration separat vom Task zu erledigen.

Verzeichnisstruktur

Die Ausgangssituation

Im einfachsten Fall arbeitet man bei gulp mit einer einzigen Datei. In dieser werden die Tasks registriert und die einzelnen Streams abgearbeitet. Ein (reduziertes) Beispiel mit uglifyjs:

gulpfile.js -

var gulp = require('gulp'),
    uglify = require('gulp-uglify');
    
gulp.task('uglify', function() {

    return gulp.src('./src/js/*')
        .pipe(uglify())
        .pipe(gulp.dest('./dist/js/'));
});

Kurz die Konfiguration rausgezogen:

var gulp = require('gulp'),
    uglifyConfig = require('./uglifyConfig');
    uglify = require('gulp-uglify');
    
gulp.task('js', function() {

    var stream = gulp.src(uglifyConfig.src)
        .pipe(uglify())
        .pipe(gulp.dest(uglifyConfig.dest));
        
    return stream;
});

Alleine durch das Herausziehen der Konfiguration wäre ich schon mal so weit, dass ich den Quellcode der einzelnen Tasks zwischen den Projekten hin- und herkopieren könnte. Um das anständig warten zu können, will ich jedoch die tasks nicht alle in einer Datei definieren. Das wird schnell groß und unübersichtlich.

Am Ende will ich tasks zwischen den Projekten austauschen können ohne in den Code blicken zu müssen — dazu muss die Konfiguration als auch die Taskdefinition in einer separaten Datei vorliegen.

Der Weg zu einer sauberen Verzeichnisstuktur

Da wir hier mit CommonJS arbeiten können, kann man den task auch komplett aus dem gulpfile.js rausziehen und das in einem eigenen Unterverzeichnis aufbauen. Auf das obige Beispiel mit uglifyjs gemünzt kann das so aussehen:

Verzeichnisstruktur:

gulpfile.js
|
gulp
|
├── config
|    └── uglify.js
|
└── task
    └── uglify.js

Um leichter den Überblick zu behalten, hat eine Konfigurationsdatei eines tasks per Konvention den gleichen Dateinamen wie der task selbst. Die Konfiguration selbst enthält ausschließlich statische Angaben, bspw. Pfade zu Quell- und Zielverzeichnis des tasks.

Der gulp-Code sieht nun folgendermaßen aus:

gulpfile.js:

require('./gulp/task/uglify');

gulp/config/uglify.js

module.exports = {
    src : './src/js/*',
    dest : './dist/js/'
}

Da das Arbeitsverzeichnis sich nicht ändert, beziehen sich die Pfadangaben immer noch auf den Pfad, in dem die gulpfile.js-Datei vorliegt. Heißt erstmal, hier muss ich nichts anpassen und die Pfade kann ich aus dem Original eins zu eins kopieren.

gulp/task/uglify.js

var gulp = require('gulp'),
    config = require('./../config/uglify');

gulp.task('js', function() {

    var stream = gulp.src(config.src)
        .pipe(uglify())
        .pipe(gulp.dest(config.dest));
        
    return stream;
});

Damit lassen sich die gulp-tasks schon mal leichter zwischen den Projekten austauschen, da ich nur noch Dateien hin- und herkopieren muss.

Damit bin ich fast am Ziel. Die einzelnen Aufgaben muss ich jedoch immer noch in der gulpfile.js registrieren.

Engültige Verzeichnisstruktur

Getreu dem unix-Motto „Everything is a file“ kann ich beim Aufruf von require ein CommonJS-Modul entweder über eine Datei mit dem zug. Namen einbinden oder ich verwende stattdessen ein Verzeichnis mit gleichem Namen und verschiebe den Code in die darunter liegende Datei index.js.

Das bedeutet, dass das Verzeichnis gulp nicht benötigt wird, da ich gulpfile.js als Verzeichnis anlegen kann.

Die Verzeichnisstruktur sieht am Ende so aus:

gulpfile.js
|
├── index.js
|
├── config
|    └── task.js
|
├── task
|    └── task.js
|
└── util
    └── utility.js

Um die tasks nicht mehr manuell hinzufügen zu müssen, bediene ich mich dem Tool require-dir.

index.js

require('require-dir')('./task', { recurse: true });

Die Tasks registrieren sich nun eigenständig bei gulp und werden damit zwischen den Projekten austauschbar.

Stream handling und Fehlermanagement

Mithilfe von stream-combiner2 lassen sich viele Streams zu einem einzigen kombinieren.

Damit ist der gulp-Task nicht nur schneller notiert, auch das Error-Handling wird deutlich einfacher⁽²⁾. Der Error-Handler ist alleine schon deswegen wichtig, damit ein watch-task nicht bei jedem Fehler sofort abbricht.

var config = require('./../config/uglify'),
    handleErrors = require('../util/handleErrors'),
    combiner = require('stream-combiner2'),
    gulp = require('gulp'),
    uglify = require('gulp-uglify');

gulp.task('uglify', function() {

    var stream = combiner.obj([
        gulp.src(config.src),
        uglify(),
        gulp.dest(config.dest)
    ];
        
    stream.on('error', handleErrors);
    
    return stream;
});

Mithilfe von gulp-notify schickt der Errorhandler Desktop-Benachrichtigungen, damit ein Fehler auch visuell wahrgenommen wird.

gulp/util/handleErrors.js view raw
var notify = require("gulp-notify");

module.exports = function() {

  var args = Array.prototype.slice.call(arguments);

  // Send error to notification center with gulp-notify
  notify.onError({
    title: "Compile Error",
    message: "<%= error %>"
  }).apply(this, args);

  // Keep gulp from hanging on this task
  this.emit('end');
};

Multistreams kombinieren

Bleiben wir beim uglify-Beispiel. Oftmals möchte man mehrere Javascripte minimieren, das obige Beispiel ist jedoch auf eine einzige Konfiguration beschränkt.

Zuerst wird daher die Konfiguration abgewandelt:

gulp/config/uglify.js

module.exports = {
    tasks : [
        {
            src : './src/js/example/*',
            dest : './dist/js/example/'
        },
        {
            src : './src/somethingElse/*',
            dest : './dist/somethingElse/'
        }
    ]
    
}

Mithilfe des Tools merge-stream werden die einzelnen Konfigurationen am Ende in einem Task abgehandelt und zu einem Stream kombiniert.⁽³⁾

var config = require('./../config/uglify'),
    handleErrors = require('../util/handleErrors'),
    combiner = require('stream-combiner2'),
    gulp = require('gulp'),
    uglify = require('gulp-uglify'),
    _ = require('lodash'),
    mergeStream = require('merge-stream');

function taskRunner(cfg) {

    var stream = combiner.obj([
        gulp.src(cfg.src),
        uglify(),
        gulp.dest(cfg.dest)
    ];
        
    stream.on('error', handleErrors);
    
    return stream;
}

gulp.task('uglify', function() {
    if(_.isArray(config.tasks)) {
        return mergeStream.apply(gulp, _.map(config.tasks, taskRunner));
    }
    
    return taskRunner(config);
});

In der Taskdefinition selbst findet sich jetzt nur noch der Aufruf der Streampipline. Dabei kann ich die Konfiguration wie gehabt auch für den Einzelfall anlegen.

Viel Spaß mit gulp!

Quellen: