DarkRiDDeR12 мин

ES-модули в браузере: диагностика двойного запуска и неверного пути

JavaScriptES modulesДиагностикаБраузер

Виджет подписывается на событие два раза, либо импорт получает HTML вместо JavaScript по неожиданному адресу. Цена ошибки — дублированные запросы и ложный ремонт: удаляют обработчик, хотя в страницу попали два разных URL модуля.

Разберём один вопрос: как в браузере отличить повторную инициализацию от двух разных module identity и как связать это с неверным путём. Пример учебный: он добавляет временные отметки в window и Console, но не выдаёт их за Network-трассу реального проекта.

Сначала определяем, что именно считается тем же модулем

Для module script важен разрешённый URL. HTML-среда хранит уже загруженные модули в module map документа; повторный статический импорт одного и того же разрешённого URL обычно использует тот же модульный результат, а не заново вычисляет исходный файл. Поэтому одинаковая строка import "./init.js" в нескольких местах сама по себе ещё не доказывает двойной запуск.

Но URL с отличающимся путём или query — другой адрес. /assets/init.js и /assets/init.js?entry=admin могут загрузиться как разные ресурсы и выполнить один и тот же код независимо. Так же к двойной инициализации приводят два разных entry, classic bundle рядом с module entry или второй документ в iframe. Диагноз начинается с URL, а не с количества тегов в шаблоне.

СимптомФакт, который нужно получитьЧто он означаетСледующее действие
Событие обработано дваждыДва лога с разными import.meta.urlСтраница получила разные module identityНайти, кто добавил query, другой base-путь или второй entry
Два тега указывают на один URLВ логах один URL и одна отметка модуляПо тегам нельзя объявить модуль выполненным дваждыИскать второй bootstrap или другой документ
Импорт вернул HTMLRequest URL, Response preview и MIME-ошибкаПуть попал в правило SPA fallback или не существуетИсправить спецификатор либо раздачу assets
Одна страница работает, вложенная — нетРазные base URL у импортёровОтносительный путь считается от разных файловСделать путь явным в каждом entry и сравнить запросы

Здесь важно не обещать абсолютную модель кеша одной строкой. Правила module map учитывают тип модуля и настройки загрузки. Для прикладной проверки достаточно увидеть фактические URL и контекст документа, в котором возникла отметка. Это надёжнее, чем угадывать по имени файла на диске.

Учебный модуль с отметкой URL

Сделаем временный файл init.js. Он фиксирует собственный import.meta.url и число запусков именно для этого URL. В проект этот код не нужно оставлять навсегда: его цель — собрать наблюдения в тестовой странице, затем удалить или заменить осмысленной инициализацией.

// assets/init.js — учебный диагностический модуль
const url = import.meta.url;
const registry = window.__moduleRuns || (window.__moduleRuns = {});
const count = (registry[url] || 0) + 1;

registry[url] = count;
console.log("[module-check]", { url, count });

export function startWidget(root) {
  root.dataset.moduleUrl = url;
  root.textContent = "widget started";
}

// assets/main.js
import { startWidget } from "./init.js";
startWidget(document.querySelector("#widget"));

При одном entry в учебной странице мы ожидаем одну запись с URL .../assets/init.js и значение count: 1. Если появилось два URL, Console уже даёт гипотезу: один из импортёров считает путь от другой папки либо к URL добавлена версия. Если один URL отмечен дважды, не надо сразу винить module map: проверяем iframe, повторную загрузку документа, classic-код с тем же побочным эффектом и собственный bootstrap.

Сам код не имитирует браузерную загрузку и не меняет правила кэширования. Он лишь показывает идентификатор, который среда передала текущему модулю. Поэтому в отчёте рядом с этим логом сохраняем вкладку, точный адрес документа и Request URL из Network. Без этого Console остаётся неполным доказательством.

Как неверный путь превращается в похожий симптом

Относительный import разрешается от URL модуля-импортёра. В следующем учебном каталоге два entry лежат в разных папках. Одинаковая строка ./init.js не означает один и тот же запрос: для каждого файла базовый URL свой.

<!-- /demo/index.html -->
<script type="module" src="./catalog/main.js"></script>
<script type="module" src="./admin/main.js"></script>

// /demo/catalog/main.js
import { startWidget } from "./init.js";

// /demo/admin/main.js
import { startWidget } from "./init.js";

// Requests are different:
// /demo/catalog/init.js
// /demo/admin/init.js

