Продолжаем эволюцию dev‑сервера и SSG: избавляемся от лишних пересборок
В предыдущих материалах я поэтапно разбирал как создать генератор статического сайта (SSG) на Node.js и реализацию простого локального dev‑сервера с авто‑обновлениями через EventStream. В этой статье минимальными усилиями решаю проблему ложных срабатываний пересборки — когда dev-сервер запускает обновление при отсутствии реальных изменений в файлах. Расскажу, как это устраняется с помощью хеширования содержимого.
Проблема
При работе с chokidar
(или другими file‑watcher
-ами) часто сталкиваешься с ситуацией: при сохранении файла, даже если содержимое не изменилось, система получает событие и пересобирает сайт → вкладка веб-браузера обновляется, а изменений нет.
Это приводит к замедлению цикла «изменил → сбилдил → проверил». Запускаются ресурсоёмкие операции: генерация, минификация, запись в файловую систему и т.п. Разработчик-пользователь раздражается из‑за постоянного процесса сборки и «мигания» веб-страницы, теряет драгоценный фокус.
Мы тратим ресурсы на сборки, которые ничего не меняют.
Дополнительный фильтр изменений
Суть идеи: при каждом событии изменения файла (change
) проверять, изменилось ли его содержимое, а не только метаданные (например, дата модификации).
Решением в лоб было бы хранить каждый ресурс в памяти, и побайтово сравнивать его с актуальной версией файла. Но память будет расходоваться неэффективно.
Внимание! Если вам необходимо держать файлы в памяти для компиляции и сборки — то линейно искать отличия будет быстрее! Используя потоковое чтение, обнаружить изменение при первом же несовпадении информации (то есть, вам и не нужно будет читать с диска весь файл). Можно обновить файл в памяти с этой позиции. Не нужно выполнять чтение ещё раз. Эффективно, но только если инструменты способны работать прямо в оперативной памяти, которая даже быстрее SSD на несколько порядков.
Я хочу применить и описать этот подход в отдельной статье.
Именно поэтому содержание файла хешируется SHA‑256. Такой Ключ или подпись быстро сравнивать и компактно хранить в памяти.
Гипотетически может произойти ситуация, когда для разного содержания вычисляется один хеш-ключ, но этого нереально добиться в условиях нашей задачи.
Имей в виду, что с хешированием, как с контрацептивами — не даёт 100% гарантии.
Приступим.
Шаг 1. Предварительная индексация
Индекс (Map
) хранит пары <путь
→ хеш
>, чтобы быстро сравнивать новое состояние со старым.
const index = new Map<string, string>();
Вычисление хеш-суммы файла:
import fs from "node:fs";
import crypto from "node:crypto";
const hashFile = (filePath: string): Promise<string | undefined> =>
fs.promises
.readFile(filePath)
.then((content) =>
crypto.createHash("sha256").update(content).digest("hex")
)
// игнорируем ошибки
.catch(() => undefined);
Использую встроенную функцию glob
чтобы заранее найти все отслеживаемые ресурсы в нужных директориях. И вычисляю их хеши вызывая написанную выше функцию hashFile
.
const createIndex = async (dirs: string[], ignore: RegExp): Promise<void> => {
console.info("Build initial files index...");
for (const dir of dirs) {
const iter = fs.promises.glob(path.join(dir, "**/*"));
for await (let filePath of iter) {
// игнорируем лишнее
if (ignore.test(filePath)) {
continue;
}
// пропускаем директории
if (fs.lstatSync(filePath).isDirectory()) {
continue;
}
// для консистентности ключей
filePath = filePath.replaceAll("\\", "/");
// вычисляем и сохраняем
const hash = await hashFile(filePath);
index.set(filePath, hash);
}
}
};
Шаг 2. Обработчик событий
Задача: вызвать функцию onChange
когда в файле произошли реальные изменения, для новых или удаленных.
- Запускается «вотчер» на все директории. Событие от
fs.watch
добавляет кандидата в очередь, вызовsubmitWatchEvents
планирует применение обновлений.
for (const dir of dirs) {
fs.watch(
dir,
{
persistent: true,
recursive: true,
},
async (_, filename: string) => {
// игнорируем файлы по фильтру
if (!ignored.test(filename)) {
// уникальность ключей
const filePath = path.join(dir, filename).replaceAll("\\", "/");
// запоминаем изменение
changedFiles.add(filePath);
// планируем обновление
submitWatchEvents();
}
}
);
}
- Планировщик использует
debounce
функцию (задержка выполнения) для отсечки повторяющихся изменений. Если в течение60 мс
обновлений нет — накопленная очередь проверяется.
const submitWatchEvents = debounce(() => {
// список кандидатов
const files = [...changedFiles];
// очищаем список для будущих обновлений
changedFiles.clear();
// проверяем каждый потенциально измененный файл
for (const file of files) {
void checkFileChanged(file, onChange);
}
}, 60);
- Для каждого кандидата вычисляется новый
hash
. Если значение отличается от предыдущего, значит ресурс изменился и необходимо отправить событие черезonChange
.
const checkFileChanged = async (
filePath: string,
onChange: (event: FileChangeEvent) => void
) => {
let kind = FileChangeKind.change;
const prevHash = index.get(filePath);
const hash = await hashFile(filePath);
if (hash) {
index.set(filePath, hash);
if (!prevHash) {
kind = FileChangeKind.add;
}
} else {
index.delete(filePath);
kind = FileChangeKind.remove;
}
if (prevHash !== hash) {
console.info(`
change ${kind}: ${filePath}`);
onChange({ filePath, kind, hash, prevHash });
}
};
Производительность
Время запуска dev‑сервера чуть возрастает из‑за первичной индексации, но следующие итерации минимальны.
Выбор алгоритма хеширования
- Встроенный
crypto.createHash("sha256")
— достаточно быстр, поддерживается «из коробки». - Для высокой производительности можно попробовать supersha или xxhash. Советую заранее перепроверить их производительность.
Рекомендация: начать со встроенного SHA‑256, при выявлении узких мест в производительности — заменить на более лёгкий алгоритм хеширования. Для лучшей кросс-платформенности в Node.js, завернуть его в WebAssembly модуль и исключить проблемы с установкой и сборкой нативного пакета.
Парсинг против байт
Иногда файл меняется, но результат сборки остаётся прежним. Например, вставили пустую строку в код → результат не изменился. То же самое, когда вызвали авто-форматирование кода. Не «тригерить ребилд», если в коде есть «ошибки», что часто бывает в процессе редактирования исходников.
- Форматируем JavaScript или TypeScript файл (допустим с помощью
prettier
). - Парсим или анализируем абстрактное синтаксическое дерево.
- Если в парсинге нет ошибок — вычисляем хеш.
- Если в файле есть ошибки, система приостанавливает отслеживание до их исправления. Это помогает избежать сборки «битого» кода.
Будем ждать до тех пор, пока «клавиатурный ниндзя» не закончит редактирование кода.
try {
const formatted = prettier.format(content);
compileSourceFile(formatted);
errors.delete(filePath);
const hash = crypto.createHash("sha256").update(formatted).digest("hex");
// ... далее по сценарию
} catch (err) {
// В файле ошибки или невалидный синтаксис, приостанавливаем живой просмотр
errors.set(filePath, err);
}
Плюсы:
- Реагируем только на рабочие правки.
- Избегаем сборки поломанного кода.
Минусы:
- Форматирование и парсинг требуют времени.
- Дополнительная нагрузка на CPU.
Баланс между скоростью и точностью
- Первичный debounce (задержка выполнения функции) хорошо отбрасывает быстрые дублирующие события. Можно уменьшить интервал, чтобы повысить отзывчивость.
- Хеширование отсекает кучу ненужных событий, в том числе сохранения неизменённого файла, а так же кратковременные временные изменения, когда контент, по сути, не изменился.
- Парсинг/форматирование нужен лишь для критичных модулей (например, ядро SSG), а для остальных файлов можно ограничиться только хешированием.
- Грамотная организация файловой структуры поможет не копировать тысячи файлов каждую сборку. Пример: вы можете раздавать папки
build/
иpublic/
через dev-сервер, для финальной сборки копировать всё вdist/
. Пропускать большие файлы, в которые, как правило, не вносят изменения руками!
Итоги
Индексация: хешируем файлы на старте.
Фильтрация событий: игнорируем неизмененные файлы.
Расширенная логика: парсинг и валидация для критичных файлов.
Такой подход сокращает лишние итерации, сохраняет мощности компьютера, и позволяет сосредоточиться на действительно важных изменениях.
Интересно было бы узнать, сталкивались ли вы с похожими проблемами? Как выглядит ваша система отслеживания файлов в режиме реального времени? Поделитесь результатами!
Ваше мнение важно!
Какие алгоритмы хеширования использовали? Для каких задач?
Сталкивались ли с подобными «ложными» изменениями в своих проектах?
Что бы мне добавить или уточнить в этой статье?
Пишите в Telegram канал (пока я не сделал комментарии на сайта), и не забудьте подписаться в социальных сетях, чтобы не пропустить новые материалы!