Во всех современных системах сборки фронтенда есть режим watch, при котором запускается специальный демон для автоматической пересборки файлов сразу после их сохранения. Также он есть и gulp.js, но с некоторыми особенностями, делающими работу с ним немного сложней. Работа gulp.js основана на обработке файлов как потоков данных (streams). И ошибки в потоках не всегда можно перехватить, а когда ошибка случается, то будет выброшено неперехваченное исключение и процесс завершится.

Чтобы этого не происходило, и watcher мог игнорировать отдельные ошибки и запускаться снова и снова при сохранении файлов, в gulp нужно сделать несколько дополнительных настроек, о которых и расскажется в этой статье.

Что происходит?


Для начала разберемся что происходит и почему же watcher иногда падает. При написании плагина к gulp рекомендуется испускать событие error при возникновении ошибок во время работы плагина. Но nodejs-потоки, на которых основана система сборки, не позволяют ошибкам оставаться незамеченными. В случае, если на событие error никто не подписался, выбросится исключение, чтобы сообщение точно достигло пользователя. В результате, при работе с gulp разработчики часто видят такие ошибки

events.js:72
    throw er; // Unhandled 'error' event


При применении Continious-Integration (CI), когда на каждый коммит запускаются автоматические проверки, это может быть и полезным (сборка провалилась, билд не собрался, ответственные получат письма). Но вечно падающий watcher в локальной разработке, который нужно постоянно перезапускать – это большая неприятность.

Что делать?


В интернете можно найти вопросы этой теме как на stackoverflow, так и на тостере. В ответах на вопросы предлагают несколько популярных решений.

Можно подписаться на событие error:

gulp.task('less', function() {
  return gulp.src('less/*.less')
    .pipe(less().on('error', gutil.log))
    .pipe(gulp.dest('app/css'));
});


Можно подключить плагин gulp-plumber, который не только подпишется на error для одного плагина, но и автоматически сделает это и для всех последующих плагинов, подключенных через pipe:

gulp.task('less', function() {
  return gulp.src('less/*.less')
      .pipe(plumber())
    .pipe(less())
    .pipe(gulp.dest('app/css'));
});


Почему так не надо делать


Несмотря на то, что проблема выглядит как решенная, это не так. Теперь ошибки сборки перехватываются, мы получаем в консоль сообщения об этом, запущенный демон-watcher не падает, но у нас теперь есть новая проблема.

Допустим у нас есть CI-сервер, где идет сборка наших скриптов для выкладки. Чтобы наша сборка работала вместе с watch, мы применили одно из решений выше. Но теперь оказывается, что при ошибках в сборке наш билд все равно отмечается как успешный. Команда gulp всегда завершается с кодом 0. Получается, для CI-сборки нам не нужно проглатывать ошибки. Можно добавлять свой обработчик ошибок только для watch режима, но это усложнит описание сборки и повысит шансы допустить ошибку. К счастью, есть решение, как настроить сборку одинаково, но при этом поддержать работу и в режиме билда и в режиме watch.

Решение


На самом деле gulp пытается слушать ошибки в потоках файлов. Но из-за того, что обычно последним идет вызов gulp.dest(), записывающий результат на диск, а ошибки случаются в промежуточных плагинах, то ошибка не достигает конца цепочки, поэтому gulp о ней не узнает. Например рассмотрим такую цепочку:

stream1.on('error', function() { console.log('error 1') })
stream2.on('error', function() { console.log('error 2') })
stream3.on('error', function() { console.log('error 3') })
stream1
   .pipe(stream2)
   .pipe(stream3);
stream1.emit('error');
// в консоли выведется только "error 1"


В отличие от, например, promise, ошибки в потоках не распространяются дальше по цепочке, их нужно перехватывать в каждом потоке отдельно. По этому поводу есть pull-request в io.js, но в нынешних версиях передать ошибку в конец цепочки не получится. Поэтому gulp не может перехватить ошибки промежуточных потоках и нам это нужно делать самостоятельно.

Зато gulp в качестве описания task принимает не только функцию, возвращающую поток, но и обычную callback-style функцию, как и многие API в node.js. В такой функции мы сами будем решать, когда задача завершилось с ошибкой, а когда успешно:

gulp.task('less', function(done) {
  gulp.src('less/*.less')
    .pipe(less().on('error', function(error) {
      // у нас ошибка
      done(error);
    }))
    .pipe(gulp.dest('app/css'))
    .on('end', function() {
      // у нас все закончилось успешно
      done();
    });
});


