`Vinyl` и `vinyl-fs`: сквозные потоки в Gulp

Сила современного Gulp — не в сборке фронтенда, а в автоматизации через потоки файлов. В основе — объект `Vinyl` и движок `vinyl-fs`. Эта статья — разбор этих технологий, сценарии применения и замена устаревшего `passthrough` на актуальные паттерны.

Введение: От таск-раннера к ядру автоматизации

Если вы работаете в веб-разработке последние 5-7 лет, то наверняка заметили значительный сдвиг. Такие инструменты, как Vite, Webpack, встроенные в Create React App или Angular CLI, заняли нишу стандартной сборки фронтенд-приложений. Они предлагают "из коробки" всё необходимое: горячую перезагрузку, разделение кода, оптимизацию ассетов. На этом фоне может показаться, что Gulp — инструмент из другой эпохи, чьё время безвозвратно прошло.

Но такое поверхностное сравнение упускает суть. Gulp не только сборщик фронтенда. Его истинная сила и актуальность сегодня лежат глубже — в предоставлении универсальной, основанной на потоках абстракции для автоматизации файловых операций.

Пока Vite оптимизирует импорты, Gulp может:

В основе этой гибкости лежат не плагины, а две фундаментальные технологии: Vinyl репозиторий на GitHub и vinyl-fs. Vinyl — это виртуальный файловый формат, абстракция, представляющая файл (с путём, содержимым и метаданными) в виде объекта JavaScript. vinyl-fs — это движок, который превращает эту абстракцию в рабочую систему: он читает файлы с диска и создаёт из них потоки Vinyl-объектов, которые можно бесконечно трансформировать, фильтровать и комбинировать, прежде чем записать обратно.

Статья, написанная в 2021 году, рассказывала о попытке Gulp 4 ввести концепцию "сквозных потоков" через специальный флаг passthrough. Сегодня этот флаг — история. Но сама концепция динамического управления файловыми потоками стала только актуальнее.

В этой статье не будем вспоминать устаревший синтаксис. Вместо этого заглянем под капот и разберём, как Vinyl и vinyl-fs создают, композируемые пайплайны для решения нестандартных задач. Вы поймёте, что gulp.src() — это, по сути, интерфейс к vinyl-fs, и научитесь управлять файловыми потоками на фундаментальном уровне. Это знание превратит Gulp из «ещё одного инструмента для сборки» в главный конструктор для автоматизации практически чего угодно.

Часть 1: Vinyl — виртуальный файловый формат

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

Представьте, что вам нужно работать с файлом: прочитать содержимое, изменить, переименовать, переместить. Классический подход Node.js с использованием модулей fs и path заставляет постоянно думать о двух параллельных реальностях: пути к файлу на диске (string) и содержимом файла (Buffer или string). Манипуляции с путём и содержимым происходят отдельно, что усложняет логику и создаёт пространство для ошибок.

Vinyl решает эту проблему, создавая единое, целостное представление. Объект Vinyl — это виртуальная репрезентация файла, которая инкапсулирует в себе свойства:

СвойствоТипОписаниеАналог в реальном файле
pathStringАбсолютный путь к файлу.Расположение файла в файловой системе.
contentsBuffer, Stream или nullСодержимое файла.Данные, которые вы читаете через fs.readFile.
baseStringБазовый путь, относительно которого вычисляется относительный путь.Часто корень проекта (process.cwd()). Критически важно для правильной работы.
cwdStringТекущая рабочая директория. Обычно не меняется.process.cwd().
historyArrayМассив пройденных путей файла. Отслеживает его перемещения в рамках пайплайна.
relativeStringВычисляемое свойство. Относительный путь от base до path.Путь, который вы часто используете для создания ссылок.

Почему эта абстракция так мощна?

  1. Работа в памяти: Создание, преобразование и фильтрация сотни "файлов", не записывая ни одного байта на диск до самого конца пайплайна. Это быстро.
  2. Согласованность данных: Путь и содержимое синхронизированы в одном объекте. Если переименовали файл (изменили path), history автоматически обновится, а contents останется привязанным к нему.
  3. Независимость от источника: Vinyl-объект может представлять как реальный файл с диска, так и сгенерированный на лету код, данные из сети или буфер в памяти. Для следующей стадии пайплайна это не имеет значения.

