Написание HMR-обработчика вручную

Hot Module Replacement (HMR) в Webpack реализуется через встроенный API module.hot, который становится доступным в каждом модуле при включённой поддержке HMR в конфигурации сборщика и при использовании dev-server или middleware, поддерживающих протокол обновлений. Ручное написание HMR-обработчиков требует понимания жизненного цикла модуля, механизма замены кода и стратегии сохранения состояния между обновлениями.

Каждый модуль в HMR-среде может быть:

  • принимающим обновления (accepting module)
  • отклоняющим обновления (declining module)
  • или пассивным, передающим обновление вверх по дереву зависимостей

Основные методы API:

  • module.hot.accept(deps, callback)
  • module.hot.dispose(callback)
  • module.hot.invalidate()

При обновлении Webpack сравнивает граф модулей, определяет изменённые части и пытается “вставить” новые версии модулей без полной перезагрузки страницы.

Минимальный HMR-обработчик

Ручная интеграция начинается с проверки доступности API:

if (module.hot) {
  module.hot.accept();
}

Такой код говорит Webpack, что модуль готов принять обновление без перезагрузки. Однако в этом случае не происходит управления состоянием и обновлением UI, поэтому применение ограничено.

Явное принятие зависимостей

HMR становится полезным, когда обновляются зависимости модуля, например компонент или утилита. В этом случае указывается конкретный модуль:

import { render } from './render';

function bootstrap() {
  render();
}

bootstrap();

if (module.hot) {
  module.hot.accept('./render', () => {
    bootstrap();
  });
}

При изменении render.js Webpack заменяет модуль, вызывает callback и позволяет повторно выполнить логику инициализации.

Проблема состояния и его сохранение

Перезапуск логики приводит к потере состояния. В реальных приложениях требуется сохранить данные между обновлениями.

Пример: простой счётчик в DOM.

let count = 0;

function render() {
  document.body.innerHTML = `
    <div>
      <p>${count}</p>
      <button id="inc">+</button>
    </div>
  `;

  document.getElementById('inc').oncl ick = () => {
    count++;
    render();
  };
}

render();

Без HMR любое изменение файла сбрасывает count. Для сохранения состояния используется module.hot.dispose.

Сохранение состояния через dispose

dispose вызывается перед заменой модуля. Он позволяет сохранить данные в объект module.hot.data.

if (module.hot) {
  module.hot.dispose((data) => {
    data.state = count;
  });
}

При следующей загрузке обновлённого модуля это состояние доступно через module.hot.data.

if (module.hot && module.hot.data) {
  count = module.hot.data.state;
}

Таким образом достигается перенос состояния между версиями модуля.

Полный пример ручного HMR с сохранением состояния

let count = 0;

if (module.hot && module.hot.data) {
  count = module.hot.data.count;
}

function render() {
  document.body.innerHTML = `
    <div>
      <p>${count}</p>
      <button id="inc">+</button>
    </div>
  `;

  document.getElementById('inc').oncl ick = () => {
    count++;
    render();
  };
}

render();

if (module.hot) {
  module.hot.accept();

  module.hot.dispose((data) => {
    data.count = count;
  });
}

В этом варианте состояние сохраняется при каждом обновлении модуля, а интерфейс пересоздаётся с актуальными данными.

Гранулярное принятие зависимостей

В сложных приложениях важно принимать обновления только для конкретных частей системы. Например, если модуль view отвечает за UI, а state — за бизнес-логику, обновление должно быть избирательным.

import { createView } from './view';
import { store } from './store';

function init() {
  createView(store);
}

init();

if (module.hot) {
  module.hot.accept('./view', () => {
    init();
  });

  module.hot.accept('./store', () => {
    init();
  });
}

Такой подход позволяет локализовать обновления без полной перезагрузки логики.

Bubbling обновлений вверх по графу модулей

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

Это поведение используется как механизм fallback. Ручная настройка позволяет контролировать границы перезагрузки:

  • низкий уровень (UI-компоненты) — принимают обновления
  • средний уровень (контейнеры) — частично принимают
  • верхний уровень (bootstrap) — часто перезагружается полностью

Очистка побочных эффектов

Критически важная часть HMR — удаление старых обработчиков событий, таймеров и подписок.

Без очистки возникают дублирования логики:

let intervalId;

function start() {
  intervalId = setInterval(() => {
    console.log('tick');
  }, 1000);
}

start();

if (module.hot) {
  module.hot.dispose(() => {
    clearInterval(intervalId);
  });
}

При обновлении модуля старый интервал обязательно должен быть остановлен, иначе создаются параллельные процессы.

Интеграция с DOM и повторная инициализация

При работе с DOM часто проще полностью пересоздать интерфейс, чем пытаться патчить отдельные узлы:

function mount() {
  const root = document.getElementById('app');
  root.innerHTML = '';

  const button = document.createElement('button');
  button.textContent = 'Click';
  root.appendChild(button);
}

mount();

if (module.hot) {
  module.hot.accept('./ui', () => {
    mount();
  });
}

Такой подход уменьшает сложность HMR-логики и снижает риск утечек состояния DOM.

Инвалидация модуля вручную

module.hot.invalidate() используется, когда модуль не может корректно обработать обновление и требует полной перезагрузки графа зависимостей.

if (module.hot) {
  module.hot.accept((err) => {
    module.hot.invalidate();
  });
}

Этот механизм применяется в случаях:

  • критических ошибок и несовместимых изменений API
  • невозможности восстановить состояние
  • изменения структуры зависимостей

Обработка ошибок в HMR

HMR-обновления могут завершаться ошибками компиляции или runtime-ошибками. В ручной обработке важно предусмотреть fallback:

if (module.hot) {
  module.hot.accept((err) => {
    console.error('HMR error:', err);
    location.reload();
  });
}

Хотя такой подход сводит на нет преимущества HMR, он предотвращает зависание приложения в неконсистентном состоянии.

Архитектурный подход к ручному HMR

При построении системы с ручными обработчиками важно разделять уровни ответственности:

  • модуль состояния (store) — хранит данные, принимает частичные обновления
  • модуль представления (view) — полностью пересоздаётся при изменениях
  • модуль инициализации — управляет точкой входа и глобальными подписками

Такое разделение позволяет локализовать HMR-логику и уменьшить количество accept в коде.

Типовые ошибки при ручной реализации

Часто встречающиеся проблемы:

  • отсутствие очистки side effects приводит к дублированию таймеров и слушателей
  • повторная инициализация DOM без удаления старых узлов создаёт утечки памяти
  • сохранение состояния в глобальных переменных без синхронизации с module.hot.data
  • слишком широкое использование module.hot.accept() на верхнем уровне, что скрывает реальные зависимости

Поведение при цепочке обновлений

При изменении глубоко вложенного модуля Webpack пересобирает цепочку зависимостей и проверяет каждый уровень на наличие accept. Если обработчик есть только на верхнем уровне, обновление проходит через весь граф и инициирует повторную инициализацию приложения.

Ручная стратегия часто заключается в том, чтобы “поднимать” accept ближе к изменяемым модулям, минимизируя область пересборки логики.

Комбинирование HMR с архитектурными паттернами

В приложениях с компонентной архитектурой HMR-обработчики обычно размещаются:

  • внутри компонентов (локальный accept)
  • в контейнерах (пересборка subtree)
  • в store (частичное обновление состояния без пересоздания)

Это позволяет сохранять стабильность UI при точечных изменениях кода и минимизировать перезапуск логики приложения.