Настройка HMR для CSS, JavaScript, React, Vue

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

При обычной перезагрузке браузер:

  1. Полностью обновляет страницу.
  2. Повторно загружает все ресурсы.
  3. Сбрасывает состояние приложения.
  4. Повторно выполняет JavaScript-код.

HMR работает иначе:

  1. Webpack отслеживает изменения файлов.
  2. Пересобирает только изменённые модули.
  3. Отправляет обновление через WebSocket.
  4. В рантайме заменяет модуль без перезагрузки страницы.

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

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

Базовая настройка HMR

Для работы HMR требуется:

  • webpack
  • webpack-cli
  • webpack-dev-server

Установка:

npm install webpack webpack-cli webpack-dev-server --save-dev

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

const path = require('path');

module.exports = {
    mode: 'development',

    entry: './src/index.js',

    output: {
        filename: 'bundle.js',
        path: path.resolve(__dirname, 'dist'),
        clean: true
    },

    devServer: {
        static: path.resolve(__dirname, 'dist'),
        hot: true,
        open: true
    }
};

Ключевой параметр:

hot: true

Он включает поддержку Hot Module Replacement в webpack-dev-server.


Включение HMR через CLI

HMR можно активировать напрямую из командной строки:

npx webpack serve --hot

Либо через package.json:

{
    "scripts": {
        "dev": "webpack serve --hot"
    }
}

Работа HMR внутри Webpack

Во время запуска dev-server создаются:

  • WebSocket-соединение между браузером и сервером;
  • HMR runtime;
  • карта зависимостей модулей.

После изменения файла:

  1. Webpack определяет затронутые модули.
  2. Создаёт update bundle.
  3. Отправляет информацию клиенту.
  4. Runtime пытается заменить модуль.
  5. Если обновление невозможно — выполняется full reload.

Ручное подключение HMR API

Webpack предоставляет API:

module.hot

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

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

Обработка обновления модуля:

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

HMR для JavaScript

Простая структура проекта

src/
    index.js
    message.js

message.js

export function message() {
    console.log('Hello');
}

index.js

import { message } from './message';

message();

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

        const updated = require('./message');
        updated.message();
    });
}

Теперь изменение message.js не вызывает полную перезагрузку страницы.


Почему HMR не всегда работает автоматически

Webpack способен заменить модуль только если:

  • модуль допускает обновление;
  • существует цепочка accept;
  • обновление не ломает граф зависимостей.

Если один из модулей не поддерживает HMR:

module A -> module B -> module C

и module C не может быть безопасно заменён, Webpack инициирует:

Full Reload

module.hot.accept

Обновление конкретного модуля

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

Обновление нескольких модулей

module.hot.accept([
    './a.js',
    './b.js'
], () => {
    console.log('modules updated');
});

Самопринятие обновлений

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

module.hot.dispose

Метод используется перед удалением старого модуля.

Пример:

let timer = setInterval(() => {
    console.log('tick');
}, 1000);

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

Без очистки возможно:

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

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

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

Пример:

let counter = 0;

export function increment() {
    counter++;
    console.log(counter);
}

После обновления:

counter = 0

Webpack предоставляет механизм хранения данных между обновлениями.


module.hot.data

Сохранение состояния:

let state = module.hot?.data?.state || {
    counter: 0
};

export function increment() {
    state.counter++;
    console.log(state.counter);
}

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

HMR для CSS

Автоматическая поддержка CSS

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

  • style-loader
  • css-loader

HMR для CSS работает автоматически.

Установка:

npm install style-loader css-loader --save-dev

Конфигурация:

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

Почему HMR CSS работает без перезагрузки

style-loader:

  1. создаёт <style>;
  2. внедряет CSS в DOM;
  3. заменяет стили при обновлении.

Без HMR:

reload page

С HMR:

replace style tag

CSS Modules и HMR

Конфигурация:

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

HMR корректно обновляет:

  • локальные классы;
  • хэши;
  • CSS-модули.

MiniCssExtractPlugin и HMR

В production часто используется:

MiniCssExtractPlugin

Но в development лучше использовать:

style-loader

Причина:

  • MiniCssExtractPlugin извлекает CSS в файлы;
  • обновление становится сложнее;
  • HMR работает менее эффективно.

Типичная схема:

const isDev = process.env.NODE_ENV === 'development';

{
    test: /\.css$/,
    use: [
        isDev
            ? 'style-loader'
            : MiniCssExtractPlugin.loader,

        'css-loader'
    ]
}

HMR для SCSS и SASS

Установка:

npm install sass sass-loader --save-dev

Конфигурация:

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

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


HMR для PostCSS

Конфигурация:

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

Изменения:

  • autoprefixer;
  • nesting;
  • postcss-плагины

обновляются без reload.


HMR для React

React Refresh

Старый механизм:

react-hot-loader

современный подход:

React Refresh

Установка React Refresh

Пакеты:

npm install react-refresh @pmmmwh/react-refresh-webpack-plugin --save-dev

Babel-конфигурация

npm install babel-loader @babel/core @babel/preset-react --save-dev

babel.config.js

module.exports = {
    presets: [
        '@babel/preset-react'
    ],
    plugins: [
        require.resolve('react-refresh/babel')
    ]
};

Webpack-конфигурация React Refresh

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

module.exports = {
    mode: 'development',

    devServer: {
        hot: true
    },

    module: {
        rules: [
            {
                test: /\.jsx?$/,
                exclude: /node_modules/,
                use: 'babel-loader'
            }
        ]
    },

    plugins: [
        new ReactRefreshWebpackPlugin()
    ]
};

Особенности React HMR

React Refresh умеет:

  • обновлять компоненты;
  • сохранять hooks state;
  • обновлять JSX;
  • обновлять стили;
  • пересоздавать component tree.