Практика: Создаём и исследуем Vinyl

Давайте посмотрим на него в деле. Установим пакет vinyl и создадим файл "виртуально".

npm install vinyl
// create-vinyl.mjs
import Vinyl from 'vinyl';

// 1. Создадим Vinyl-файл из строки (как будто сгенерировали код)
const virtualFile = new Vinyl({
cwd: '/home/user/project',
base: '/home/user/project/src',
path: '/home/user/project/src/components/Button.js',
contents: Buffer.from('export default function Button() { return <button>Click</button>; }')
});

console.log('Виртуальный файл создан:');
console.log(' Путь (path):', virtualFile.path);
console.log(' Базовый путь (base):', virtualFile.base);
console.log(' Относительный путь (relative):', virtualFile.relative); // 'components/Button.js'
console.log(' История (history):', virtualFile.history); // ['/home/user/project/src/components/Button.js']
console.log(' Размер содержимого:', virtualFile.contents?.length, 'байт');

// 2. "Переместим" файл в другую папку
virtualFile.path = '/home/user/project/dist/ui/Button.js';
console.log('\nПосле "перемещения":');
console.log(' Новый путь (path):', virtualFile.path);
console.log(' Обновлённая история (history):', virtualFile.history); // Теперь два элемента
console.log(' Новый relative (от base!):', virtualFile.relative); // 'dist/ui/Button.js' — ОЙ!
// Внимание! relative теперь неверен, потому что base остался старым.
// В реальном пайплайне за этим следит vinyl-fs и плагины.

Вывод этого примера: base — это не техническая деталь. Это "система координат" файла. Большинство плагинов (например, для переименования или копирования) работают, манипулируя path, но опираясь на base. Если base задан неверно или забыт, логика относительных путей ломается.

Vinyl — это атом. Чтобы превратить множество таких атомов в управляемый поток, способный читаться с диска и записываться на него, нужен vinyl-fs. Он делает абстракцию практичной.

Часть 2: vinyl-fs — фабрика файловых потоков

Если Vinyl — это абстрактный файловый дескриптор, то vinyl-fs пакет на npm — фабрика и менеджер потоков. Он берёт абстрактные объекты Vinyl и превращает в живой, управляемый поток, который можно читать с диска, трансформировать и записывать обратно. Понимание работы vinyl-fs — ключ к раскрытию мощи экосистемы.

Связь, о которой умалчивают: gulp.src = vinyl-fs.src

Вот главный момент, который сделает понимание целостным: функция gulp.src() — это прямая реэкспортированная и адаптированная функция vinyl-fs.src(). Gulp — это, по большей части, обёртка и организация задач вокруг ядра, являющегося vinyl-fs.

Когда вы вызываете gulp.src('./src/*.js'), происходит следующее:

  1. vinyl-fsс помощью библиотеки glob находит файлы по указанному шаблону.
  2. Для каждого найденного файла создаётся объект Vinyl. Свойства path, base, cwd и history заполняются на основе пути, а contents загружается в виде потока (Stream) или буфера (Buffer).
  3. Эти Vinyl-объекты по одному передаются вниз по потоку Node.js (ReadableStream).

Пример: как src() и dest() управляют потоками

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

// vinyl-fs-demo.mjs
import vfs from 'vinyl-fs';
import through2 from 'through2'; // Утилита для создания трансформирующих потоков

// 1. Создаём исходный поток из реальных файлов
const sourceStream = vfs.src('./src/**/*.js', {
base: './src', // Явно задаём base! Это важно.
buffer: true, // Загружать содержимое как Buffer (по умолчанию). false = как Stream.
read: true // Собственно, читать содержимое. false - только метаданные.
});

