javascript

Перезапрос упавшей статики

  • пятница, 20 февраля 2026 г. в 00:00:06
https://habr.com/ru/articles/1001150/

Современное фронтенд-приложение после сборки - это не один большой JS-файл, а главный entry-point и набор чанков: файлов, которые подгружаются по требованию при динамическом импорте (import(), React.lazy() и т.д.). Пользователь открывает страницу, грузится основной бандл, переходит в раздел - браузер по запросу качает ещё один файл, например chunk-abc123.js.

И вот этот запрос чанка может упасть. Причины бывают разные:

  • Временный обрыв сети или таймаут;

  • Прокси, файрвол, расширения браузера режут или подменяют ответ;

  • Деплой: на сервере уже новая версия, старые чанки удалены, а у пользователя в кэше старый index.html — он просит chunk-old.js, которого уже нет (404);

  • CDN отдал ошибку или устаревший/битый ответ;

  • Субресурсная целостность (SRI): файл подменили — checksum не сошёлся.

В итоге пользователь видит белый экран или «Loading chunk failed» / ChunkLoadError, хотя само приложение и сервер в порядке - просто один запрос статики не прошёл. И тут хочется автоматически повторить загрузку этого же (или того же по смыслу) ресурса, прежде чем показывать ошибку.


Как бы мы сделали это на статическом сайте

На статическом сайте написанном без фреймворков и библиотек мы сами расставляем <script> и <link>. Логика перезапроса могла бы быть такой:

  • Подписываемся на window.onerror или error на конкретных тегах

  • При ошибке загрузки скрипта/стиля - через некоторое время создаём новый тег с тем же src (возможно, с cache-bust query) и вставляем в документ

  • Ограничиваем число попыток и задержку между ними

То есть точка входа одна: мы контролируем, как и когда создаются теги. Перехватить ошибку и повторить запрос. Тобеж нужно написать свою небольшую обвязку в HTML/JS.


Но у нас сборщик (бандлер)

В реальности статику генерирует бандлер Webpack, Vite, Rollup, esbuild, Rsbuild и т.д. Он сам:

  • решает имена и пути чанков;

  • генерирует код, который в рантайме запрашивает эти чанки.

То есть кто и как создаёт запрос на чанк - определяется не нашим разметкой, а рантаймом бандлера, вшитым в сгенерированный JS. Чтобы добавить перезапрос при ошибке, нужно либо:

  • как-то встроить логику retry в этот рантайм (подменить функцию загрузки),

  • или же обрабатывать ошибки на уровне приложения (обёртки над import(), Error Boundary, «перезагрузить страницу»).

Оба пути имеют смысл; возможность первого сильно зависит от того, как устроен бандлер.


Решение «в лоб» (абстрактно)

Идея такая:

  1. Перехватить момент загрузки чанка
    Вместо «запросил один раз → упало → показали ошибку» делаем: запросили → ошибка → ждём (опционально с задержкой) → повторяем запрос (с тем же URL или с cache-bust) → повторяем до N раз.

  2. Cache-bust при повторе
    Чтобы не тащить из кэша тот же битый ответ, к URL при повторных попытках добавляют query, например ?cache-bust=... или &retry=2. Так мы не полагаемся на то, что браузер сам решит не кэшировать ошибку.

  3. Поведение после исчерпания попыток
    Либо пробрасываем ошибку дальше (пользователь видит ошибку/Error Boundary), либо выполняем «последний шанс»: например, редирект на страницу с просьбой обновить или на fallback-страницу.

