Продолжаем эволюцию 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 когда в файле произошли реальные изменения, для новых или удаленных.

  1. Запускается «вотчер» на все директории. Событие от 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();
      }
    }
  );
}
  1. Планировщик использует debounce функцию (задержка выполнения) для отсечки повторяющихся изменений. Если в течение 60 мс обновлений нет — накопленная очередь проверяется.
const submitWatchEvents = debounce(() => {
  // список кандидатов
  const files = [...changedFiles];
  // очищаем список для будущих обновлений
  changedFiles.clear();
  // проверяем каждый потенциально измененный файл
  for (const file of files) {
    void checkFileChanged(file, onChange);
  }
}, 60);
  1. Для каждого кандидата вычисляется новый 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‑сервера чуть возрастает из‑за первичной индексации, но следующие итерации минимальны.

⚡️ Выбор алгоритма хеширования

Рекомендация: начать со встроенного SHA‑256, при выявлении узких мест в производительности — заменить на более лёгкий алгоритм хеширования. Для лучшей кросс-платформенности в Node.js, завернуть его в WebAssembly модуль и исключить проблемы с установкой и сборкой нативного пакета.

🛠️ Парсинг против байт

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

  1. Форматируем JavaScript или TypeScript файл (допустим с помощью prettier).
  2. Парсим или анализируем абстрактное синтаксическое дерево.
  3. Если в парсинге нет ошибок — вычисляем хеш.
  4. Если в файле есть ошибки, система приостанавливает отслеживание до их исправления. Это помогает избежать сборки «битого» кода. 🛑 Будем ждать до тех пор, пока «клавиатурный ниндзя» не закончит редактирование кода.
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);
}

⚖️ Баланс между скоростью и точностью

🏁 Итоги

  1. 🔍 Индексация: хешируем файлы на старте.
  2. 🛑 Фильтрация событий: игнорируем неизмененные файлы.
  3. 🧠 Расширенная логика: парсинг и валидация для критичных файлов.

🚀 Такой подход сокращает лишние итерации, сохраняет мощности компьютера, и позволяет сосредоточиться на действительно важных изменениях.

🔎 Интересно было бы узнать, сталкивались ли вы с похожими проблемами? Как выглядит ваша система отслеживания файлов в режиме реального времени? Поделитесь результатами!


❤️ Ваше мнение важно!

✨ Пишите в Telegram канал (пока я не сделал комментарии на сайта), и не забудьте подписаться в социальных сетях, чтобы не пропустить новые материалы!