Hot Module Replacement (HMR): принцип работы

Hot Module Replacement (HMR) — механизм динамической замены модулей во время работы приложения без полной перезагрузки страницы. Технология позволяет обновлять изменённые части кода в уже запущенном приложении, сохраняя текущее состояние интерфейса, данных и окружения.

В экосистеме Webpack HMR применяется прежде всего в процессе разработки. После изменения файла браузер не выполняет полноценный reload страницы, а получает только обновлённый модуль и внедряет его в текущий runtime.

Основные преимущества:

  • ускорение разработки;
  • сохранение состояния приложения;
  • мгновенное обновление интерфейса;
  • снижение количества повторных инициализаций;
  • уменьшение нагрузки на браузер.

Без HMR любое изменение требует полной перезагрузки страницы:

Изменение файла → новая сборка → reload страницы

С HMR процесс выглядит иначе:

Изменение файла → пересборка модуля → передача update → замена модуля в runtime

Архитектура HMR

HMR состоит из нескольких компонентов:

  1. Webpack Compiler
  2. HMR Runtime
  3. Dev Server
  4. WebSocket-соединение
  5. Механизм обновления модулей

Compiler

Webpack отслеживает изменения файлов через watcher файловой системы. После изменения запускается инкрементальная пересборка.

Compiler определяет:

  • какие модули изменились;
  • какие chunk-файлы необходимо обновить;
  • какие зависимости затронуты.

Результатом становятся специальные update-файлы.


HMR Runtime

Runtime внедряется в bundle автоматически.

Он отвечает за:

  • получение информации об обновлениях;
  • загрузку hot-update-файлов;
  • замену модулей;
  • вызов accept/dispose-обработчиков;
  • откат при ошибках.

Dev Server

Наиболее часто HMR используется вместе с:

  • webpack-dev-server
  • webpack-hot-middleware
  • webpack-plugin-serve

Dev Server:

  • обслуживает bundle из памяти;
  • отслеживает изменения;
  • уведомляет браузер о новых update;
  • передаёт hot-update-файлы.

WebSocket-соединение

Между браузером и dev server создаётся постоянное соединение через WebSocket.

Сервер отправляет событие:

{
  "type": "hash",
  "data": "newCompilationHash"
}

После этого runtime понимает, что появилась новая версия сборки.


Принцип работы HMR

Полный цикл HMR выглядит следующим образом.

1. Изменение исходного файла

Например:

// math.js
export function sum(a, b) {
    return a + b;
}

Разработчик изменяет код:

export function sum(a, b) {
    return Number(a) + Number(b);
}

2. Пересборка изменённых модулей

Webpack запускает частичную пересборку.

Компилятор определяет:

  • изменённый модуль;
  • связанные chunk;
  • зависимости;
  • новый hash сборки.

3. Генерация update-файлов

Webpack создаёт специальные файлы:

main.3f5c.hot-update.js
main.3f5c.hot-update.json

JSON-файл содержит список обновлённых chunk.

JS-файл содержит новый код модулей.


4. Уведомление браузера

Dev server отправляет уведомление через WebSocket:

[HMR] bundle updated

Runtime получает новый hash и начинает проверку обновлений.


5. Загрузка hot-update

Runtime загружает:

*.hot-update.json
*.hot-update.js

После этого начинается применение обновления.


6. Замена модуля

Webpack заменяет старый модуль новым в runtime-модульном графе.

Старый код удаляется:

modules[moduleId] = newModuleFactory;

После этого модуль может быть переисполнен.


HMR Runtime API

Главный API доступен через объект:

module.hot

Этот объект существует только при включённом HMR.


Проверка поддержки HMR

if (module.hot) {
    console.log('HMR enabled');
}

Метод accept

Метод accept сообщает Webpack, что модуль умеет принимать hot-update.

Пример:

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

Без accept Webpack обычно выполняет полную перезагрузку страницы.


Accept конкретной зависимости

if (module.hot) {
    module.hot.accept('./math.js', () => {
        console.log('math module updated');
    });
}

После изменения math.js callback будет вызван повторно.


Повторный импорт обновлённого модуля

import { sum } from './math';

console.log(sum(2, 3));

if (module.hot) {
    module.hot.accept('./math', () => {
        const updated = require('./math');

        console.log(updated.sum(10, 20));
    });
}

Метод dispose

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

Пример:

if (module.hot) {
    module.hot.dispose(() => {
        console.log('cleanup');
    });
}

Dispose особенно важен для:

  • таймеров;
  • WebSocket;
  • DOM listener;
  • сторонних библиотек;
  • подписок.

Сохранение состояния между обновлениями

Dispose может сохранять данные:

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

    if (module.hot.data) {
        counter = module.hot.data.counter;
    }
}

module.hot.data содержит данные предыдущей версии модуля.


Самопринимающие модули

Self-accepted modules обновляют сами себя.

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

Такие модули не требуют участия родительских модулей.


HMR Boundary

Граница обновления — модуль, который принимает изменения через accept.

Например:

App
 ├── Header
 ├── Sidebar
 └── Button

