HTML-препроцессор Pug — это всё тот же Jade, только Pug. Создатель решил изменить имя своего продукта после претензий со стороны какой-то компании. Технически, Pug — это Jade второй версии, пока что находящийся в альфе.
К сожалению, эта статья устарела и не учитывает некоторые особенности Pug/Jade (комментарии, RAW-зависимости). Однако, я советую прочитать эту статью и попробовать пакет emitty, который является логическим продолжением мыслей, озвученных здесь.
Недавно я занимался небольшим интернет-магазином, состоящим из 18 страниц. В качестве основы была выбрана, конечно же, методология организации кода для HTML-препроцессоров, которая была описана мной в статье «Организация кода для HTML-препроцессоров».
Напомню, что идея описанного способа заключается в разделении страницы на макет, компоненты и, собственно, саму страницу. То есть среднестатистическая страница в минимальном обмундировании может быть представлена следующим набором файлов:
- index.pug
- layouts/_default.pug
- page/index/_main.pug
- partials/_head.pug
- partials/_header.pug
- partials/_footer.pug
И вот тут уже можно заметить проблему — файлов достаточно много. При этом не стоит забывать, что у страницы могут быть какие-нибудь компоненты, например, верхний бар, навигационная панель или слайдер продукции. Представим, что каждая страница в общей сложности имеет 10 зависимостей, что в конечном итоге даст 180 обращений к файловой системе при компиляции всех 18 страниц. То есть каждый раз, когда я изменяю одну из страниц или любой из компонентов — будут компилироваться все 18 страниц. Даже при условии, что у вас SSD — это не лучший вариант.
В этом проекте компиляция занимала от 4,5 до 6,0 секунд в зависимости от загруженности процессора и файловой системы. Для одноразовой сборки это вполне приемлемая цифра, но когда сборка осуществляется в реальном времени — это ужасные муки. Вы можете себе представить, что вы изменили заголовок страницы и, чтобы увидеть это изменение в браузере вам нужно подождать пять секунд? — поверьте, это ужасно.
Именно поэтому я стал искать возможные решения, которые бы ускорили компиляцию до приемлемых 500 миллисекунд. В принципе, меня устроила бы и 1 секунда, потому что именно столько времени мне нужно, чтобы перейти от окна редактора к браузеру, используя Alt + Tab и не потерять некоторое количество нервных клеток из-за ожидания.
Вообще, если попытаться обобщить решение, то его суть будет заключаться в следующем: пусть компилируются лишь те страницы, зависимости которых были изменены. Такой подход называется инкрементальной сборкой, когда страница должна компилироваться только в том случае, если её зависимости (компоненты и прочее) были изменены.
Нужно оговориться, что в своих проектах я использую Gulp, поэтому решения я искал именно для него. К слову, я нашёл три возможных решения:
Вариант с плагином gulp-jade-inheritance сразу же отпал из-за того, что он работает с Jade версии 1.9.2. Второй претендент отпал по иным причинам, о которых мы поговорим позже. Третий же вариант работает с зависимостями только на одном уровне вложенности. То есть, если страница имеет зависимость от файла a.pug
, а этот файл в свою очередь зависит от файла b.pug
, то при изменении файла b ничего компилироваться не будет.
Теперь разберёмся с gulp-pug-inheritance. Дело в том, что завести нормально этот плагин у меня так и не получилось. Я не знаю почему, но иногда страницы даже не доходили до процесса компиляции, а также, порой древо зависимостей страницы строилось около минуты. Разбираться в проблемах с этим плагином мне не захотелось из-за предвзятого отношения к нему. Понимаете, создатель плагина пошёл самым странным путём, добавив в свой проект glob и парсер Pug.
Почему парсер — это зло?
Для того, чтобы понять проблематику, нужно понять то, что мы хотим сделать. Сейчас наша задача сводится к построению древа зависимостей страницы. Тут всё очень просто:
- Шаг 1. Получаем файл
- Шаг 2. Находим в файле все конструкции типа
extends
иinclude
- Шаг 3. Получаем путь из этих конструкций и добавляем его в древо зависимостей
- Шаг 4. Если файл имеет зависимости, то переходим к первому шагу для каждой из зависимостей
Вот такая вот очень простая рекурсия. Читаем файлы, находим зависимости и делаем это до тех пор, пока не закончатся файлы, от которых зависит страница.
Кстати, не стоит бояться понятия древа. В нашем случае под ним стоит понимать обычный массив строк, где каждая строка — это путь до файла, от которого зависит страница.
Окей, но какова задача парсера? — разобрать синтаксис Pug с помощью регулярных выражений и построить древо для дальнейшей его обработки и преобразования в HTML. На сегодняшний день pug-lexer имеет 28 регулярных выражений для обработки синтаксиса, причём это я не вчитывался в код, а просто сделал поиск по файлу. На выходе парсера мы имеем древо, в котором нам нужно найти все extends
и include
. Это операция довольно ресурсоёмкая и именно поэтому использование парсера здесь зло.
Куда эффективнее позаимствовать из парсера регулярные выражения лишь для extends
и include
, но об этом немного позднее.
Строим велосипед
Как я уже говорил раньше — в проекте используется Gulp, поэтому решения я буду писать конкретно под свой проект. Однако, вы можете приспособить его, например, и под Grunt.
Я упростил задачу, обрабатывающую файлы шаблонов до минимума, оставив лишь шаг компиляции Pug-файлов и обработчик ошибок. Сейчас наша задача выглядит следующим образом:
function task() {
return $.gulp.src('app/templates/*.pug')
.pipe($.pug({
pretty: true
}).on('error', pugErrorHandler))
.pipe($.gulp.dest('build'));
}
Также, на события изменения файлов был повешен обработчик Gulp:
$.gulp.watch([
'app/templates/**/*',
], $.gulp.series('templates', 'reload'));
Сейчас, в случае изменения любого из файлов в директории app/templates
будет вызвана задача templates
, которая будет компилировать каждую страницу. Наша же задача, как я говорил раньше, это компиляция только тех страниц, зависимости которых были изменены.
Для начала нужно построить древо всех зависимостей для каждой из страниц. Эта задача была успешно решена с помощью ниже приведённого кода. Каждую функцию я снабдил комментариями.
/**
* Нормализация пути
*
* В файле путь до другого файла может иметь вид `sidebar` или `partials/header`
* поэтому каждый путь должен быть обработан.
*
* Эта функция добавляет к пути контекст или, проще говоря родительскую директорию.
*/
function normalizePath(filepath, context) {
if (context) {
return path.join(context, filepath).replace(/\\/g, '/');
}
return filepath;
}
/**
* Получение путей
*
* С помощью регулярного выражения, забираем из файла все пути из конструкций
* extends и include.
*/
function getPaths(source) {
const match = source.match(/^\s*(include|extends)\s(.*)/gm);
if (match) {
return match.map((match) => match.replace(/\s*(include|extends)./g, '') + '.pug');
}
return null;
}
/**
* Получение всех страниц из директории `app/templates`
*/
function getPages() {
return fs.readdirSync('app/templates').filter((filepath) => /pug$/.test(filepath));
}
/**
* Чтение файла
*/
function getPage(name) {
const filepath = path.join('app/templates', name);
try {
return fs.readFileSync(filepath).toString();
} catch (err) {
return false;
}
}
/**
* Вычисление древа зависимостей
*
* Функция рекурсивно проходит по всем зависимостям и заносит их в массив.
*/
function calculateTree(target, context, tree) {
const page = getPage(target);
if (!page) {
return tree;
}
let paths = getPaths(page);
if (!paths) {
return tree;
}
paths = paths.map((filepath) => normalizePath(filepath, path.dirname(target)));
paths.forEach((filepath) => {
tree = calculateTree(filepath, path.dirname(target), tree);
});
return tree.concat(paths);
}
/**
* Получение зависимостей для каждой из страниц
*/
function getPathsTree() {
const cacheTree = {};
getPages().forEach((page) => {
cacheTree[page] = calculateTree(page, null, [page]);
});
return cacheTree;
}
module.exports.getPathsTree = getPathsTree;
Наибольший интерес представляет собой интеграция всей этой лапши в Gulp. Идея такова, чтобы отфильтровать лишние файлы из потока. Алгоритмически это будет выглядеть так:
- Получаем все зависимости страниц, используя код выше
- Если запущено слежение за файлами, то получаем имя изменившегося файла
- Фильтруем страницы в потоке следующим образом:
- Если не запущено слежение за файлами, то пропускаем все страницы в поток
- Если имя изменившегося файла есть в древе зависимостей страницы, то пропускаем её дальше в поток
- Если имя изменившегося файла не найдено в древе зависимостей, то выбрасываем страницу из потока
Имплементируем описанный алгоритм. Сначала нужно преобразовать вотчер таким образом, чтобы при изменении любого файла в директории app/templates
он возвращал его имя:
$.gulp
.watch([
'app/templates/**/*'
], $.gulp.series('templates', 'reload'))
// Обработчик изменения любого из файлов в директории `app/templates`, включая события
// удаления или создания файлов, а также директорий
.on('all', (event, path) => {
// Получаем имя файла и записываем его в глобальную переменную
global.changedTplFile = path.replace(/[\\\/]/g, '/').replace(/app\/templates\//, '');
});
Глобальная переменная здесь используется из-за того, что задачи у меня разделены по файлам.
Затем изменяем задачу таким образом, чтобы можно было контролировать файлы в потоке. Для этого я буду использовать плагин gulp-filter. Хочу заметить, что код ниже будет работать только на Node.js 6-ой ветки. Для того, чтобы завести его на Node.js 5-ой ветки и ранее, необходимо заменить .includes
на .indexOf(changed) + 1
.
function task() {
// Получаем древо зависимостей для каждой страницы
const pathsTree = _.getPathsTree();
return $.gulp.src('app/templates/*.pug')
// Фильтруем файлы в потоке
.pipe($.filter((file) => {
// Если не запущен режим слежения за изменением файлов, то пропускаем в поток
// все страницы
if (!global.watch) {
return true;
}
// Если имя изменившегося файла есть в зависимостях страницы, то пропускаем
// страницу дальше в поток, попутно выводя сообщение в консоль для контроля
const changed = global.changedTplFile;
if (pathsTree[file.relative].includes(changed)) {
console.log($.chalk.green('>> ') + `Compiling: ${file.relative}`);
return true;
}
// Иначе отбрасываем страницу
return false;
}))
.pipe($.pug({
pretty: true
}).on('error', pugErrorHandler))
.pipe($.gulp.dest('build'));
}
Выводы
Вот такие вот пироги. С помощью описанного выше способа мне удалось снизить время с 5-6 секунд до 300-500 миллисекунд. Думаю, что результат меня более чем удовлетворяет. Конечно, можно использовать промисы (Promise) при построении древа зависимостей и строить древо для каждой страницы асинхронно. Можно вообще перестроить древо таким образом, чтобы оперировать не страницами, а файлами и, тем самым, упростить его перестроение. Имеется в виду, что нужно будет перестраивать древо не для всех страниц, а только для конкретного файла, а зависимости страницы смотреть уже, бегая по объекту. Но это дело наживное и занимает считанные миллисекунды.
К слову, при использовании парсера Pug на каждую страницу уходило почти в три раза больше времени. Ощущаете разницу общего решения и заточенного под конкретную задачу?
Changelog
- [10.08.2016] Статья устарела. Используйте пакет yellfy-pug-inheritance.