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