{ "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 и тип модуля. Одинаковый разрешённый URL в одном документе не равен двум независимым модулям только потому, что импорт встретился дважды. Но query, fragment или другой путь меняют URL. Например, /assets/init.js и /assets/init.js?entry=admin — разные записи графа. Одинаковый файл на диске не делает их одной module identity.
Эта модель объясняет частую ловушку. В шаблоне оставляют 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';\nmount(document.querySelector('#catalog'));\n\n// admin/main.js\nimport { mount } from '../assets/init.js?entry=admin';\nmount(document.querySelector('#admin'));\nВ этом учебном примере ожидаются две записи, потому что URL различаются query-параметром. Это не ошибка само по себе. Ошибка появляется, если оба entry должны обслуживать один контейнер и один побочный эффект. Тогда сначала убирают лишний entry или приводят импорты к одному намеренному URL. Запрещать query без проверки нельзя: его может добавлять версия ресурса или отдельный вариант загрузки.
\nИзменим только путь, чтобы увидеть другую причину. Если 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 проверяйте отдельно: он может изменить тело ответа, но не отменяет необходимость сверить конечный URL и MIME.
\nПоддержка браузеров зависит от целевой среды. Учебный код использует module scripts и import.meta.url; его нельзя автоматически объявить совместимым со всеми старыми браузерами. Если нужен fallback, его проектируют как отдельный entry и проверяют, что два entry не выполняют один побочный эффект одновременно.
Диагностика завершена, когда после чистой загрузки каждый документ имеет ожидаемый entry, каждый импорт имеет проверенный Request URL, ответ содержит JavaScript с корректным MIME, а функция инициализации принадлежит одному владельцу. Для отрицательного пути отдельно доказано, что неверный URL не маскируется HTML-ответом со статусом 200. Повторный тест должен показать ожидаемое число подписок и запросов, а не только отсутствие ошибки в Console.
\nТак «модуль запускается дважды» превращается в короткую цепочку доказательств: документ, entry, разрешённый URL, ответ, побочный эффект. Каждый шаг можно проверить отдельно. Это быстрее, чем менять обработчики и конфигурацию наугад.
\ntype=\"module\", entry и зависимости.