Сохранение состояния hooks

Без React Refresh:

reload -> reset useState

С React Refresh:

update component -> preserve state

Пример:

function Counter() {
    const [count, setCount] = useState(0);

    return (
        <button onCl ick={() => setCount(count + 1)}>
            {count}
        </button>
    );
}

Изменение JSX не сбросит состояние.


Ограничения React Refresh

Состояние может потеряться если:

  • изменился порядок hooks;
  • компонент перестал быть function component;
  • изменён экспорт;
  • изменилась сигнатура hooks.

Пример проблемного изменения:

if (condition) {
    useEffect(() => {});
}

React Fast Refresh Runtime

Внутри runtime происходит:

  1. регистрация компонентов;
  2. сравнение сигнатур;
  3. безопасное обновление;
  4. восстановление state.

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

forced remount

HMR для Vue

Vue Loader и HMR

Vue поддерживает HMR через:

  • vue-loader
  • webpack-dev-server

Установка:

npm install vue-loader vue-template-compiler --save-dev

Для Vue 3:

npm install vue-loader @vue/compiler-sfc --save-dev

Конфигурация Vue

const { VueLoaderPlugin } = require('vue-loader');

module.exports = {
    module: {
        rules: [
            {
                test: /\.vue$/,
                loader: 'vue-loader'
            }
        ]
    },

    plugins: [
        new VueLoaderPlugin()
    ],

    devServer: {
        hot: true
    }
};

HMR внутри Vue-компонентов

Vue умеет обновлять:

  • template;
  • script;
  • style.

Пример:

<template>
    <div>{{ count }}</div>
</template>

<script>
export default {
    data() {
        return {
            count: 0
        };
    }
};
</script>

<style scoped>
div {
    color: red;
}
</style>

Обновление template

Изменение:

<div>{{ count }}</div>

обновляет:

  • virtual DOM;
  • render function.

Состояние компонента сохраняется.


Обновление style

Vue заменяет стили без перезагрузки страницы.

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

  • scoped styles;
  • CSS modules;
  • SCSS;
  • PostCSS.

Обновление script

При изменении <script> Vue:

  1. уничтожает старый экземпляр;
  2. создаёт новый;
  3. повторно рендерит компонент.

В некоторых случаях локальное состояние теряется.


HMR и Source Maps

Для корректной отладки обычно используется:

devtool: 'eval-source-map'

или:

devtool: 'cheap-module-source-map'

HMR без source maps работает, но отладка становится неудобной.


devServer.hot

Значения параметра

true

devServer: {
    hot: true
}

Включает HMR.


false

devServer: {
    hot: false
}

Только full reload.


only

devServer: {
    hot: 'only'
}

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

не делать reload

Полезно для диагностики проблем HMR.


Live Reload и HMR

Это разные механизмы.

Live Reload

change -> reload page

HMR

change -> replace module

Совместное использование

devServer: {
    hot: true,
    liveReload: true
}

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

fallback to reload

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

HMR не работает

Причины:

  • mode: production;
  • отсутствует hot: true;
  • неправильный loader;
  • конфликт плагинов;
  • использование extract CSS;
  • отсутствие module.hot.accept.

Полная перезагрузка вместо HMR

Причины:

  • модуль не принимает обновление;
  • runtime error;
  • broken dependency graph;
  • обновление entry point.

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

Проблема:

button.addEventListener(...)

после HMR выполняется повторно.

Решение:

module.hot.dispose(() => {
    cleanup();
});

Утечки памяти

Причины:

  • таймеры;
  • WebSocket;
  • DOM listeners;
  • сторонние библиотеки.

Пример очистки:

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

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

Использование filesystem cache

Webpack 5:

cache: {
    type: 'filesystem'
}

Ускоряет повторные пересборки.


Исключение node_modules

{
    test: /\.js$/,
    exclude: /node_modules/
}

Lazy Compilation

Webpack 5 поддерживает:

experiments: {
    lazyCompilation: true
}

Модули собираются по требованию.


Оптимальный devtool

Для HMR часто используют:

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

Преимущества:

  • быстрые rebuild;
  • хорошие source maps;
  • минимальная нагрузка.

Архитектурные особенности HMR

Граница обновления

HMR наиболее эффективен при компонентной архитектуре.

Плохой пример:

huge global module

Хороший пример:

small isolated modules

Side Effects

Побочные эффекты усложняют HMR.

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

window.globalData = {};
startServer();
connectSocket();

Лучше:

export function init() {}
export function destroy() {}

Чистые модули

HMR проще работает с:

  • pure functions;
  • isolated state;
  • predictable lifecycle.

HMR и TypeScript

Конфигурация:

npm install ts-loader typescript --save-dev
{
    test: /\.tsx?$/,
    loader: 'ts-loader',
    exclude: /node_modules/
}

HMR работает аналогично JavaScript.


React + TypeScript + HMR

{
    test: /\.(ts|tsx)$/,
    use: 'babel-loader'
}

Совместно с:

react-refresh

получается полноценный Fast Refresh.


Полная конфигурация React HMR

const path = require('path');

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

module.exports = {
    mode: 'development',

    entry: './src/index.jsx',

    output: {
        path: path.resolve(__dirname, 'dist'),
        filename: 'bundle.js'
    },

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

    cache: {
        type: 'filesystem'
    },

    module: {
        rules: [
            {
                test: /\.jsx?$/,
                exclude: /node_modules/,
                use: {
                    loader: 'babel-loader'
                }
            },

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

    resolve: {
        extensions: ['.js', '.jsx']
    },

    plugins: [
        new ReactRefreshWebpackPlugin()
    ],

    devServer: {
        static: path.resolve(__dirname, 'dist'),
        hot: true,
        open: true,
        historyApiFallback: true
    }
};