Так уж сложилось, что я работаю с Grunt и Gulp одновременно, поэтому после статьи про то, как писать плагин для Grunt, мне кажется логичным рассказать аналогичную историю для Gulp.

Наверное, всем уже известно, что Gulp работает с потоками, что слегка добавляет головной боли тем, кто с потоками не знаком. Поэтому первым делом я предлагаю обратиться к теории и уже потом плавно перейти к практике.

Теория

Поток — это абстрактный интерфейс, реализуемый многими объектами в Node.js. Все потоки — это объекты, которые являются экземпляром класса EventEmitter. Потоки можно представлять как набор данных, передающихся фрагмент за фрагментом. Благодаря этому данные можно обрабатывать по мере их накопления, а не ждать, пока они будут переданы полностью.

Различают несколько типов потоков:

  • Readable — поток чтения, используемый для операции чтения.
  • Writable — поток записи, используемый для операции записи.
  • Duplex — дуплексный поток, используемый как для чтения, так и для записи.
  • Transform — дуплексный поток, выход которого рассчитывается на основе входных данных.

Рассмотрим поток чтения и записи более подробно, так как их понимание является базовым для операции ввода и вывода данных в потоке.

Ниже представлен фрагмент кода, создающего поток чтения, который считывает данные из файла input.txt, содержащего более 2000 строк текста. Считываемые фрагменты данных могут иметь разные размеры в зависимости от размера буфера.

var stream = fs.createReadStream('input.txt')

// Событие Data вызывается после появления нового фрагмента данных
stream.on('data', (chunk) => {
  console.log(chunk.toString());
});

// Событие End вызывается после загрузки всех фрагментов данных
stream.on('end', () => {
  console.log('Конец файла');
});

Вывод в консоль будет следующим:

$ node read.js
65536
65536
43609
Конец файла

Такой подход предпочтительнее, так как здесь обеспечивается более эффективная обработка данных, чем ожидание передачи всех данных в буфер памяти.

Как я уже говорил ранее, в Node.js существует и поток записи, позволяющий записывать фрагменты данных. Его суть заключается в том, что данные в файл записываются по мере поступления фрагментов данных из потока чтения.

Стоит также отметить, что дуплексный поток и поток трансформации также называют каналом. Понятие канала тесно связано с понятием трубы (pipe). Pipe — это механизм (метод), который пересылает все данные из потока чтения в указанное место, тем самым позволяя соединять потоки. Примером такой цепочки может быть следующий код:

const fs = require('fs');
const zlib = require('zlib');

fs.createReadStream('input.txt.gz')
  .pipe(zlib.createGunzip())
  .pipe(fs.createWriteStream('output.txt'));

Сначала мы создаём поток чтения из файла input.txt.gz. Далее мы строим трубу из потока чтения в поток zlib.createGunzip(), который распаковывает данные. Как только поток прочитает данные из файла, он передаст прочитанный фрагмент (chunk) дальше по трубе. В конце строится труба из потока распаковки данных в поток записи этих данных в файл.

Также стоит упомянуть, что потоки чтения можно ставить на паузу или возобновить их, когда это требуется. У каждого метода потоков есть свои события.

Мне кажется, что на этом стоит остановиться, так как этих знаний достаточно для того, чтобы понять с чем мы имеем дело. Теперь поговорим о потоках в Gulp.

Потоки в Gulp

Сейчас я открою страшную тайну: плагин для Gulp — это всего лишь поток трансформации, выход которого рассчитывается на основе входных данных.

Любой Gulp-плагин принимает на вход набор vinyl-объектов, которые он должен вернуть дальше в поток для того, чтобы результатом его работы можно было воспользоваться другим плагинам.

Gulp работает с метаобъектами, называемыми vinyl-объектами и содержащими в себе виртуальное описание файла: путь и буфер или поток. Это означает, что перед тем, как данные будут переданы в поток, они накапливаются в буфере до тех пор, пока не будет прочитано всё его содержимое.