// 2. Создаём простейший "плагин"-трансформер через through2
const myTransform = through2.obj(function (file, enc, callback) {
// 'file' здесь - это Vinyl-объект
if (file.isBuffer()) {
// Преобразуем содержимое: добавим комментарий в начало каждого файла
const updatedContent = Buffer.from(`// Обработано через vinyl-fs\n${file.contents.toString()}`);
file.contents = updatedContent;
}
this.push(file); // Передаём изменённый файл дальше по потоку
callback();
});

// 3. Создаём поток для записи (destination)
const destStream = vfs.dest('./output');

// 4. Собираем пайплайн и запускаем
sourceStream
.pipe(myTransform)
.pipe(destStream)
.on('finish', () => {
console.log('✅ Все файлы из ./src обработаны и записаны в ./output');
console.log(' Каждый файл сохранил структуру папок относительно base="./src"');
})
.on('error', (err) => {
console.error('❌ Ошибка в потоке:', err);
});

Опция: base

Обратите внимание на опцию base: './src' в вызове vfs.src. Это сердце правильной работы с путями.

Вывод: vinyl-fs — это механизм, который превращает абстрактную идею "потока файлов" в работающую реальность. Он обеспечивает корректное чтение, маршрутизацию на основе base и запись.

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

Часть 3: "Сквозные потоки" (Passthrough) — концепция, а не флаг

В оригинальной статье 2021 года идея была революционной: динамически добавить новые файлы в середину работающего пайплайна, "пропустив" их мимо предыдущих шагов. Технически это пытались реализовать через флаг {passthrough: true} в gulp.src(). Однако этого флага больше нет в публичном API. Его использование непредсказуемо и недокументировано.

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

Эволюция идеи: от хака к архитектуре

Проблема подхода через passthrough в его «магичности» и непрозрачности. Он нарушал классическую, легко читаемую линейную цепочку .pipe(). Современные альтернативы делают слияние явным, предсказуемым и тестируемым.

Современные паттерны вместо passthrough

1. Явное слияние потоков в начале

Распространённый сценарий — обработка файлов из нескольких источников одним пайплайном. Для этого вместо попытки вставить что-то в середину, создайте несколько потоков и слейте их до начала основной обработки.

// modern-merge-start.mjs
import gulp from 'gulp';
import babel from 'gulp-babel';
import concat from 'gulp-concat';
import merge from 'merge-stream'; // Ключевой пакет!

export function build() {
// Создаём независимые потоки
const appStream = gulp.src('src/**/*.js').pipe(babel());
const vendorStream = gulp.src('vendor/**/*.js');
const configStream = gulp.src('config/*.json');

// Явно объединяем их в один поток ДАЛЬНЕЙШЕЙ обработки
return merge(appStream, vendorStream, configStream)
.pipe(concat('all.js'))
.pipe(gulp.dest('dist'));
}

Почему это лучше passthrough: Структура задачи ясна. Видно, какие потоки создаются и в какой момент объединяются. Легко добавлять или убирать источники.

2. Композиция задач через series() и parallel()

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

// modern-composition.mjs
import gulp from 'gulp';
import svgSprite from 'gulp-svg-sprite'; // Допустим, создаёт спрайт
import { stream as critical } from 'critical'; // Допустим, извлекает критический CSS

// Задача 1: Создать SVG-спрайт из отдельных иконок
function buildSprite() {
return gulp.src('src/icons/*.svg')
.pipe(svgSprite(/* config */))
.pipe(gulp.dest('dist/assets')); // Результат: один файл спрайта
}

// Задача 2: Обработать основные стили
function buildStyles() {
return gulp.src('src/css/*.css')
.pipe(/* postcss, autoprefixer */)
.pipe(gulp.dest('dist/css'));
}

// Задача 3: Извлечь критический CSS (ей нужен и HTML, и уже готовые стили)
function extractCritical() {
return gulp.src('dist/*.html')
.pipe(critical({ base: 'dist/', inline: true }))
.pipe(gulp.dest('dist'));
}

