Инкрементальная компиляция с emitty

Около полугода назад я написал статью «Инкрементальная компиляция Pug-файлов», которая, судя по статистике, стала весьма популярной. Спустя некоторое время после её выхода я опубликовал npm-модуль yellfy-pug-inheritance, который разрабатывался в качестве внутреннего модуля для организации инкрементальной компиляции Pug-файлов в Yellfy. Однако, время идёт и многому суждено измениться. Вот на дворе почти финал 2016 года, а я решаю сменить Pug на что-то более производительное и модульное. Почему? — Pug очень медленный.

Jade и Pug очень медленные

Выше представлен скриншот результатов бенчмарка, где на странице присутствует пять циклов, 150 переменных и около 500 селекторов. Я выбрал PostHTML с синтаксисом SugarML, потому что это весьма производительное решение, имеющее множество различных плагинов и настраиваемый синтаксис. Если же вы хотите чумовую производительность, то можете присмотреться к Slm.

Соответственно, теперь yellfy-pug-inheritance не вписывается в будущее окружение Yellfy. Необходимо было разработать более универсальное решение, которое позволило бы:

  • Поддерживать популярные HTML- и CSS-препроцессоры из коробки
  • Упростить процесс включения модуля в окружение
  • Разрешить пользователям менять поведение модуля и его настройки

Всё это было реализовано в модуле emitty. Далее в статье пойдёт подробное описание того, что умеет этот модуль, зачем он нужен и как его настроить.

Инкрементальная компиляция

Но, постойте-ка, а что значит инкрементальная сборка? Всё очень просто инкрементальная сборка — это когда проект собирается полностью лишь один раз, а затем происходит сборка лишь тех файлов, что были изменены с момента последней сборки проекта.

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

Инкрементальная сборка

Теперь поговорим про инкрементальную компиляцию. Если термин инкрементальной сборки относится к выполнению «задачи» для сборки какой-либо части проекта, то инкрементальная компиляция относится к внутренностям этой самой «задачи».

Под инкрементальной компиляцией стоит понимать компиляцию только тех файлов, что зависят от изменившегося файла. Вот так вот просто. В переводе на человеческий — если файл a.pug зависит от файла b.pug и файл b.pug изменился, то скомпилирован должен быть только файл a.pug, а не какой-нибудь там файл c.pug.

Инкрементальная компиляция

Резюмируя можно сказать, что инкрементальная сборка позволяет избежать запуска лишних задач, а инкрементальная компиляция — компиляции лишних файлов.

Кому нужен emitty?

Во-первых, emitty может быть полезен проектам, которые имеют более 5-10 страниц и, конечно же, используют HTML- или CSS-препроцессор. Мотивация? — одна страница компилируется в среднем 100-200ms, соответственно, 5 страниц — 1s, а 10 страниц — 2s и так далее. В случае использования emitty вы не выйдете за пределы 400ms даже если в вашей директории шаблонов или стилей имеется более 9000 файлов (9000 файлов перебераются за 400ms). Вы можете представить себе проект, стили или шаблоны которого насчитывают больше 1000 файлов? При этом нужно учесть, что вы можете указать директории, которые не будут проверяться при сканировании.

Во-вторых, emitty пригодится тем, кто придерживается методологии организации файлов проекта, которая основана на компонентном подходе. Одно дело, когда вы изменили файл шапки или подвала, и все страницы перекомпилировались, а другое, когда вы изменили лишь файл компонента, который используется максимум на 2-3 страницах. Я думаю не стоит объяснять выгоду во времени, даже если ваш препроцессор поддерживает кеширование.

В-третьих, теперь emitty стало намного проще пользоваться по сравнению с тем, что было во времена yellfy-pug-inheritance. Просто подключите модуль, вызовите настройщик и необходимый метод в нужном вам месте.

Функциональность emitty

emitty — это инструмент, позволяющий организовать инкрементальную компиляцию файлов практически для любого HTML- и CSS-препроцессора, запущенного в режиме наблюдения (watch) и не только.

Внимание

Далее речь пойдёт только о режиме наблюдения, так как именно для этого создавался emitty. Если вы хотите работать без вотчера, то перед компиляцией файлов вам необходимо предварительно загрузить последнее состояние хранилища, используя options.snapshot или emitty.load и вручную определить необходимость перекомпиляции с помощью emitty.resolver.checkDependencies.

Как это работает?

