Hot Module Replacement (HMR) в Webpack реализуется через встроенный
API module.hot, который становится доступным в каждом
модуле при включённой поддержке HMR в конфигурации сборщика и при
использовании dev-server или middleware, поддерживающих протокол
обновлений. Ручное написание HMR-обработчиков требует понимания
жизненного цикла модуля, механизма замены кода и стратегии сохранения
состояния между обновлениями.
Каждый модуль в HMR-среде может быть:
Основные методы API:
module.hot.accept(deps, callback)module.hot.dispose(callback)module.hot.invalidate()При обновлении Webpack сравнивает граф модулей, определяет изменённые части и пытается “вставить” новые версии модулей без полной перезагрузки страницы.
Ручная интеграция начинается с проверки доступности 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 вызывается перед заменой модуля. Он позволяет
сохранить данные в объект 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;
}
Таким образом достигается перенос состояния между версиями модуля.
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();
});
}
Такой подход позволяет локализовать обновления без полной перезагрузки логики.
Если модуль не вызывает accept, обновление поднимается
выше по дереву зависимостей. В конечном итоге, если ни один модуль не
принял обновление, происходит полная перезагрузка страницы.
Это поведение используется как механизм fallback. Ручная настройка позволяет контролировать границы перезагрузки:
Критически важная часть HMR — удаление старых обработчиков событий, таймеров и подписок.
Без очистки возникают дублирования логики:
let intervalId;
function start() {
intervalId = setInterval(() => {
console.log('tick');
}, 1000);
}
start();
if (module.hot) {
module.hot.dispose(() => {
clearInterval(intervalId);
});
}
При обновлении модуля старый интервал обязательно должен быть остановлен, иначе создаются параллельные процессы.
При работе с 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();
});
}
Этот механизм применяется в случаях:
HMR-обновления могут завершаться ошибками компиляции или runtime-ошибками. В ручной обработке важно предусмотреть fallback:
if (module.hot) {
module.hot.accept((err) => {
console.error('HMR error:', err);
location.reload();
});
}
Хотя такой подход сводит на нет преимущества HMR, он предотвращает зависание приложения в неконсистентном состоянии.
При построении системы с ручными обработчиками важно разделять уровни ответственности:
Такое разделение позволяет локализовать HMR-логику и уменьшить
количество accept в коде.
Часто встречающиеся проблемы:
module.hot.datamodule.hot.accept() на
верхнем уровне, что скрывает реальные зависимостиПри изменении глубоко вложенного модуля Webpack пересобирает цепочку
зависимостей и проверяет каждый уровень на наличие accept.
Если обработчик есть только на верхнем уровне, обновление проходит через
весь граф и инициирует повторную инициализацию приложения.
Ручная стратегия часто заключается в том, чтобы “поднимать” accept ближе к изменяемым модулям, минимизируя область пересборки логики.
В приложениях с компонентной архитектурой HMR-обработчики обычно размещаются:
Это позволяет сохранять стабильность UI при точечных изменениях кода и минимизировать перезапуск логики приложения.