// Собираем пайплайн: сначала ПАРАЛЛЕЛЬНО создаём спрайт и стили,
// затем, когда оба этапа готовы, ПОСЛЕДОВАТЕЛЬНО запускаем извлечение критического CSS.
export const build = gulp.series(
gulp.parallel(buildSprite, buildStyles),
extractCritical
);

Почему это лучше passthrough: Это архитектурно чистый подход. Каждая задача ответственна за свой чёткий результат. Порядок зависимостей (сначала стили и спрайт → потом критический CSS) задан явно и декларативно.

3. Условное добавление файлов через сквозные трансформеры

Если нужно решить, добавлять ли файл в поток по ходу обработки (например, на основе содержимого), это работа для трансформирующего потока.

// modern-conditional.mjs
import gulp from 'gulp';
import through2 from 'through2';
import Vinyl from 'vinyl';

export function processWithInjection() {
return gulp.src('src/pages/*.html')
.pipe(through2.obj(function (file, enc, cb) {
// 1. Сначала передаём оригинальный файл дальше
this.push(file);

// 2. Логика для условного добавления нового файла
// basename — это псевдоним, предоставляемый некоторыми плагинами, для чистого Vinyl используйте path.basename(file.relative)
if (file.basename === 'index.html') {
const virtualFile = new Vinyl({
cwd: file.cwd,
base: file.base,
path: file.dirname + '/injected-script.js',
contents: Buffer.from('console.log("Injected for index.html");')
});
// Динамически создаём и добавляем в поток новый Vinyl-объект
this.push(virtualFile);
}
cb();
}))
.pipe(gulp.dest('dist')); // В dist попадут и .html, и условно добавленные .js
}

Почему это лучше passthrough: Полный контроль. Вы сами решаете, когда, как и какой Vinyl-объект добавить в поток, основываясь на логике внутри трансформера. Это мощный низкоуровневый паттерн.

Ключевой вывод

Не ищите волшебный флаг passthrough. Ищите правильный паттерн для задачи:

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

Часть 4: Практические сценарии в современном стеке

Теперь, когда разобрались с теорией Vinyl, vinyl-fs и современными паттернами управления потоками, давайте посмотрим, как это работает в реальных, актуальных задачах.

Сценарий 1: Сборка современного JavaScript-проекта

Задача: обработать наши ES6+ модули через Babel, проверить линтером и объединить со сторонними библиотеками из node_modules.

// modern-js-build.mjs
import gulp from 'gulp';
import babel from 'gulp-babel';
import eslint from 'gulp-eslint-new'; // Замена устаревшему JSHint и ESLint
import concat from 'gulp-concat';
import terser from 'gulp-terser'; // Замена UglifyJS, лучше поддерживает ES6+
import merge from 'merge-stream';
import sourcemaps from 'gulp-sourcemaps'; // Для отладки
import { deleteAsync } from 'del'; // Для очистки

// Очистка папки dist перед сборкой
export function clean() {
return deleteAsync(['dist/js']);
}

function buildJs() {
// 1. Поток с нашими исходниками
const appStream = gulp.src('src/**/*.js')
.pipe(eslint())
.pipe(eslint.format())
.pipe(eslint.failAfterError())
.pipe(sourcemaps.init()) // Инициализация sourcemaps
.pipe(babel({
presets: ['@babel/preset-env']
}));

// 2. Поток с vendor-библиотеками из node_modules
// base критически важен для сохранения структуры в merge
const vendorStream = gulp.src([
'node_modules/lodash-es/lodash.js',
'node_modules/axios/dist/axios.js'
], { base: 'node_modules' });

// 3. Слияние, объединение и минификация
return merge(appStream, vendorStream)
.pipe(concat('app.bundle.min.js'))
.pipe(terser())
.pipe(sourcemaps.write('.')) // Запись .map файла
.pipe(gulp.dest('dist/js'));
}

// Главная задача: сначала очистить, потом собрать
export const js = gulp.series(clean, buildJs);