В момент первого запуска emitty сканирует указанную директорию в соответствии с параметрами заданного «языка», читает каждый подходящий файл и выявляет его зависимости, после чего сохраняет полученный результат в хранилище в виде пары «путь файла: зависимости и время последнего изменения». При повторном запуске у emitty может быть два варианта поведения:

  1. Если передан изменившийся файл, то обновляется только его содержимое.
  2. В случае, если нет переданного файла, то emitty вновь сканирует указанную директорию и обрабатывает все файлы, у которых время последнего изменения больше, чем то, что записано в хранилище.

А что за «языки»?

Под «языком» нужно понимать параметры, с помощью которых сканер определяет нужный ему файл и его зависимости. К таким параметрам относятся:

  • extends — расширитель, позволяющий указать один из встроенных языков, как «родителя», который поделится своими настройками.
  • extensions — расширения файлов.
  • matcher — регулярное выражение для поиска зависимостей.
  • comments.start и comments.end — начало и конец комментария.
  • indentBased — флажок синтаксиса, построенного на индентации — в зависимости от этого применяются разные алгоритмы поиска комментариев.

Благодаря возможности создавать свои «языки» вы также можете использовать emitty не только с HTML- или CSS-препроцессорами, но и с другими инструментами, если того требует ваше окружение и задачи, которое оно решает. Например, применять Babel только для тех файлов, что действительно нужно трансформировать.

Замечание

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

Среди встроенных языков emitty имеет:

  • jade — Сейчас Pug
  • pug
  • nunjucks
  • posthtml — с плагином posthtml-include и/или posthtml-extend
  • sugarml — с плагином posthtml-include и/или posthtml-extend
  • less
  • stylus — по умолчанию только синтаксис построенный на индентации (можно переопределить при желании)
  • sass
  • scss

Рассмотрим пару примеров, чтобы вы понимали как можно быстро добавить свой HTML- или CSS-препроцессор в emitty.

Slm

Slm — это шаблонизатор, синтаксис которого построен на индентации.

{
  // Расширение файлов
  extensions: ['.slm'],
  // Регулярное выражение для поиска зависимостей
  matcher: /=?=\s(?:partial|extend)\(['"]([^'"]+)['"].*?\)/,
  // Начало и конец комментария
  comments: {
    start: '/',
    end: ''
  },
  // Флажок синтаксиса, построенного на индентации
  indentBased: true
}

Расширитель

Расширитель позволяет унаследовать от указанного встроенного языка все свойства и при необходимости перезаписать их.

Таким образом выглядит добавление нового языка, который наследует настройки от встроенного:

{
  extends: 'pug',
  extensions: ['.sgr', '.sml'],
  matcher: /(?:^|:)\s*(?:include|extends)\(?src=['"]([^'"]+)['"].*\)/
}

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

Как использовать emitty?

Теперь, когда вы представляете себе как работает emitty — пришло время поговорить о том, как же им пользоваться.

Как я уже говорил ранее, emitty является более универсальным и продуманным потомком yellfy-pug-inheritance, поэтому он создавался с учётом ранее полученных ошибок. Самой главной ошибкой предка был муторный процесс включения в уже работающее рабочее окружение и необходимость использования Gulp 4-ой ветки. Теперь этой проблемы нет. Зато есть два API: общее и потоковое. Не сложно догадаться, что второе API позволяет включать emitty как Gulp-плагин без необходимости писать сложную задачу.

Работа с API

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

Инициализация конфигурации

const emitty = require('emitty').setup('app/templates', 'pug');

В момент инициализации конфигурации модуль проверяет переданные данные, используя следующий алгоритм:

  1. Проверяет наличие переданной директории.
  2. Проверяет наличие настроек языка, как строки или объекта (в случае пользовательского языка).
  3. Проверяет наличие переданной директории в файловой системе.
  4. Проверяет правильность настроек языка.

Если что-то пошло не так, то emitty выдаст читабельную для человека ошибку.

При желании вы можете передать третий параметр — настройки модуля. Среди них:

  • snapshot — возможность передать предыдущее состояние хранилища.
  • log — функция логирования, предназначенная только для потоков.
  • cleanupInterval — инвалидация хранилища каждые указанные N-секунд. Я бы рекомендовал использовать эту опцию только в том случае, если ваш проект имеет более 1000 файлов, которые часто удаляются или добавляются.
  • makeVinylFile — создаёт Vinyl-файл внутри стрима для работы с gulp.src с переданной опцией read: false.
  • scanner.depth — максимальная глубина вложенности директорий при сканировании. Из коробки, значение равно 30-ти, чего должно хватить практически любому проекту.
  • scanner.exclude — glob-паттерн директорий, которые будут исключены из сканирования.