Для наглядности создадим файл с 2000 строк, содержащий произвольное содержимое, например, вывод команды lorem10*2000. Напишем код, подключив модуль vinyl-fs, на котором построен Gulp:

require('vinyl-fs').src('test.txt').on('data', (file) => {
  console.log('Прочитано %d байт содержимого файла', file.contents.length);
})

Указывая gulp.src() и gulp.dest() мы создаём канал, по которому протекают данные. Методы src и dest, соответственно, — это поток чтения и записи.

Выполнив этот код получим следующий результат:

$ node vinyl.js
Прочитано 174681 байт содержимого файла

Кратко о том, что нужно запомнить на текущий момент:

  • Stream (поток) — это непрерывное чтение содержимого файла и (или) его запись.
  • Pipe (труба) — механизм, позволяющий соединить два потока в канал, по которому протекают данные от входа (поток чтения) к выходу (поток записи).
  • Vinyl — это виртуальное представление файла в потоке.
  • Vinyl-fs — это адаптер между локальной и виртуальной файловой системой.

Инструментарий

Так как мы пишем плагин для Gulp, то потребуются вспомогательные пакеты, которые можно установить следующим образом:

$ npm i -S through2 gulp-util

Краткое описание:

  • through2 — это обёртка над потоками в Node.js, обеспечивающая более простую работу с потоками.
  • gulp-util — набор утилит, помогающих в создании плагина для Gulp: логирование, вызов ошибок и т.д.

