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