В таком примере две инициализации могут быть ожидаемы, если это два независимых экрана. Они становятся ошибкой, когда оба entry живут в одной странице и пишут в один контейнер. Решение зависит от причины: либо оставить один entry, либо вынести общий код в один путь, либо передать элемент в чистую функцию без верхнеуровневой подписки. Нельзя лечить это удалением query, пока не понятно, служит ли он намеренной версией ресурса.

Неверный путь иногда маскируется успешным HTTP-статусом. SPA-сервер может ответить 200 и отдать index.html на /demo/admin/init.js. Для module script это всё равно неправильный ресурс: браузер ожидает JavaScript и сообщает о MIME или синтаксисе. Поэтому статус 200 не закрывает проверку; открываем Response и Content-Type.

Учебная страница с намеренно разными URL

Следующая страница нужна только для демонстрации различия URL. Она не должна попадать в production: query в примере показывает отдельную identity, а не рекомендуемый способ запускать виджет дважды. После запуска сравниваем два значения data-module-url у элементов.

<!doctype html>
<div id="first"></div>
<div id="second"></div>
<script type="module">
  import { startWidget as first } from "./assets/init.js";
  import { startWidget as second } from "./assets/init.js?demo=second";

  first(document.querySelector("#first"));
  second(document.querySelector("#second"));
</script>

Так браузер видит два разных адреса и два отдельных экземпляра учебного модуля. В настоящем расследовании такой результат не означает, что query надо запретить. Он означает, что его владелец должен быть назван: cache busting, вариант entry или ошибочно склеенный путь. Затем проверяем, какая из этих причин допустима для текущей страницы.

Схема диагностики: одинаковый URL init.js ведёт к одной module identity, а URL с query или другой папкой ведут к разным отметкам import.meta.url
Сначала сравниваем фактические URL модулей, затем решаем, является ли второй entry штатным или дублирует инициализацию.

Порядок полевой диагностики

  1. Зафиксировать внешний симптом: какой обработчик, запрос или DOM-элемент повторился; сохранить адрес страницы и момент перезагрузки.
  2. В тестовой ветке добавить учебную отметку с import.meta.url в модуль с побочным эффектом. Не помещать в неё данные пользователя или production-логи.
  3. Открыть Network, перезагрузить страницу и для каждого JS-ответа записать Initiator, Request URL, статус, redirect и Content-Type.
  4. Сгруппировать Console-отметки по URL. Разные адреса — повод найти разные importers, query или entry; один адрес — повод проверить второй документ и bootstrap.
  5. Для ответа с HTML или MIME-ошибкой проверить относительный путь от файла-импортёра и правило SPA fallback. Исправить либо import, либо серверный маршрут assets.
  6. Убрать учебную отметку после решения. Критерий готовности: один владелец инициализации, понятный URL каждого модуля и воспроизводимое поведение после чистой перезагрузки.

Ограничения и безопасные выводы

  • Результат зависит от документа. Та же module identity в iframe и в основной странице не делает их одним runtime; контекст окна надо записывать отдельно.
  • Query в URL не является автоматически ошибкой: его может добавлять версия или осознанный вариант entry. Ошибкой является неучтённая вторая инициализация.
  • Идентичный URL обычно переиспользуется через module map, но не следует использовать это как замену явной архитектуре запуска. Побочный эффект верхнего уровня остаётся сложнее проверять.
  • CORS, redirect, service worker и серверный rewrite могут менять наблюдаемую доставку. В каждом случае смотрим фактический Network-ответ, а не только исходный import.
  • Пример создан для учебной страницы и не содержит настоящей browser trace. Его лог нужен для построения гипотезы, а окончательный диагноз закрывают URL, ответ сервера и владелец entry.

Итог

Двойной запуск нельзя надёжно доказать количеством строк в шаблоне. Сначала смотрим, какие URL модулей реально получил документ и кто их импортировал. Затем различаем две identity, второй bootstrap и ошибку раздачи пути. Такой порядок превращает «модуль выполнился дважды» в проверяемую цепочку: URL → entry → побочный эффект → решение.

Проверяемые источники

  • ECMAScript 2019: Modules — нормативная модель Module Record, статических import/export и выполнения связанного графа модулей
  • HTML Living Standard: JavaScript module scripts — module map, URL-идентичность модуля и разрешение module specifier в браузере
  • HTML Living Standard: the script element — тип module, загрузка графа зависимостей, отличие async и nomodule, CORS для внешних модулей
  • Fetch Standard: CORS protocol and credentials — ограничения межсайтовой загрузки, которые относятся и к импортам модулей
  • URL Standard — модель URL, на которой основано разрешение относительных адресов модулей