Доступ к хранилищу

emitty.load({}) // Загружает предыдущее состояние хранилища
emitty.scan().then(() => {
  console.log(emitty.keys()); // Ключи хранилища (пути файлов)
  console.log(emitty.storage()); // Само хранилище
  emitty.resolver.getDependencies('a.pug'); // Показывает файлы, от которых зависит указанный файл
  emitty.resolver.checkDependencies('a.pug', 'b.pug') // Проверяет зависит ли первый файл от второго
});

В emitty.scan() вы можете передать два параметра: путь изменившегося файла и его объект fs.Stats — это необязательно, но полезно, так как может сохранить вам время на сканировании директории. Если же вы не хотите передавать эти параметры, то сканер вновь пройдётся по директории, и в случае выявления устаревания элемента в хранилище обновит его.

Потоки

emitty.stream() — это полный аналог emitty.scan. Те же параметры и принцип работы. Однако, если в случае использования .scan() вы должны проверять зависимость файлов вручную, то тут это делается автоматически для тех файлов, что приходят по трубе потока.

Внимание

Если вы не передадите путь к изменившемуся файлу, то этим файлом станет последний найденный изменившийся файл с момента последнего сканирования директории.

Как работать с Gulp?

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

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

// npm i gulpjs/gulp#4.0 gulp-if gulp-pug emitty
const gulp = require('gulp');
const gulpif = require('gulp-if');
const pug = require('gulp-pug');
const emitty = require('emitty').setup('app/templates', 'pug', {
  makeVinylFile: true
});

gulp.task('watch', () => {
  // Указывает, что запущен режим наблюдения за изменениями файлов
  global.watch = true;

  gulp.watch('app/templates/**/*.pug', gulp.series('templates'))
    // Формирует информацию об изменившимся файле
    .on('all', (event, filepath, stats) => {
      global.emittyChangedFile = {
        path: filepath,
        stats
      };
    });
});

gulp.task('templates', () =>
  // read: false отменяет чтение содержимого файлов при создании потока Gulp
  gulp.src('app/templates/*.pug', { read: false })
    // Мы передаём в Stream API путь к изменившемуся файлу и его объект fs.Stats
    .pipe(gulpif(global.watch, emitty.stream(global.emittyChangedFile.path, global.emittyChangedFile.stats)))
    .pipe(pug({ pretty: true }))
    .pipe(gulp.dest('build'))
);

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

Внимание

Сейчас emitty (в зависимости от настроек) сканирует директорию или читает изменившийся файл столько раз, сколько файлов генерируется в потоке (увы, пока нет возможности отследить, что это один и тот же запуск «задачи»). То есть, если вы имеете в потоке 100 файлов, то emitty просканирует директорию 100 раз. Практически во всех случаях это совсем небольшая задержка (проверяется только время изменения файла), особенно, если вы указываете директории, которые необходимо исключить при сканировании. Поэтому сейчас я советую использовать Promise API. Также, я уже рассматриваю варианты решения этой проблемы.

Все варианты интеграции вы можете посмотреть в репозитории модуля или перейдя по ссылке: How to use with Gulp.

А что с CSS-препроцессорами?

К счастью большинство современных CSS-препроцессоров довольно производительны и, скорее всего, emitty вам не понадобится. Однако, если ваш проект полностью соответствует компонентному подходу организации файловой структуры, то вы можете разбить стили на несколько частей. При этом будет компилироваться только та часть, к которой относится изменённый файл. Затем вы сможете сконкатенировать все части в один большой файл, если этого требует ваше окружение.

Также вы можете разбить стили на две большие части: основные стили и всё остальное. Соответственно, компилироваться будут лишь те файлы, что нуждаются в компиляции. Затем все части вы сможете сконкатенировать в нужном вам порядке. Но помните, что такой подход будет полностью работать лишь в случае соблюдения компонентного подхода.

Вот и сказке конец

Я рассказал о том, как легко можно организовать инкрементальную компиляцию файлов для HTML- и CSS-препроцессоров. Теперь вы можете сами решить нужно вам это или нет.

Если у вас есть какие-либо идеи, то не стесняйтесь создавать ишью в репозитории emitty. В основном, я отвечаю довольно быстро.