Если изменяется Button, Webpack ищет ближайший accept-handler.

Если Button не принимает обновление:

  • проверяется Sidebar;
  • затем App;
  • затем выполняется reload страницы.

Bubble-механизм

HMR использует всплытие обновлений вверх по графу зависимостей.

child → parent → parent → root

Если никто не принимает обновление:

[HMR] Update declined
[HMR] Reloading page

Настройка HMR в Webpack

Минимальная конфигурация:

const webpack = require('webpack');

module.exports = {
    mode: 'development',

    devServer: {
        hot: true
    },

    plugins: [
        new webpack.HotModuleReplacementPlugin()
    ]
};

В Webpack 5 webpack-dev-server часто автоматически включает plugin.


webpack-dev-server и HMR

При использовании:

devServer: {
    hot: true
}

Webpack:

  • запускает WebSocket server;
  • внедряет runtime;
  • генерирует hot-update;
  • активирует live update.

Отличие HMR от Live Reload

Live Reload

Изменение → reload страницы

Недостатки:

  • теряется состояние;
  • перезапускается приложение;
  • повторный рендер.

HMR

Изменение → замена модуля

Сохраняются:

  • state;
  • DOM;
  • runtime;
  • активные соединения.

HMR и CSS

CSS — один из лучших сценариев для HMR.

Пример:

import './styles.css';

После изменения CSS:

  • новый стиль внедряется в <style>;
  • старый удаляется;
  • reload страницы не требуется.

style-loader и HMR

style-loader автоматически поддерживает HMR.

module: {
    rules: [
        {
            test: /\.css$/,
            use: [
                'style-loader',
                'css-loader'
            ]
        }
    ]
}

MiniCssExtractPlugin и HMR

С извлечением CSS ситуация сложнее.

В production CSS выносится в отдельные файлы:

MiniCssExtractPlugin.loader

HMR для extracted CSS работает ограниченно и зависит от loader/runtime.


HMR в React

React активно использует HMR.

Современный стандарт:

React Fast Refresh

Fast Refresh:

  • сохраняет state компонентов;
  • обновляет JSX;
  • перерендеривает дерево компонентов;
  • не перезагружает страницу.

React Refresh Plugin

Пример настройки:

const ReactRefreshWebpackPlugin =
    require('@pmmmwh/react-refresh-webpack-plugin');

module.exports = {
    mode: 'development',

    devServer: {
        hot: true
    },

    plugins: [
        new ReactRefreshWebpackPlugin()
    ]
};

Почему React Fast Refresh лучше обычного HMR

Обычный HMR обновляет JS-модули.

Fast Refresh дополнительно:

  • отслеживает React-компоненты;
  • анализирует hooks;
  • сохраняет component state;
  • предотвращает full reload.

HMR во Vue

Во Vue механизм встроен глубоко в runtime.

Поддерживаются:

  • template update;
  • style update;
  • component replacement.

При изменении template:

<template>
    <button>Save</button>
</template>

Vue обновляет render function без reload.


HMR в Svelte

Svelte HMR работает через специальные адаптеры:

  • svelte-hmr;
  • vite-plugin-svelte;
  • webpack loaders.

Особенность:

  • пересоздание компонентов;
  • попытка сохранить state;
  • обновление runtime DOM.

HMR в Angular

Angular historically хуже интегрирован с HMR.

Часто используются:

  • @angular-devkit
  • кастомные bootstrap-обёртки
  • Angular CLI HMR

Пример:

ng serve --hmr

HMR и состояние приложения

Главная проблема HMR — состояние приложения.

Например:

const state = {
    counter: 0
};

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


State Preservation

Для сохранения состояния используются:

  • React Fast Refresh;
  • Redux store singleton;
  • external state container;
  • module.hot.data.

HMR и Redux

Store обычно выносится отдельно:

export const store = configureStore(...);

Reducer может обновляться динамически:

if (module.hot) {
    module.hot.accept('./reducers', () => {
        store.replaceReducer(newReducer);
    });
}

Ограничения HMR

HMR подходит не для всех сценариев.

Плохо обновляются:

  • глобальные singleton;
  • side-effect-модули;
  • polyfills;
  • bootstrap-код;
  • runtime-конфигурация.

Side Effects и HMR

Опасный пример:

window.addEventListener('resize', handler);

После нескольких hot-update listener может дублироваться.

Правильный вариант:

window.addEventListener('resize', handler);

if (module.hot) {
    module.hot.dispose(() => {
        window.removeEventListener('resize', handler);
    });
}

HMR и memory leaks

Неправильная очистка приводит к:

  • утечкам памяти;
  • дублированию подписок;
  • множественным таймерам;
  • росту DOM-узлов.

HMR и WebSocket

Частая ошибка:

const socket = new WebSocket(url);

После hot-update создаются новые подключения.

Корректная очистка:

if (module.hot) {
    module.hot.dispose(() => {
        socket.close();
    });
}

HMR и setInterval

Проблемный код:

setInterval(update, 1000);

После каждого update появляется новый timer.

