Продолжаем эволюцию 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 });
  }
};
 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 канал (пока я не сделал комментарии на сайта), и не забудьте подписаться в социальных сетях, чтобы не пропустить новые материалы!