Gulp под капотом: создание инструмента автоматизации задач на основе потоков

Опубликовано: 2022-03-11

Разработчики внешнего интерфейса в настоящее время используют несколько инструментов для автоматизации рутинных операций. Три самых популярных решения — это Grunt, Gulp и Webpack. Каждый из этих инструментов основан на разных философиях, но у них одна общая цель: упростить процесс сборки внешнего интерфейса. Например, Grunt управляется конфигурацией, а Gulp почти ничего не требует. Фактически, Gulp полагается на то, что разработчик пишет код для реализации потока процессов сборки — различных задач сборки.

Gulp под капотом: создание инструмента автоматизации задач на основе потоков

Когда дело доходит до выбора одного из этих инструментов, мой личный фаворит — Gulp. В целом это простое, быстрое и надежное решение. В этой статье мы увидим, как Gulp работает под капотом, попытавшись реализовать наш собственный инструмент, похожий на Gulp.

Глоток API

Gulp поставляется всего с четырьмя простыми функциями:

  • gulp.task
  • gulp.src
  • gulp.dest
  • gulp.watch

Эти четыре простые функции в различных комбинациях предлагают всю мощь и гибкость Gulp. В версии 4.0 Gulp представил две новые функции: gulp.series и gulp.parallel. Эти API позволяют выполнять задачи последовательно или параллельно.

Из этих четырех функций первые три абсолютно необходимы для любого файла Gulp. позволяя определять и вызывать задачи из интерфейса командной строки. Четвертое — это то, что делает Gulp по-настоящему автоматическим, позволяя запускать задачи при изменении файлов.

глоток

Это элементарный gulpfile:

 gulp.task('test', function{ gulp.src('test.txt') .pipe(gulp.dest('out')); }); 

Он описывает простую тестовую задачу. При вызове файл test.txt из текущего рабочего каталога должен быть скопирован в каталог ./out . Попробуйте, запустив Gulp:

 touch test.txt # Create test.txt gulp test

Обратите внимание, что метод .pipe не является частью Gulp, это API потока узлов, он соединяет поток для чтения (сгенерированный gulp.src('test.txt') ) с потоком для записи (сгенерированный gulp.dest('out') ). Вся связь между Gulp и плагинами основана на потоках. Это позволяет нам писать код gulpfile таким элегантным способом.

Встречайте вилку

Теперь, когда у нас есть некоторое представление о том, как работает Gulp, давайте создадим собственный инструмент, похожий на Gulp: Plug.

Мы начнем с plug.task API. Он должен позволить нам регистрировать задачи, а задачи должны выполняться, если имя задачи передается в параметрах команды.

 var plug = { task: onTask }; module.exports = plug; var tasks = {}; function onTask(name, callback){ tasks[name] = callback; }

Это позволит регистрировать задачи. Теперь нам нужно сделать эту задачу исполняемой. Для простоты мы не будем делать отдельный лаунчер задач. Вместо этого мы включим его в нашу реализацию плагина.

Все, что нам нужно сделать, это запустить задачи, указанные в параметрах командной строки. Нам также нужно убедиться, что мы пытаемся сделать это в следующем цикле выполнения, после того как все задачи будут зарегистрированы. Самый простой способ сделать это — запускать задачи в обратном вызове с тайм-аутом или, что предпочтительнее, с помощью process.nextTick:

 process.nextTick(function(){ var taskName = process.argv[2]; if (taskName && tasks[taskName]) { tasks[taskName](); } else { console.log('unknown task', taskName) } });

Составьте plugfile.js следующим образом:

 var plug = require('./plug'); plug.task('test', function(){ console.log('hello plug'); })

… и запустить его.

 node plugfile.js test

Он будет отображать:

 hello plug

Подзадачи

Gulp также позволяет определять подзадачи при регистрации задачи. В этом случае plug.task должен принимать 3 параметра: имя, массив подзадач и функцию обратного вызова. Давайте реализуем это.

Нам нужно будет обновить API задачи как таковой:

 var tasks = {}; function onTask(name) { if(Array.isArray(arguments[1]) && typeof arguments[2] === "function"){ tasks[name] = { subTasks: arguments[1], callback: arguments[2] }; } else if(typeof arguments[1] === "function"){ tasks[name] = { subTasks: [], callback: arguments[1] }; } else{ console.log('invalid task registration') } } function runTask(name){ if(tasks[name].subTasks){ tasks[name].subTasks.forEach(function(subTaskName){ runTask(subTaskName); }); } if(tasks[name].callback){ tasks[name].callback(); } } process.nextTick(function(){ if (taskName && tasks[taskName]) { runTask(taskName); } });

