{ "index": 319, "slug": "editorial-2019-02-field-es-modules", "title": "ES-модули в браузере: найдите второй запуск по фактическому URL", "excerpt": "Виджет запускается дважды или импорт получает HTML вместо JavaScript. Разбираем module identity, разрешение относительных путей и проверку через Network.", "contentHtml": "

Страница загрузилась, но обработчик сработал два раза. Пользователь видит два запроса, двойное уведомление или повторную подписку. В другом варианте кнопка молчит: HTML пришёл со статусом 200, а модуль не выполнился. Цена ошибки — не только сломанный экран. Дублированная инициализация может отправить форму дважды, создать две подписки на одно событие и оставить состояние, которое трудно удалить.

\n

Первый вывод обычно звучит так: «браузер дважды запустил один модуль» или «import не работает». Оба вывода преждевременны. Сначала нужно получить фактический URL каждого модуля и ответ сервера. Браузер строит граф модулей из разрешённых URL. Две разные строки в исходнике могут дать один URL. Одна и та же строка может дать разные URL, если её импортируют из разных каталогов.

\n

Тезис простой: диагностируйте не количество строк <script>, а пару «документ → разрешённый URL модуля». Если пара отличается, браузер может загрузить и вычислить разные записи. Если URL один, ищите другой документ, второй entry или побочный эффект в classic-скрипте. Если ответ не JavaScript, проверяйте раздачу ресурса, даже когда HTTP-статус равен 200.

\n

Как браузер строит граф модулей

\n

Тег <script type=\"module\" src=\"./catalog/main.js\"> задаёт entry. Браузер разрешает адрес относительно URL документа, загружает entry и разбирает его статические import. Каждый импорт разрешается относительно URL файла, в котором он записан. Поэтому ./init.js из /demo/catalog/main.js означает /demo/catalog/init.js, а из /demo/admin/main.js — /demo/admin/init.js.

\n

После разрешения браузер использует module map документа — карту ключей «URL запроса + тип модуля». Она не даёт повторно загрузить, разобрать и вычислить один и тот же модуль в одном документе или worker. Важно разделять два адреса: ключом карты служит Request URL, а base URL загруженного module script берётся из Response URL после редиректа. Поэтому import.meta.url полезен как метка загруженного скрипта, но Request URL, redirect и Initiator нужно брать из Network.

\n

Query и fragment входят в URL модуля и могут создать отдельные записи. Например, /assets/init.js, /assets/init.js?entry=admin и /assets/init.js#panel — не повод автоматически считать один файл одной module identity: для графа это разные URL. Одинаковый файл на диске не делает их одной записью.

\n

Эта модель объясняет частую ловушку. В шаблоне оставляют module entry и старый bundle. Оба файла вызывают startWidget(), хотя разработчик считает bundle запасным вариантом. В другой ловушке один entry импортирует ./init.js, а второй — тот же файл через алиас или query. Счётчик в коде виджетов растёт, и команда исправляет обработчик вместо точки входа.

\n

Симптом → причина → проверка → действие

\n
СимптомПричинаПроверкаДействие
Обработчик сработал дваждыДва entry или два разных URL модуляСверить Request URL и Initiator в NetworkОставить одного владельца инициализации
Два тега указывают на один URLДублирование ещё не доказаноПроверить iframe, второй документ и classic bundleНайти фактический вызов побочного эффекта
Импорт дал 404Путь считается от другого импортёраОткрыть фактический URL запросаИсправить спецификатор в файле-импортёре
Статус 200, но MIME-ошибкаSPA fallback вернул index.htmlПосмотреть Response и Content-TypeРазделить маршрут приложения и каталог assets
Импорт с другого origin заблокированОтвет не проходит CORS для модульного запросаПроверить origin и заголовки ответаНастроить разрешённый origin или использовать тот же origin
\n

Таблица задаёт порядок проверки, но не заменяет факты. Статус 200 сообщает только об успешном HTTP-ответе. Он не доказывает, что сервер отдал JavaScript. Для модуля важны тело ответа, MIME, редиректы и точный URL. Ошибка CORS также не доказывает неправильный путь: ресурс может существовать, но быть запрещён для текущего origin.

\n

Учебная проверка через import.meta.url

\n

Ниже учебный пример. Он помогает сравнить identity в одной странице. Он не является трассой production-сайта и не заменяет Network. Код временно ставит отметку в window и выводит URL. После диагностики отметку нужно удалить или заменить нормальным наблюдением без глобального состояния.

\n
// assets/init.js\nconst moduleUrl = import.meta.url;\nconst registry = window.__moduleRuns || (window.__moduleRuns = {});\nconst count = (registry[moduleUrl] || 0) + 1;\nregistry[moduleUrl] = count;\nconsole.log('[module-check]', { moduleUrl, count });\n\nexport function mount(root) {\n  root.dataset.moduleUrl = moduleUrl;\n  root.textContent = 'widget started';\n}\n\n// catalog/main.js\nimport { mount } from '../assets/init.js';\nconst catalog = document.querySelector('#catalog');\nif (catalog) mount(catalog);\n\n// admin/main.js\nimport { mount } from '../assets/init.js?entry=admin';\nconst admin = document.querySelector('#admin');\nif (admin) mount(admin);
\n

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

\n

Метка import.meta.url показывает URL, который host передал модулю. Если сервер сделал редирект, он может отличаться от исходного Request URL. Поэтому пример отвечает на вопрос «какой URL видит загруженный модуль», а Network — на вопросы «что запросили», «кто инициировал запрос» и «куда он перенаправился».

\n

Изменим только путь, чтобы увидеть другую причину. Если admin/main.js содержит import '../assets/init.js', его фактический URL зависит от расположения самого файла. Перенос entry в /demo/v2/admin/main.js меняет результат даже при неизменной строке import. Путь в исходнике — это инструкция для разрешения, а не абсолютная ссылка на файл в репозитории.

\n

Неверный URL может вернуть HTML. Например, сервер приложения отвечает своим index.html на неизвестный путь, чтобы поддержать клиентскую маршрутизацию. Браузер получает 200, но модульный загрузчик ожидает JavaScript. В Console появится MIME-ошибка или синтаксическая ошибка HTML. Исправлять компонент в React или повторять импорт в этом случае бессмысленно: сначала нужно исправить адрес или правило раздачи.

\n
\"Схема
Сначала сравните Request URL, Response URL и Initiator. Только затем решайте, является ли второй запуск ошибкой, отдельным entry или намеренным вариантом ресурса.
\n

Что считать одним запуском

\n

Запуск модуля и запуск функции внутри модуля — разные события. Браузер может вычислить модуль один раз, а код страницы вызовет экспортированную функцию дважды. Верно и обратное: два разных URL могут вычислить один и тот же текст файла дважды, потому что для графа это разные адреса. Поэтому отметку ставят в самом модуле и отдельно логируют место, где вызывается mount().

\n

Третий вариант создаёт новый документ. Перезагрузка iframe, открытие страницы во втором окне и worker имеют отдельные контексты. Один и тот же URL в основном документе и iframe не означает один runtime. Если в Console видны одинаковые URL, добавьте к записи имя документа или контекста. Иначе можно потратить время на поиск несуществующего повторного импорта.

\n

Classic-скрипт тоже может выполнять ту же работу. Связка с nomodule рассчитана на старые браузеры, но ошибка в условии, ручная вставка или сборочный шаблон могут оставить оба пути активными. Проверяйте не только module map, но и все вызовы функции, которая создаёт подписку, запрос или DOM-узел.

\n

Порядок диагностики

\n
  1. Запишите симптом: какой обработчик, запрос или DOM-узел повторился. Сохраните адрес страницы и способ воспроизведения.
  2. Временно добавьте в модуль отметку с import.meta.url и счётчиком. Не записывайте в неё пользовательские данные.
  3. Откройте Network, очистите фильтр и перезагрузите страницу. Для каждого module request сохраните Request URL, Initiator, статус, redirect, Response URL и Content-Type.
  4. Сгруппируйте записи по документу и Request URL. Разные URL ведут к поиску query, fragment, алиаса, другого каталога или второго entry. Отдельно отметьте редирект.
  5. Если Request URL один, проверьте iframe, worker, повторный bootstrap и classic-скрипт. Отдельно найдите все вызовы функции с побочным эффектом.
  6. Если ответ содержит HTML или неверный MIME, исправьте путь либо серверную раздачу assets. После этого повторите запрос напрямую и через страницу.
  7. Удалите учебный счётчик и повторите чистую загрузку. Зафиксируйте один владелец инициализации, фактический URL entry и ожидаемое число вызовов.
\n

Ограничения

\n

Нативные модули не превращают любой путь в пакетный импорт. В браузере относительный путь должен разрешиться в URL, а сервер должен вернуть доступный JavaScript. Спецификаторы вроде имени npm-пакета требуют отдельного механизма, например import map, либо заранее собранного файла. Наличие import в исходнике не означает, что браузер умеет читать структуру node_modules.

\n

Кэш, service worker и редирект могут менять наблюдаемую картину. Для первого прохода отключите сохранение кэша в DevTools и проверьте, кто был Initiator. Service worker может изменить тело и заголовки ответа, но не отменяет необходимость сверить Request URL, Response URL и MIME.

\n

Поддержка браузеров зависит от целевой среды. Учебный код использует module scripts и import.meta.url; его нельзя автоматически объявить совместимым со всеми старыми браузерами. Если нужен fallback, его проектируют как отдельный entry и проверяют, что два entry не выполняют один побочный эффект одновременно.

\n

Критерий готовности

\n

Диагностика завершена, когда после чистой загрузки каждый документ имеет ожидаемый entry, каждый импорт имеет проверенный Request URL, редирект объяснён, ответ содержит JavaScript с корректным MIME, а функция инициализации принадлежит одному владельцу. Для отрицательного пути отдельно доказано, что неверный URL не маскируется HTML-ответом со статусом 200. Повторный тест должен показать ожидаемое число подписок и запросов, а не только отсутствие ошибки в Console.

\n

Так «модуль запускается дважды» превращается в короткую цепочку доказательств: документ, entry, Request URL, Response URL, ответ, побочный эффект. Каждый шаг можно проверить отдельно. Это быстрее, чем менять обработчики и конфигурацию наугад.

\n

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

\n" }