Правильный вариант:

const timer = setInterval(update, 1000);

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

HMR и Module Graph

Webpack хранит граф модулей:

entry
 ├── app.js
 │    ├── ui.js
 │    └── api.js

При обновлении:

  1. определяется изменённый модуль;
  2. анализируются parents;
  3. ищется accept boundary;
  4. выполняется patch runtime.

Hot Update Manifest

Файл:

main.hash.hot-update.json

Содержит:

{
  "c": ["main"],
  "h": "newHash"
}

Где:

  • c — обновлённые chunk;
  • h — новый hash.

Hot Update Chunk

Файл:

main.hash.hot-update.js

Содержит новые module factories.

Пример:

self["webpackHotUpdate"]("main", {
    "./src/math.js": function(...) {

    }
});

Runtime Injection

Webpack внедряет runtime примерно такого типа:

function hotCheck() {

}

function hotApply() {

}

Runtime:

  • сравнивает hash;
  • загружает update;
  • выполняет apply.

Статусы HMR

Runtime использует внутренние состояния:

idle
check
prepare
dispose
apply
abort
fail

Получить статус:

module.hot.status();

Подписка на изменения:

module.hot.addStatusHandler(status => {
    console.log(status);
});

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

Ошибка применения update:

[HMR] Update failed

Причины:

  • синтаксическая ошибка;
  • несовместимый runtime;
  • отсутствующий chunk;
  • declined dependency.

Decline Updates

Модуль может отказаться от обновлений:

module.hot.decline('./module');

В таком случае выполняется reload.


invalidate()

Принудительная инвалидизация:

module.hot.invalidate();

Webpack помечает модуль как устаревший и инициирует новый update cycle.


Full Reload Fallback

Если HMR невозможен:

Cannot apply update

Webpack автоматически делает:

window.location.reload()

Lazy Compilation и HMR

Webpack 5 поддерживает lazy compilation.

Модули собираются только при обращении:

experiments: {
    lazyCompilation: true
}

HMR при этом продолжает работать, но логика update становится сложнее.


HMR и Code Splitting

При динамическом импорте:

import('./module');

Webpack создаёт отдельный chunk.

HMR способен обновлять:

  • entry chunk;
  • async chunk;
  • lazy chunk.

HMR и Source Maps

Для корректной отладки HMR важны source maps.

Оптимальные варианты:

devtool: 'eval-source-map'

или:

devtool: 'cheap-module-source-map'

Производительность HMR

Скорость HMR зависит от:

  • размера графа модулей;
  • количества loader;
  • скорости файловой системы;
  • объёма transpilation;
  • cache;
  • числа chunk.

Оптимизация HMR

Наиболее важные техники:

Filesystem Cache

cache: {
    type: 'filesystem'
}

thread-loader

Параллельная обработка:

use: [
    'thread-loader',
    'babel-loader'
]

Исключение node_modules

exclude: /node_modules/

Быстрые source maps

devtool: 'eval-cheap-module-source-map'

HMR и Babel

Babel сам по себе не реализует HMR.

Но он участвует через:

  • React Refresh;
  • plugin-transform-runtime;
  • transpilation pipeline.

HMR и TypeScript

TypeScript поддерживает HMR через обычный JS-runtime.

Пример:

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

Для типизации:

declare const module: {
    hot?: any;
};

HMR и ESM

Webpack поддерживает HMR и для ES Modules.

Однако внутренний runtime всё ещё использует собственную систему модулей.


HMR и Native ES Modules

Нативные ESM браузера не имеют встроенного HMR.

Поэтому bundlers реализуют его самостоятельно:

  • Webpack;
  • Vite;
  • Parcel.

Отличие HMR Webpack и Vite

Webpack

  • работает через bundle;
  • обновляет module graph runtime;
  • требует compilation pipeline.

Vite

  • использует native ESM;
  • обновляет отдельные модули;
  • быстрее на старте.

Типичные проблемы HMR

Дублирование эффектов

socket.connect();

После update:

connect
connect
connect

Потеря состояния

После update state сбрасывается.


Full reload вместо HMR

Причины:

  • отсутствует accept;
  • runtime error;
  • unsupported module.

Зависшие update

Иногда runtime остаётся в состоянии:

prepare

Причины:

  • network failure;
  • broken chunk;
  • corrupted runtime.

Диагностика HMR

Полезные сообщения:

[HMR] Waiting for update signal
[HMR] Checking for updates
[HMR] Updated modules
[HMR] App is up to date

Практический пример полного HMR-цикла

math.js

export function multiply(a, b) {
    return a * b;
}

index.js

import { multiply } from './math';

function render() {
    console.log(multiply(2, 5));
}

render();

if (module.hot) {
    module.hot.accept('./math', () => {
        console.log('math updated');

        render();
    });
}

После изменения math.js:

  1. Webpack пересобирает модуль;
  2. создаёт hot-update;
  3. runtime получает update;
  4. старый модуль заменяется;
  5. вызывается callback;
  6. render выполняется повторно;
  7. страница не перезагружается.