Теперь, если наш plugfile.js выглядит так:

 plug.task('subTask1', function(){ console.log('from sub task 1'); }) plug.task('subTask2', function(){ console.log('from sub task 2'); }) plug.task('test', ['subTask1', 'subTask2'], function(){ console.log('hello plug'); })

… запустить его

 node plugfile.js test

… должно отображаться:

 from sub task 1 from sub task 2 hello plug

Обратите внимание, что Gulp выполняет подзадачи параллельно. Но для простоты в нашей реализации мы запускаем подзадачи последовательно. Gulp 4.0 позволяет управлять этим с помощью двух новых функций API, которые мы реализуем позже в этой статье.

Источник и назначение

Плагин будет бесполезен, если мы не разрешим чтение и запись файлов. Итак, далее мы реализуем plug.src . Этот метод в Gulp ожидает аргумент, который является либо маской файла, либо именем файла, либо массивом масок файлов. Он возвращает читаемый поток Node.

На данный момент в нашей реализации src мы просто разрешим имена файлов:

 var plug = { task: onTask, src: onSrc }; var stream = require('stream'); var fs = require('fs'); function onSrc(fileName){ var src = new stream.Readable({ read: function (chunk) { }, objectMode: true }); //read file and send it to the stream fs.readFile(path, 'utf8', (e,data)=> { src.push({ name: path, buffer: data }); src.push(null); }); return src; }

Обратите внимание, что здесь мы используем objectMode: true , необязательный параметр. Это связано с тем, что потоки узлов по умолчанию работают с двоичными потоками. Если нам нужно передавать/получать объекты JavaScript через потоки, мы должны использовать этот параметр.

Как видите, мы создали искусственный объект:

 { name: path, //file name buffer: data //file content }

… и передал его в поток.

С другой стороны, метод plug.dest должен получить имя целевой папки и вернуть доступный для записи поток, который будет получать объекты из потока .src. Как только файловый объект будет получен, он будет сохранен в целевой папке.

 function onDest(path){ var writer = new stream.Writable({ write: function (chunk, encoding, next) { if (!fs.existsSync(path)) fs.mkdirSync(path); fs.writeFile(path +'/'+ chunk.name, chunk.buffer, (e)=> { next() }); }, objectMode: true }); return writer; }

Обновим наш plugfile.js:

 var plug = require('./plug'); plug.task('test', function(){ plug.src('test.txt') .pipe(plug.dest('out')) })

… создать test.txt

 touch test.txt

… и запустите его:

 node plugfile.js test ls ./out

test.txt следует скопировать в папку ./out .

Сам Gulp работает примерно так же, но вместо наших искусственных файловых объектов он использует виниловые объекты. Это намного удобнее, так как содержит не только имя файла и содержимое, но и дополнительную метаинформацию, такую ​​как имя текущей папки, полный путь к файлу и т.д. Он может не содержать весь буфер содержимого, но вместо этого имеет читаемый поток содержимого.

Винил: лучше, чем файлы

Существует отличная библиотека Vinyl-fs, которая позволяет нам манипулировать файлами, представленными в виде объектов Vinyl. По сути, это позволяет нам создавать доступные для чтения и записи потоки на основе маски файла.

Мы можем переписать функции плагинов, используя библиотекуvinyl-fs. Но сначала нам нужно установить Vinyl-fs:

 npm i vinyl-fs

После установки наша новая реализация плагина будет выглядеть примерно так:

 var vfs = require('vinyl-fs') function onSrc(fileName){ return vfs.src(fileName); } function onDest(path){ return vfs.dest(path); } // ...

… и попробовать:

 rm out/test.txt node plugFile.js test ls out/test.txt

Результаты должны быть такими же.

Плагины глотка

Поскольку наш сервис Plug использует соглашение о потоках Gulp, мы можем использовать собственные плагины Gulp вместе с нашим инструментом Plug.

Давайте попробуем один. Установить глоток-переименовать:

 npm i gulp-rename

… и обновите plugfile.js, чтобы использовать его:

 var plug = require('./app.js'); var rename = require('gulp-rename'); plug.task('test', function () { return plug.src('test.txt') .pipe(rename('renamed.txt')) .pipe(plug.dest('out')); });

Как вы уже догадались, запуск plugfile.js должен дать тот же результат.

 node plugFile.js test ls out/renamed.txt

Мониторинг изменений

Последний, но не менее важный метод — это gulp.watch Этот метод позволяет нам зарегистрировать прослушиватель файлов и вызывать зарегистрированные задачи при изменении файлов. Давайте реализуем это:

 var plug = { task: onTask, src: onSrc, dest: onDest, watch: onWatch }; function onWatch(fileName, taskName){ fs.watchFile(fileName, (event, filename) => { if (filename) { tasks[taskName](); } }); }

Чтобы попробовать, добавьте эту строку в plugfile.js:

 plug.watch('test.txt','test');

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

Серия против Параллели

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

  • gulp.parallel
  • глоток.серия

Эти методы позволяют пользователю управлять последовательностью выполнения задач. Для регистрации подзадач параллельно можно использовать gulp.parallel, что является текущим поведением Gulp. С другой стороны, gulp.series можно использовать для запуска подзадач последовательно, одна за другой.

Предположим, у нас есть test1.txt и test2.txt в текущей папке. Чтобы параллельно скопировать эти файлы в нашу папку, создадим подключаемый файл:

 var plug = require('./plug'); plug.task('subTask1', function(){ return plug.src('test1.txt') .pipe(plug.dest('out')) }) plug.task('subTask2', function(){ return plug.src('test2.txt') .pipe(plug.dest('out')) }) plug.task('test-parallel', plug.parallel(['subTask1', 'subTask2']), function(){ console.log('done') }) plug.task('test-series', plug.series(['subTask1', 'subTask2']), function(){ console.log('done') })

Для упрощения реализации функции обратного вызова подзадачи возвращают свой поток. Это поможет нам отслеживать жизненный цикл потока.

Мы начнем вносить изменения в наш API:

 var plug = { task: onTask, src: onSrc, dest: onDest, parallel: onParallel, series: onSeries };

Нам также нужно будет обновить функцию onTask , поскольку нам нужно добавить дополнительную метаинформацию о задаче, чтобы помочь нашему средству запуска задач правильно обрабатывать подзадачи.

 function onTask(name, subTasks, callback){ if(arguments.length < 2){ console.error('invalid task registration',arguments); return; } if(arguments.length === 2){ if(typeof arguments[1] === 'function'){ callback = subTasks; subTasks = {series: []}; } } tasks[name] = subTasks; tasks[name].callback = function(){ if(callback) return callback(); }; } function onParallel(tasks){ return { parallel: tasks }; } function onSeries(tasks){ return { series: tasks }; }

Для простоты мы будем использовать async.js, служебную библиотеку для работы с асинхронными функциями для запуска задач параллельно или последовательно:

 var async = require('async') function _processTask(taskName, callback){ var taskInfo = tasks[taskName]; console.log('task ' + taskName + ' is started'); var subTaskNames = taskInfo.series || taskInfo.parallel || []; var subTasks = subTaskNames.map(function(subTask){ return function(cb){ _processTask(subTask, cb); } }); if(subTasks.length>0){ if(taskInfo.series){ async.series(subTasks, taskInfo.callback); }else{ async.parallel(subTasks, taskInfo.callback); } }else{ var stream = taskInfo.callback(); if(stream){ stream.on('end', function(){ console.log('stream ' + taskName + ' is ended'); callback() }) }else{ console.log('task ' + taskName +' is completed'); callback(); } } }

Мы полагаемся на «конец» потока узла, который испускается, когда поток обработал все сообщения и закрылся, что указывает на завершение подзадачи. С async.js нам не нужно иметь дело с большим количеством обратных вызовов.

Чтобы попробовать, давайте сначала запустим подзадачи параллельно:

 node plugFile.js test-parallel
 task test-parallel is started task subTask1 is started task subTask2 is started stream subTask2 is ended stream subTask1 is ended done

И запускать те же подзадачи последовательно:

 node plugFile.js test-series
 task test-series is started task subTask1 is started stream subTask1 is ended task subTask2 is started stream subTask2 is ended done

Заключение

Вот и все, мы реализовали API Gulp и теперь можем использовать плагины Gulp. Конечно, не используйте Plugin в реальных проектах, так как Gulp — это нечто большее, чем то, что мы здесь реализовали. Я надеюсь, что это небольшое упражнение поможет вам понять, как Gulp работает внутри, и позволит нам более свободно использовать его и расширять его с помощью плагинов.

Связанный: Введение в автоматизацию JavaScript с помощью Gulp