Такое описание сборки выглядит немного больше, потому что мы теперь делаем часть работы за gulp, но теперь мы ее делаем правильно. А если написать себе вспомогательную функцию, вроде такой, то разница будет практически незаметна:

gulp.task('less', wrapPipe(function(success, error) {
  return gulp.src('less/*.less')
     .pipe(less().on('error', error))
     .pipe(gulp.dest('app/css'));
}));


Зато мы получим правильные сообщения в консоли как во время разработки и пересборке при сохранении, так и на CI-сервере во время билда.

Комментарии (14)


  1. lolmaus
    01.06.2015 11:11
    +1

    Вы не могли бы выложить это в npm? Чтобы не приходилось копипастить функцию из проекта в проект.


    1. justboris Автор
      01.06.2015 12:11

      Пока я не уверен, что такая волшебная функция подойдет всем. Например, можно вспользоваться end-of-stream, а не просто слушать событие «end».

      Пока это просто паттерн работы с ошибками в pipe. Когда решение дозреет до настоящего модуля – конечно опубликую


  1. LevshinO
    01.06.2015 11:30
    +2

    Так в plumber же можно передать функцию-обработчик ошибки…


    1. justboris Автор
      01.06.2015 12:01
      +2

      И что нужно написать в обработчике ошибки, чтобы gulp узнал, что task провалился?


      1. LevshinO
        01.06.2015 13:07

        Прошу прощения, неверно понял суть вопроса.


  1. hell0w0rd
    01.06.2015 21:34

    Что-то не понял, а зачем watch при сборке на CI сервере?


    1. justboris Автор
      01.06.2015 22:24

      watch на локальной машине. Но вы же хотите в режиме watch и билда исполнять одни и те же таски?


      1. hell0w0rd
        01.06.2015 22:56

        Да, поэтому когда я использовал gulp, делал так:

        var gulp = require('gulp');
        var _if = require('gulp-if');
        
        var env = process.env.NODE_ENV || 'development';
        var production = env === 'production';
        
        gulp.task('less', function () {
          gulp.src('less/*.less')
              .pipe(_if(!production, plumber()))
              .pipe(less())
        });
        

        Чего очень сильно не хватает в webpack.


        1. justboris Автор
          01.06.2015 23:20

          Да, это тоже решение.

          Но во-первых, дополнительное условие – уже не очень здорово.
          Во-вторых plumber должен включаться не в зависимости от environment, а от запускаемой задачи. Получается, нужно делать как-то так:

          var usePlumber = false;
          gulp.task('dev', function() {
               usePlumber = true;
               gulp.start()
          });
          
          gulp.task('less', function () {
            gulp.src('less/*.less')
                .pipe(_if(usePlumber, plumber()))
                .pipe(less())
          });
          


          Команды gulp dev и gulp build (подозреваю, что вы собираете не только стили) задокументировать и объяснить другим участникам команды проще, чем в комбинации с environment-переменной


          1. hell0w0rd
            01.06.2015 23:32

            совсем не согласен. У вас в арсенале есть еще npm-scripts.

            {
              "scripts": {
                "start": "gulp",
                "build": "NODE_ENV=production gulp build"
              }
            }
            

            В итоге вы можете сколько угодно менять систему сборки, разработчики вашей команды, которым они чужды могут даже об этом не знать, запуская npm start каждый раз после git pull.


            1. justboris Автор
              02.06.2015 00:07

              На env обычно завязаны условия, сжимать скрипты или нет, уровень логгирования и т.д. Поэтому лучше разделять опции watch/build и minify/не-minify.

              А вообще, топик совсем не об этом. А о том, что можно обойтись совсем без специального условия для watch, сделать так, чтобы и для dev-демона и для production сборки применялся один и тот же конфиг. Так ловить production-специфичные баги намного проще.


              1. hell0w0rd
                02.06.2015 00:43

                На мой взгляд вашу проблему лучше решать с помощью готовых инструментов, обернув plumber, или аналог, в if. А использовать NODE_ENV, или ENABLE_PLUMBER — это уже вам решать.


        1. justboris Автор
          01.06.2015 23:20

          А что не так в webpack?


          1. hell0w0rd
            01.06.2015 23:33

            Тем что правила описываются внутри объекта, который разработчик строит как угодно, на свое усмотрение.