Чтобы это встроить в рантайм бандлера, нужно, чтобы у сборщика была единая точка, через которую все чанки запрашиваются, и чтобы в неё можно было вклиниться (плагин, патч шаблона и т.п. Отсюда разница между бандлерами.


Разные бандлеры: где как это сделать

Почему «из коробки» сложно в Vite, Rollup и esbuild

Vite (в production) и Rollup собирают выход в ES-модули. Динамический импорт остаётся в виде нативного import(...) в сгенерированном коде. То есть загрузку чанков выполняет сам браузер, по спецификации ES modules. Нет единого рантайм-объекта вроде __webpack_require__.e с одной функцией «загрузи чанк», которую можно подменить плагином на этапе сборки. Рантайм минимальный (preload polyfill и т.п.), и он не рассчитан на кастомную логику retry.
Поэтому:

  • встроить retry в сгенерированный код без хаков и обхода дерева модулей - практически некуда;

  • типичный подход для Vite/Rollup - не трогать бандлер, а делать retry на уровне приложения: обёртка над динамическим импортом (например, lazyWithRetry для React), повторный вызов import() при ошибке

esbuild тоже не предоставляет единого рантайм-хука для загрузки чанков; динамические импорты уходят в нативный import(). Ситуация похожая: retry проще реализовать в коде приложения, а не внутри сборки.

Итого: в Vite, Rollup, esbuild «плагин сборки», который бы один раз подменил загрузчик чанков и добавил retry всем чанкам автоматически, в стандартной архитектуре не предусмотрен. Тут нужны обёртки в коде и/или отдельный скрипт в HTML, который перехватывает ошибки загрузки тегов.

Webpack и плагин для перезапроса чанков

У Webpack есть свой рантайм: общая логика загрузки чанков живёт в сгенерированном коде в виде глобальных функций, в частности:

  • __webpack_require__.e (или аналог) - «ensure chunk», по сути загрузка скрипта чанка;

  • функция, которая по chunkId возвращает URL скрипта.

Плагин может на этапе компиляции подменить этот рантайм: добавить в бандл код, который перехватывает вызов загрузки чанка, при ошибке (через .catch) делает паузу, добавляет к URL cache-bust и снова вызывает загрузку, и так до N раз.

webpack-retry-chunk-load-plugin делает именно это:

  • В хуке thisCompilation подключается к mainTemplate.hooks.localVars и дописывает в рантайм кусок кода;

  • Сохраняет ссылки на «старые» getChunkScriptFilename и ensureChunk;

  • Подменяет их: при вызове загрузки чанка к URL может добавляться query (cache-bust), а при ошибке загрузки возвращается Promise, который через setTimeout уменьшает счётчик попыток, обновляет query (например, retry-attempt=1) и снова вызывает ensureChunk;

  • Опции:

    • maxRetries, retryDelay (число или строка с функцией от номера попытки),

    • cacheBust (строка с функцией, возвращающей строку для query), chunks (в какие чанки внедрять),

    • lastResortScript (что выполнить в браузере, если все попытки провалились - например, редирект на страницу ошибки).

Пример конфига:

const { RetryChunkLoadPlugin } = require('webpack-retry-chunk-load-plugin');

plugins: [
  new RetryChunkLoadPlugin({
    cacheBust: `function() { return Date.now(); }`,
    retryDelay: 3000,
    maxRetries: 5,
    lastResortScript: "window.location.reload();",
  }),
],

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


Как ещё можно такое сделать

Rsbuild и plugin-assets-retry

Rsbuild (сборщик из экосистемы Rspack/Web Infra) поддерживает плагин @rsbuild/plugin-assets-retry. Он работает на уровне тегов в HTML: перехватывает ошибки загрузки ресурсов (script, link, img и т.д.) и повторяет запрос, с опциями по количеству попыток, задержке, добавлению query при повторе. Может подставлять резервные домены (domain fallback). Runtime плагина по умолчанию инлайнится в HTML, чтобы не зависеть от загрузки ещё одного скрипта. Ограничение: в микрофрондах и кастомных шаблонах, где скрипты подключаются не через стандартную разметку, поведение может не сработать без дополнительной настройки.

Retry на уровне приложения (любой бандлер)

Для любого стека (Vite, Rollup, CRA, Next и т.д.) можно не трогать сборку и реализовать retry только в коде. Два варианта:

  • Функция, которая вызывает import(url), при ошибке ждёт, при необходимости добавляет к URL cache-bust и повторяет вызов. В React так делают lazyWithRetry: внутри React.lazy() подставляют эту функцию вместо обычного import().

  • Либо без автоматического retry: при ChunkLoadError показываем экран с кнопкой «Обновить» / «Повторить загрузку», по нажатию — снова вызов динамического импорта или window.location.reload().

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

Кэш и заголовки: почему retry не отменяет правильную настройку

Браузер и его кэш. От чего зависит, пойдёт ли браузер за новым содержимым или достанет старую копию? От многих факторов: тип и версия браузера, платформа, пользовательские настройки, заголовки с сервера, прокси на пути. Есть ли над всем этим хозяйством какой-то контроль? Можно ли дать всем команду «забить на кэш» и дружно пойти за новым? Короткий ответ - нет. Но есть варианты: Cache-Control, Last-Modified (и ETag), переименование файла, переименование URL. В контексте чанков это значит следующее.

Браузер и промежуточные прокси решают, идти ли за новым содержимым или отдать из кэша, по множеству факторов: тип и версия браузера, платформа, настройки, HTTP-заголовки (Cache-Control, Last-Modified, ETag), переименование файла/URL и т.д. Жёстко приказать «всем игнорировать кэш» нельзя, но можно снизить вероятность ситуации «старый HTML просит уже несуществующий чанк»:

  • Не кэшировать (или кэшировать очень коротко) index.html и entry-point: например, Cache-Control: no-cache или max-age=0, чтобы при каждом заходе браузер хотя бы перепроверял главную страницу и получал актуальные имена чанков.

  • Чанки с хешем в имени (например, chunk-abc123.js) кэшировать надолго: файл с новым хешем = новый деплой, старые URL просто перестают запрашиваться после обновления HTML.

Тогда после деплоя пользователь скорее получит свежий HTML и новые пути к чанкам, а retry нужен в основном для сетевых сбоев и единичных сбоев CDN, а не для массового «все ходят со старым HTML».


Итого

  • Проблема: чанки (JS/CSS при code splitting) могут не загрузиться из-за сети, деплоя или кэша — пользователь видит ChunkLoadError;

  • Идея решения: перехватить ошибку загрузки и повторить запрос (с ограничением попыток и опционально cache-bust);

  • На статическом сайте это делается своей обвязкой вокруг тегов <script>/<link>;

  • В проекте с бандлером нужно либо встроить retry в рантайм загрузки чанков (где он есть), либо реализовать retry в коде приложения;

  • Webpack даёт такую возможность через рантайм и плагин webpack-retry-chunk-load-plugin;

  • Vite, Rollup, esbuild не дают единой точки подмены загрузки чанков в сгенерированном коде, поэтому там разумно использовать обёртки над import() и Error Boundary;

  • Rsbuild предлагает plugin-assets-retry на уровне тегов в HTML;

  • Настройка кэша (не кэшировать HTML, долго кэшировать хешированные чанки) уменьшает класс проблем «старый HTML — старые чанки» и дополняет retry.


Литература и ссылки