Gulp.watch: ловим ошибки правильно +9


Во всех современных системах сборки фронтенда есть режим 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
    /#8442051 / +1

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

    • justboris
      /#8442115

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

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

  2. LevshinO
    /#8442067 / +2

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

    • justboris
      /#8442093 / +2

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

      • LevshinO
        /#8442211

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

  3. hell0w0rd
    /#8443109

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

    • justboris
      /#8443195

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

      • hell0w0rd
        /#8443257

        Да, поэтому когда я использовал 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.

        • justboris
          /#8443281

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

          Но во-первых, дополнительное условие – уже не очень здорово.
          Во-вторых 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-переменной

          • hell0w0rd
            /#8443307

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

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

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

            • justboris
              /#8443365

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

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

              • hell0w0rd
                /#8443399

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

        • justboris
          /#8443283

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

          • hell0w0rd
            /#8443309

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