Что изменилось с 2021 года:

  1. ESLint вместо JSHint: Современный стандарт линтинга с экосистемой плагинов.
  2. Babel для транспиляции: Позволяет использовать современный синтаксис JavaScript.
  3. Terser вместо UglifyJS: Корректно минифицирует ES6+ код.
  4. Source Maps: Обязательный инструмент для отладки минифицированного кода.
  5. Чёткая композиция задач: Функция clean выполняется перед сборкой.

Сценарий 2: Создание сайтмапа на основе HTML-шаблонов

Здесь выходим за рамки сборки ресурсов и используем потоки Vinyl для генерации контента на основе данных из потока.

// generate-sitemap.mjs
import gulp from 'gulp';
import through2 from 'through2';
import Vinyl from 'vinyl';
import { SitemapStream } from 'sitemap'; // Библиотека для генерации XML сайтмапа

function generateSitemap() {
let allPages = [];

return gulp.src('src/templates/**/*.html')
.pipe(through2.obj(function (file, enc, cb) {
// Извлекаем метаданные из файла (например, из комментариев или frontmatter)
// Для примера: считаем, что в data у нас уже есть объект с URL и датой
const pageData = file.data || {
url: file.relative.replace(/\\/g, '/').replace(/\.html$/, ''),
lastmod: new Date().toISOString()
};
allPages.push(pageData);
this.push(file); // Продолжаем передавать HTML-файлы дальше (например, для сборки)
cb();
}))
.on('end', () => {
// После обработки ВСЕХ файлов создаём сайтмап
const smStream = new SitemapStream({ hostname: 'https://example.com' });
const xmlContent = allPages.reduce((stream, page) => {
stream.write(page);
return stream;
}, smStream).end();

// Собираем XML в буфер
const chunks = [];
xmlContent.on('data', chunk => chunks.push(chunk));
xmlContent.on('end', () => {
const sitemapFile = new Vinyl({
cwd: process.cwd(),
base: process.cwd(),
path: 'dist/sitemap.xml',
contents: Buffer.concat(chunks)
});

// Создаём специальный поток для записи этого одного файла
const writeStream = require('stream').Writable({ objectMode: true });
writeStream._write = function (file, enc, next) {
require('fs').writeFileSync(file.path, file.contents);
next();
};
writeStream.write(sitemapFile);
writeStream.end();
});
});
}

Обратите внимание: Поток используется не только для трансформации файлов, но и как источник данных (метаданные страниц). На основе этих данных, собранных после обработки потока (on('end')), создаём новый Vinyl-файл.

Сценарий 3: Параллельная оптимизация ассетов с агрегацией результатов

Частая задача — оптимизировать изображения и шрифты из папок, а просто скопировать в одну папку dist/assets. Подходит для parallel и merge.

// optimize-assets.mjs
import gulp from 'gulp';
import merge from 'merge-stream';
import imagemin from 'gulp-imagemin';
import fonter from 'gulp-fonter'; // Конвертация форматов шрифтов
import flatten from 'gulp-flatten'; // Для уплощения структуры

export function optimizeImages() {
return gulp.src(['src/images/**/*.{jpg,png,svg}', 'src/photos/*.jpeg'])
.pipe(imagemin([imagemin.mozjpeg({ quality: 80 }), imagemin.optipng()]))
.pipe(gulp.dest('dist/assets/images'));
}

export function optimizeFonts() {
return gulp.src('src/fonts/**/*.{ttf,otf}')
.pipe(fonter({ formats: ['woff2', 'woff'] })) // Конвертируем в веб-форматы
.pipe(flatten()) // Складываем все шрифты в одну папку (опционально)
.pipe(gulp.dest('dist/assets/fonts'));
}

// Собираем все ассеты параллельно
export const assets = gulp.parallel(optimizeImages, optimizeFonts);

// Или создаём единый поток всех ассетов в одну папку
export function allAssets() {
const images = gulp.src('src/images/**/*')
.pipe(imagemin())
.pipe(gulp.dest('dist/assets/all'));

const fonts = gulp.src('src/fonts/**/*')
.pipe(fonter({ formats: ['woff2'] }));

return merge(images, fonts)
.pipe(gulp.dest('dist/assets/all')); // Всё в одной папке
}

