Так уж сложилось, что я работаю с 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.
Предложенные для замещения пакеты находятся в этом коммите.