В отличии от Grunt, Gulp не имеет никакого API для разработчиков плагинов. Вместо него используется модуль gulp-util, который собрал в себя всё самое необходимое для создания плагина. Однако, с выходом четвертой версии Gulp этот набор инструментов полностью будет расформирован (см. PR #102).

Так, например, начиная с четвертой версии Gulp, раскрашивать вывод консоли предлагается с помощью chalk, а передавать файл в поток можно будет, используя vinyl.

Идея

Я предлагаю написать простейший плагин, который минифицирует HTML-код: удаляет идентацию в начале строк, а также их переносы.

Тесты

Когда-нибудь случится чудо и все будут тестировать свой код. Работу любого пакета нужно тестировать, чтобы облегчить отлов ошибок и упростить работу тем, кто захочет что-то добавить в ваш пакет.

Для тестирования я буду использовать Mocha. Более подробно о тестировании я уже писал в статье «Введение в тестирование JavaScript-кода», но на примере пакета AVA. Этот пакет можно установить с помощью команды:

$ npm i -D mocha

Далее потребуется добавить в файл package.json секцию scripts со следующим содержимым:

"scripts": {
  "test": "mocha test.js"
}

Если вы имеете несколько файлов, в которых находятся тесты, то перечислять их все не нужно, достаточно лишь сократить команду до "test": "mocha".

Окей, создадим три файла: test.js, input.html и output.html. В первом будут находиться сами тесты, а во втором и третьем, соответственно, исходные и эталонные данные. Исходный HTML-файл будет иметь следующее содержание:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Document</title>
</head>
<body>
  <p>test</p>
</body>
</html>

Файл output.html будет иметь точно такое же содержание, но без пробелов в начале строк и их переносов.

Мы уже знаем, что Gulp-плагин представляет собой поток, поэтому первым делом создаём его. Далее подписываемся на событие data, которое выстрелит тогда, когда данные будут обработаны. В теле функции обработчика события мы должны получить содержимое эталонного файла и сравнить его с тем, что выдаст плагин после своей работы. Я всегда добавляю пустую строку в конце файла, поэтому перед сравнением у эталонного файла нужно её удалить. Затем мы передаём в плагин vinyl-объект, содержимым которого является буфер исходного файла и его путь.

Когда тест будет запущен, исходный файл будет передан в плагин, тот его обработает и вернёт обратно. После этого сработает тест и, если содержимое обработанного файла совпадает с тем, что представлено в эталонном тесте, то тест завершится успешно.

'use strict';

const fs = require('fs');
const assert = require('assert');
const gutil = require('gulp-util');
const m = require('./');

it('default test', (done) => {
  const stream = m();

  stream.on('data', (file) => {
    const fixtures = fs.readFileSync('./fixtures/output.html', 'utf-8');
    // Так как содержимое файла в vinyl-объекте представлено буфером, то
    // перед тем, как что-то с ним делать — необходимо преобразовать его к строке
    assert.equal(file.contents.toString(), fixtures.replace(/\n/g, ''));
    done();
  });

  stream.write(new gutil.File({
    path: 'input.html',
    contents: fs.readFileSync('./input.html')
  }));

  stream.end();
});

Запустить тест можно командой $ npm t или $ npm test.

Внимание

Пожалуйста, не забудьте исключить тестовые файлы из пакета, распространяемого через npm. Это можно сделать, используя секцию "files": [] в package.json, которая представляет собой «белый лист». Например, так: "files": ["tasks"]. Файл README.md и LICENSE автоматически будут включены в пакет. Подробнее об этом можно узнать в документации к npm.

Теперь я бы хотел ответить на вопрос: «Почему тесты пишутся раньше, чем сам плагин?». Дело в том, что протестировать работу плагина из консоли просто так не получится, так как плагин — это поток. Лучше написать заранее простенький тест, запускающий плагин и выводящий результат его работы, чем писать что-то, что в итоге будет работать не правильно или не работать вообще.

Разработка

Ниже представлен типичный шаблон плагина для Gulp, который используется практически всеми разработчиками:

'use strict';
const gutil = require('gulp-util');
const through = require('through2');

module.exports = (options) => {
  // Какие-то действия с опциями. Например, проверка их существования,
  // задание значения по умолчанию и т.д.

  return through.obj(function(file, enc, cb) {
    // Если файл не существует
    if (file.isNull()) {
      cb(null, file);
      return;
    }

    // Если файл представлен потоком
    if (file.isStream()) {
      cb(new gutil.PluginError('gulp-example-plugin', 'Streaming not supported'));
      return;
    }

    // Код плагина

    // Возвращаем обработанный файл для следующего плагина
    this.push(file);
    cb();
  });
};

Метод through.obj() принимает на вход две функции. Первая функция называется transformFunction и нужна для трансформации содержимого файлов, а вторая flushFunction — это функция, вызываемая только при окончании трансформации файлов, то есть непосредственно перед тем, как поток завершится. Вторая функция необязательна и используется в том случае, если есть необходимость дождаться выполнения первой функции или добавить в поток новые файлы.

Для реализации задуманной идеи нам потребуется пакет, удаляющий идентацию в начале строки:

$ npm i -S trimLeft

Полный листинг логики плагина приведён ниже. Мне кажется, что комментариев в данном случае более чем достаточно. Главное не забудьте, что любой Gulp-плагин на вход получает набор vinyl-объектов и должен возвращать его в следующий плагин.

// Отлавливаем ошибку
try {
  // Так как содержимое файла в vinyl-объекте представлено буфером, то
  // перед тем, как что-то с ним делать — необходимо преобразовать его к строке
  const data = file.contents.toString()
    // Разбиваем строку на массив строк
    .split('\n')
    // Проходимся по каждой строке и удаляем её идентацию
    .map((line) => trimLeft(line))
    // Преобразуем массив в строку
    .join('');

  // Загоняем строку обратно в буфер
  file.contents = new Buffer(data);

  // Передаём файл следующему плагину
  this.push(file);
} catch (err) {
  // Если была ошибка, то отсылаем её Gulp
  this.emit('error', new PluginError('gulp-example-plugin', err));
}

Разумеется, что этот код нужно поместить в первую функцию through.obj() типичного шаблона, описанного выше.

Весь код, описанный в этой статье доступен в репозитории на GitHub.

Немного про Gulp 4-ой ветки

Начиная с четвертой версии пакет gulp-util будет полностью расформирован, если верить PR #102. Это изменение не принесёт серьёзных проблем вашему плагину, но потребует немного времени на установку дополнительных пакетов, которые будут замещать ранее собранный функционал в gulp-util.

Предложенные для замещения пакеты находятся в этом коммите.

Мои плагины

Что почитать?