{ "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.
Тег <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.
После разрешения браузер использует module map документа — карту ключей «URL запроса + тип модуля». Она не даёт повторно загрузить, разобрать и вычислить один и тот же модуль в одном документе или worker. Важно разделять два адреса: ключом карты служит Request URL, а base URL загруженного module script берётся из Response URL после редиректа. Поэтому import.meta.url полезен как метка загруженного скрипта, но Request URL, redirect и Initiator нужно брать из Network.
Query и fragment входят в URL модуля и могут создать отдельные записи. Например, /assets/init.js, /assets/init.js?entry=admin и /assets/init.js#panel — не повод автоматически считать один файл одной module identity: для графа это разные URL. Одинаковый файл на диске не делает их одной записью.
Эта модель объясняет частую ловушку. В шаблоне оставляют module entry и старый bundle. Оба файла вызывают startWidget(), хотя разработчик считает bundle запасным вариантом. В другой ловушке один entry импортирует ./init.js, а второй — тот же файл через алиас или query. Счётчик в коде виджетов растёт, и команда исправляет обработчик вместо точки входа.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Обработчик сработал дважды | Два 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 |
Таблица задаёт порядок проверки, но не заменяет факты. Статус 200 сообщает только об успешном HTTP-ответе. Он не доказывает, что сервер отдал JavaScript. Для модуля важны тело ответа, MIME, редиректы и точный URL. Ошибка CORS также не доказывает неправильный путь: ресурс может существовать, но быть запрещён для текущего origin.
\nНиже учебный пример. Он помогает сравнить identity в одной странице. Он не является трассой production-сайта и не заменяет Network. Код временно ставит отметку в window и выводит URL. После диагностики отметку нужно удалить или заменить нормальным наблюдением без глобального состояния.
// 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 — на вопросы «что запросили», «кто инициировал запрос» и «куда он перенаправился».
Изменим только путь, чтобы увидеть другую причину. Если admin/main.js содержит import '../assets/init.js', его фактический URL зависит от расположения самого файла. Перенос entry в /demo/v2/admin/main.js меняет результат даже при неизменной строке import. Путь в исходнике — это инструкция для разрешения, а не абсолютная ссылка на файл в репозитории.
Неверный URL может вернуть HTML. Например, сервер приложения отвечает своим index.html на неизвестный путь, чтобы поддержать клиентскую маршрутизацию. Браузер получает 200, но модульный загрузчик ожидает JavaScript. В Console появится MIME-ошибка или синтаксическая ошибка HTML. Исправлять компонент в React или повторять импорт в этом случае бессмысленно: сначала нужно исправить адрес или правило раздачи.
Запуск модуля и запуск функции внутри модуля — разные события. Браузер может вычислить модуль один раз, а код страницы вызовет экспортированную функцию дважды. Верно и обратное: два разных URL могут вычислить один и тот же текст файла дважды, потому что для графа это разные адреса. Поэтому отметку ставят в самом модуле и отдельно логируют место, где вызывается mount().
Третий вариант создаёт новый документ. Перезагрузка iframe, открытие страницы во втором окне и worker имеют отдельные контексты. Один и тот же URL в основном документе и iframe не означает один runtime. Если в Console видны одинаковые URL, добавьте к записи имя документа или контекста. Иначе можно потратить время на поиск несуществующего повторного импорта.
\nClassic-скрипт тоже может выполнять ту же работу. Связка с nomodule рассчитана на старые браузеры, но ошибка в условии, ручная вставка или сборочный шаблон могут оставить оба пути активными. Проверяйте не только module map, но и все вызовы функции, которая создаёт подписку, запрос или DOM-узел.
import.meta.url и счётчиком. Не записывайте в неё пользовательские данные.Нативные модули не превращают любой путь в пакетный импорт. В браузере относительный путь должен разрешиться в URL, а сервер должен вернуть доступный JavaScript. Спецификаторы вроде имени npm-пакета требуют отдельного механизма, например import map, либо заранее собранного файла. Наличие import в исходнике не означает, что браузер умеет читать структуру node_modules.
Кэш, service worker и редирект могут менять наблюдаемую картину. Для первого прохода отключите сохранение кэша в DevTools и проверьте, кто был Initiator. Service worker может изменить тело и заголовки ответа, но не отменяет необходимость сверить Request URL, Response URL и MIME.
\nПоддержка браузеров зависит от целевой среды. Учебный код использует module scripts и import.meta.url; его нельзя автоматически объявить совместимым со всеми старыми браузерами. Если нужен fallback, его проектируют как отдельный entry и проверяют, что два entry не выполняют один побочный эффект одновременно.
Диагностика завершена, когда после чистой загрузки каждый документ имеет ожидаемый entry, каждый импорт имеет проверенный Request URL, редирект объяснён, ответ содержит JavaScript с корректным MIME, а функция инициализации принадлежит одному владельцу. Для отрицательного пути отдельно доказано, что неверный URL не маскируется HTML-ответом со статусом 200. Повторный тест должен показать ожидаемое число подписок и запросов, а не только отсутствие ошибки в Console.
\nТак «модуль запускается дважды» превращается в короткую цепочку доказательств: документ, entry, Request URL, Response URL, ответ, побочный эффект. Каждый шаг можно проверить отдельно. Это быстрее, чем менять обработчики и конфигурацию наугад.
\ntype=\"module\", root entry и загрузка зависимостей.import.meta и его получение модулем.import.meta.url, включая query и hash.