Рекомендации по организации кода

  1. Разделяйте gulpfile на модули: Создайте папку gulp-tasks/ и разложите задачи по файлам (js.js, css.js, assets.js). Импортируйте в основной gulpfile.mjs.

  2. Используйте gulp.lastRun для инкрементальных сборок: Чтобы ускорить сборку, обрабатывайте только изменившиеся файлы.

    export function incrementalJs() {
    return gulp.src('src/**/*.js', { since: gulp.lastRun(incrementalJs) })
    .pipe(babel())
    .pipe(gulp.dest('dist'));
    }
  3. Экспортируйте задачи явно: Это даёт преимущества при автодополнении в редакторах и работе через CLI.

Эти сценарии показывают, как, отталкиваясь от базовых принципов потоков Vinyl, строить как типовые процессы сборки, так и решать задачи автоматизации. В этом и заключается современная сила подхода Gulp.

Заключение: Vinyl как основа гибкой автоматизации

Мы прошли путь от деконструкции старой статьи о Gulp 4 до понимания технологий, которые делают его гибким инструментом и сегодня. Давайте подведём итоги и расставим акценты.

Суть переосмысления

Оригинальная статья 2021 года фокусировалась на конкретной, устаревшей синтаксической возможности — флаге passthrough. Наше же исследование показало, что истинная ценность лежит не в синтаксисе, а в архитектурных принципах:

Такой взгляд превращает Gulp из "сборщика фронтенда" в платформу для автоматизации на основе потоков данных. Вы не просто конкатенируете JS-файлы — вы оперируете потоком виртуальных файлов, которые могут представлять собой что угодно: Markdown-статьи, SVG-иконки, данные из API, конфигурационные шаблоны.

Выводы для современного разработчика

  1. Сила в ядре, а не в обёртке: Мощь gulp.src() и экосистемы исходит от vinyl-fs. Понимание этого позволяет выходить за рамки готовых плагинов и создавать кастомные трансформеры через through2.
  2. base — главный секрет правильных путей: Проблемы с неожиданной структурой выходных каталогов решаются корректной установкой свойства base при создании потока. Это системообразующее понятие для vinyl-файла.
  3. Экосистема эволюционирует: Устаревшие инструменты вроде JSHint и CoffeeScript уступили место ESLint и TypeScript/Babel. Современные плагины (например, gulp-terser, gulp-imagemin) лучше, быстрее и поддерживают современные стандарты. Использование ES-модулей (gulpfile.mjs) стало стандартом.
  4. Автоматизация — это композиция: Сложные пайплайны строятся не цепочкой из 20 .pipe(), а через грамотную композицию небольших, переиспользуемых задач с помощью gulp.series() и gulp.parallel(). Это делает код читаемым и поддерживаемым.

Когда стоит выбирать Gulp?

Gulp не является и, пожалуй, не будет инструментом первого выбора для типовой сборки SPA-приложения на React или Vue. Для этого существуют оптимизированные инструменты (Vite, Webpack с готовыми конфигурациями).

Gulp блестяще проявляет себя в других нишах:

Итог

Понимание Vinyl и vinyl-fs даёт не знание о Gulp, а знание о паттерне потоковой обработки файлов, реализованном в JavaScript. Это представляет Gulp как гибкий конструктор для решения нестандартных задач автоматизации, где другие инструменты потребовали бы написания многострочного одноразового кода.

Поэтому, возвращаясь к самому началу: да, Gulp изменил роль в экосистеме. Перестал быть главным сборщиком, но стал надёжным и мощным ядром для построения собственной системы автоматизации. И это — даже более ценная позиция.

Более детально с этими фундаментальными понятиями можно ознакомиться в официальной документации Gulp в разделе "Concepts".

Комментарии


Дополнительные материалы

Предыдущая Статья

PSR-20 Clock: Тестируемое время в PHP