Hot Module Replacement на нативных ES-модулях


Представьте, что вы начинаете разрабатывать JS-приложение. При разработке вы используете локальный сервер, запускаете проект, далее собирается JS-бандл и приложение открывается в браузере. В вашем проекте пока что немного модулей, и приложение «собирается» быстро. Правда в процессе разработки вам приходится полностью обновлять страницу, чтобы увидеть, как работают ваши написанные скрипты, и состояние приложения теряется при перезагрузке. Со временем также модулей в приложении становится много и полная «пересборка» теперь длится долго.

Решение этой проблемы — инкрементальная пересборка. То есть билд только тех модулей, которые вы только что изменили, вместо пересборки бандла всего приложения целиком.

Ок, сделали. Перебилд теперь проиcходит быстрее, но страницу всё ещё приходится полностью перезагружать для обновления. Теперь задача в том, чтобы точечно обновить загруженный код в вашем браузере с запущенным сервером. Как это сделать «на горячую», без перезагрузки всей страницы целиком? Для этого придуман подход Hot Module Replacement (HMR).

HMR — это способ точечного обновления в браузере только изменившихся JS-модулей приложения вместо полной перезагрузки страницы. Дальше я рассмотрю, как HMR реализован в сборщиках на нативных ES-модулях — например, в Snowpack или Vite.

В HMR-движке на нативных ES-модулях есть две части: серверная и клиентская.

На сервере из всех модулей и их зависимостей (import и export) строится дерево: в корне — «главный» модуль (что-то типа app.js), от него расходится древовидная цепочка подключаемых JS-модулей, от которых модуль зависит.

Также на сервере стороне подготавливаются все файлы модулей и запускается WebSocket Server, который будет ждать сообщения от клиента, с запросом на пересборку дерева или на перезагрузку страницы целиком.

Клиент подключается к серверу по WebSocket. Когда изменяется один из файлов JS-модулей, от клиента на сервер отправляется сообщение, какой именно модуль изменился.

Когда сервер получает сообщение от клиента, он начинает анализировать зависимости этого модуля. Сервер идёт вверх по цепочке родительских зависимостей и инвалидирует их до тех пор, пока не дойдёт до «граничного» модуля — это последний элемент в цепочке, который явно помечен как принимающий HMR-обновления. Именно этот файл и все его зависимости сервер «перебилдит», а клиент запросит на обновление. В случае, если этим «граничным» модулем окажется корневой модуль app.js, то приложение перезагрузится целиком.

Так как в движке используется механизм нативных ES-модулей найти «граничный» родительский модуль и заменить его — достаточно. Все дочерние зависимости модуля уже обработаются и загрузятся автоматически самим браузером.

Итоговый найденный «граничный» модуль теперь нужно «подменить», а затем перестроить дерево зависимостей (убрать старые связи и добавить новые), ведь во внесённых изменениях могли быть подключены новые модули.

Как же именно «подменяется» модуль со старого на новый?

В случае, если используются нативные ES-модули, выполняется динамический import интересующего модуля. А если преварительно изменившиеся файлы ещё нужно «сбилдить», то перед импортом ещё выполняется этот этап билда.

Чтобы импортировать свежий файл (не кешированную браузером версию), то к импорту можно добавить уникальную метку, например, текущий timestamp:

const updateID = Date.now();
import(muduleName + `?time=${updateID}`);

Остаётся решить вопрос, как при такой «подмене» передать в новый модуль состояние из старого модуля.

Для этого на колбеке динамического импорта нового модуля нужно забрать интересущие данные из старого модуля и записать данные в новый.

Посмотрим детали реализации.

Всю служебную инфу о модуле будем записывать в специальном объекте import.meta.

Самое первое, что нужно, это пометка, участвует ли модуль в HMR-процессе. Это объект import.meta.hot:

// тут идёт код модуля
let test = 1;
//...
if (import.meta.hot) {
// в этот условии записывается вся начинка HMR
// нужная только для режима разработки
}

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

Внутри import.meta.hot есть несколько методов, которые будут вызываться на этапах «жизни» HMR-модуля и свойств.

import.meta.hot.accept — этот метод, который в старом модуле принимает подменённый новый модуль.

export let value = 1;
import.meta.hot.accept(({ module }) => {
// module – новый импортированный модуль
value = module.value;
});

При замене нужно сменить переменные в экспортирующих частях модуля со старого на новый модуль.

import.meta.hot.dispose — этот метод, который в старом модуле запускается перед подключением нового модуля, чтобы подчистить в старом необходимые штуки (отключить стили, снять обработчики событий).

document.head.appendChild(style);
import.meta.hot.dispose(() => {
document.head.removeChild(style);
});

import.meta.hot.decline — это метод для того, чтобы модуль мог безусловно отклонить HMR-обновление. Этот метод триггерит полную перезагрузку страницы. Он нужнен, например, в случае, если модуль вносит в состояние приложения какие-то непоправимые сайд-эффекты.

// HMR безусловно отклоняется
import.meta.hot.decline();

import.meta.hot.invalidate — это метод для того, чтобы по условию пометить модуль как нуждающийся в обновлении и стриггерить перезагрузку страницы.

// Если something, то модуль инвалидируется
import.meta.hot.accept(({ module }) => {
if (something) {
import.meta.hot.invalidate();
}
});

import.meta.hot.data — «буфер» для передачи данных между обновлением модулей (к нему можно обращаться между dispose() и accept()).

export let value = 1;
if (import.meta.hot) {
// Приём данных от прошлого dispose
import.meta.hot.accept(({ module }) => {
value = import.meta.hot.data.value || module.value;
});
// Отправка данных будущему accept
import.meta.hot.dispose(() => {
import.meta.hot.data = { value };
});
}

Важное дополнение про ES-модули. Так как ES-модули не поддерживают неотносительные пути при подключении — import {init} from 'module', то на первом этапе «сборки» нужно для дев-режима автоматически пройтись по всем файлам и заменить пути импортов на относительные import {init} from '/node_modules/module'.