Files

8 lines
20 KiB
JSON
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"index": 319,
"slug": "editorial-2019-02-field-es-modules",
"title": "ES-модули в браузере: найдите второй запуск по фактическому URL",
"excerpt": "Виджет запускается дважды или импорт получает HTML вместо JavaScript. Разбираем module identity, разрешение относительных путей и проверку через Network.",
"contentHtml": "<p>Страница загрузилась, но обработчик сработал два раза. Пользователь видит два запроса, двойное уведомление или повторную подписку. В другом варианте кнопка молчит: HTML пришёл со статусом 200, а модуль не выполнился. Цена ошибки — не только сломанный экран. Дублированная инициализация может отправить форму дважды, создать две подписки на одно событие и оставить состояние, которое трудно удалить.</p>\n<p>Первый вывод обычно звучит так: «браузер дважды запустил один модуль» или «import не работает». Оба вывода преждевременны. Сначала нужно получить фактический URL каждого модуля и ответ сервера. Браузер строит граф модулей из разрешённых URL. Две разные строки в исходнике могут дать один URL. Одна и та же строка может дать разные URL, если её импортируют из разных каталогов.</p>\n<p>Тезис простой: диагностируйте не количество строк <code>&lt;script&gt;</code>, а пару «документ → разрешённый URL модуля». Если пара отличается, браузер может загрузить и вычислить разные записи. Если URL один, ищите другой документ, второй entry или побочный эффект в classic-скрипте. Если ответ не JavaScript, проверяйте раздачу ресурса, даже когда HTTP-статус равен 200.</p>\n<h2>Как браузер строит граф модулей</h2>\n<p>Тег <code>&lt;script type=\"module\" src=\"./catalog/main.js\"&gt;</code> задаёт entry. Браузер разрешает адрес относительно URL документа, загружает entry и разбирает его статические <code>import</code>. Каждый импорт разрешается относительно URL файла, в котором он записан. Поэтому <code>./init.js</code> из <code>/demo/catalog/main.js</code> означает <code>/demo/catalog/init.js</code>, а из <code>/demo/admin/main.js</code> — <code>/demo/admin/init.js</code>.</p>\n<p>После разрешения браузер использует module map документа — карту ключей «URL запроса + тип модуля». Она не даёт повторно загрузить, разобрать и вычислить один и тот же модуль в одном документе или worker. Важно разделять два адреса: ключом карты служит Request URL, а base URL загруженного module script берётся из Response URL после редиректа. Поэтому <code>import.meta.url</code> полезен как метка загруженного скрипта, но Request URL, redirect и Initiator нужно брать из Network.</p>\n<p>Query и fragment входят в URL модуля и могут создать отдельные записи. Например, <code>/assets/init.js</code>, <code>/assets/init.js?entry=admin</code> и <code>/assets/init.js#panel</code> — не повод автоматически считать один файл одной module identity: для графа это разные URL. Одинаковый файл на диске не делает их одной записью.</p>\n<p>Эта модель объясняет частую ловушку. В шаблоне оставляют module entry и старый bundle. Оба файла вызывают <code>startWidget()</code>, хотя разработчик считает bundle запасным вариантом. В другой ловушке один entry импортирует <code>./init.js</code>, а второй — тот же файл через алиас или query. Счётчик в коде виджетов растёт, и команда исправляет обработчик вместо точки входа.</p>\n<h2>Симптом → причина → проверка → действие</h2>\n<div class=\"table-scroll\"><table><thead><tr><th scope=\"col\">Симптом</th><th scope=\"col\">Причина</th><th scope=\"col\">Проверка</th><th scope=\"col\">Действие</th></tr></thead><tbody><tr><td>Обработчик сработал дважды</td><td>Два entry или два разных URL модуля</td><td>Сверить <code>Request URL</code> и Initiator в Network</td><td>Оставить одного владельца инициализации</td></tr><tr><td>Два тега указывают на один URL</td><td>Дублирование ещё не доказано</td><td>Проверить iframe, второй документ и classic bundle</td><td>Найти фактический вызов побочного эффекта</td></tr><tr><td>Импорт дал 404</td><td>Путь считается от другого импортёра</td><td>Открыть фактический URL запроса</td><td>Исправить спецификатор в файле-импортёре</td></tr><tr><td>Статус 200, но MIME-ошибка</td><td>SPA fallback вернул <code>index.html</code></td><td>Посмотреть Response и <code>Content-Type</code></td><td>Разделить маршрут приложения и каталог assets</td></tr><tr><td>Импорт с другого origin заблокирован</td><td>Ответ не проходит CORS для модульного запроса</td><td>Проверить origin и заголовки ответа</td><td>Настроить разрешённый origin или использовать тот же origin</td></tr></tbody></table></div>\n<p>Таблица задаёт порядок проверки, но не заменяет факты. Статус 200 сообщает только об успешном HTTP-ответе. Он не доказывает, что сервер отдал JavaScript. Для модуля важны тело ответа, MIME, редиректы и точный URL. Ошибка CORS также не доказывает неправильный путь: ресурс может существовать, но быть запрещён для текущего origin.</p>\n<h2>Учебная проверка через import.meta.url</h2>\n<p>Ниже учебный пример. Он помогает сравнить identity в одной странице. Он не является трассой production-сайта и не заменяет Network. Код временно ставит отметку в <code>window</code> и выводит URL. После диагностики отметку нужно удалить или заменить нормальным наблюдением без глобального состояния.</p>\n<pre><code>// 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);</code></pre>\n<p>В этом учебном примере ожидаются две записи, потому что URL различаются query-параметром. Это не ошибка само по себе. Ошибка появляется, если оба entry должны обслуживать один контейнер и один побочный эффект. Тогда сначала убирают лишний entry или приводят импорты к одному намеренному URL. Запрещать query без проверки нельзя: его может добавлять версия ресурса или отдельный вариант загрузки.</p>\n<p>Метка <code>import.meta.url</code> показывает URL, который host передал модулю. Если сервер сделал редирект, он может отличаться от исходного Request URL. Поэтому пример отвечает на вопрос «какой URL видит загруженный модуль», а Network — на вопросы «что запросили», «кто инициировал запрос» и «куда он перенаправился».</p>\n<p>Изменим только путь, чтобы увидеть другую причину. Если <code>admin/main.js</code> содержит <code>import '../assets/init.js'</code>, его фактический URL зависит от расположения самого файла. Перенос entry в <code>/demo/v2/admin/main.js</code> меняет результат даже при неизменной строке import. Путь в исходнике — это инструкция для разрешения, а не абсолютная ссылка на файл в репозитории.</p>\n<p>Неверный URL может вернуть HTML. Например, сервер приложения отвечает своим <code>index.html</code> на неизвестный путь, чтобы поддержать клиентскую маршрутизацию. Браузер получает 200, но модульный загрузчик ожидает JavaScript. В Console появится MIME-ошибка или синтаксическая ошибка HTML. Исправлять компонент в React или повторять импорт в этом случае бессмысленно: сначала нужно исправить адрес или правило раздачи.</p>\n<figure><img src=\"/assets/editorial/2019/es-modules-diagnostic-identity-2019.svg\" alt=\"Схема диагностики ES-модулей: одинаковый URL даёт одну module identity, а другой путь или query создаёт отдельную запись\" loading=\"lazy\" /><figcaption>Сначала сравните Request URL, Response URL и Initiator. Только затем решайте, является ли второй запуск ошибкой, отдельным entry или намеренным вариантом ресурса.</figcaption></figure>\n<h2>Что считать одним запуском</h2>\n<p>Запуск модуля и запуск функции внутри модуля — разные события. Браузер может вычислить модуль один раз, а код страницы вызовет экспортированную функцию дважды. Верно и обратное: два разных URL могут вычислить один и тот же текст файла дважды, потому что для графа это разные адреса. Поэтому отметку ставят в самом модуле и отдельно логируют место, где вызывается <code>mount()</code>.</p>\n<p>Третий вариант создаёт новый документ. Перезагрузка iframe, открытие страницы во втором окне и worker имеют отдельные контексты. Один и тот же URL в основном документе и iframe не означает один runtime. Если в Console видны одинаковые URL, добавьте к записи имя документа или контекста. Иначе можно потратить время на поиск несуществующего повторного импорта.</p>\n<p>Classic-скрипт тоже может выполнять ту же работу. Связка с <code>nomodule</code> рассчитана на старые браузеры, но ошибка в условии, ручная вставка или сборочный шаблон могут оставить оба пути активными. Проверяйте не только module map, но и все вызовы функции, которая создаёт подписку, запрос или DOM-узел.</p>\n<h2>Порядок диагностики</h2>\n<ol><li>Запишите симптом: какой обработчик, запрос или DOM-узел повторился. Сохраните адрес страницы и способ воспроизведения.</li><li>Временно добавьте в модуль отметку с <code>import.meta.url</code> и счётчиком. Не записывайте в неё пользовательские данные.</li><li>Откройте Network, очистите фильтр и перезагрузите страницу. Для каждого module request сохраните Request URL, Initiator, статус, redirect, Response URL и Content-Type.</li><li>Сгруппируйте записи по документу и Request URL. Разные URL ведут к поиску query, fragment, алиаса, другого каталога или второго entry. Отдельно отметьте редирект.</li><li>Если Request URL один, проверьте iframe, worker, повторный bootstrap и classic-скрипт. Отдельно найдите все вызовы функции с побочным эффектом.</li><li>Если ответ содержит HTML или неверный MIME, исправьте путь либо серверную раздачу assets. После этого повторите запрос напрямую и через страницу.</li><li>Удалите учебный счётчик и повторите чистую загрузку. Зафиксируйте один владелец инициализации, фактический URL entry и ожидаемое число вызовов.</li></ol>\n<h2>Ограничения</h2>\n<p>Нативные модули не превращают любой путь в пакетный импорт. В браузере относительный путь должен разрешиться в URL, а сервер должен вернуть доступный JavaScript. Спецификаторы вроде имени npm-пакета требуют отдельного механизма, например import map, либо заранее собранного файла. Наличие <code>import</code> в исходнике не означает, что браузер умеет читать структуру <code>node_modules</code>.</p>\n<p>Кэш, service worker и редирект могут менять наблюдаемую картину. Для первого прохода отключите сохранение кэша в DevTools и проверьте, кто был Initiator. Service worker может изменить тело и заголовки ответа, но не отменяет необходимость сверить Request URL, Response URL и MIME.</p>\n<p>Поддержка браузеров зависит от целевой среды. Учебный код использует module scripts и <code>import.meta.url</code>; его нельзя автоматически объявить совместимым со всеми старыми браузерами. Если нужен fallback, его проектируют как отдельный entry и проверяют, что два entry не выполняют один побочный эффект одновременно.</p>\n<h2>Критерий готовности</h2>\n<p>Диагностика завершена, когда после чистой загрузки каждый документ имеет ожидаемый entry, каждый импорт имеет проверенный Request URL, редирект объяснён, ответ содержит JavaScript с корректным MIME, а функция инициализации принадлежит одному владельцу. Для отрицательного пути отдельно доказано, что неверный URL не маскируется HTML-ответом со статусом 200. Повторный тест должен показать ожидаемое число подписок и запросов, а не только отсутствие ошибки в Console.</p>\n<p>Так «модуль запускается дважды» превращается в короткую цепочку доказательств: документ, entry, Request URL, Response URL, ответ, побочный эффект. Каждый шаг можно проверить отдельно. Это быстрее, чем менять обработчики и конфигурацию наугад.</p>\n<h2>Проверяемые источники</h2>\n<ul><li><a href=\"https://html.spec.whatwg.org/multipage/webappapis.html#module-map\" target=\"_blank\" rel=\"noopener noreferrer\">HTML Standard: module map</a> — ключи «Request URL + module type», дедупликация загрузки и различие Request URL и Response URL.</li><li><a href=\"https://html.spec.whatwg.org/multipage/webappapis.html#fetch-a-single-module-script\" target=\"_blank\" rel=\"noopener noreferrer\">HTML Standard: fetch a single module script</a> — CORS-режим, проверка MIME и создание module script.</li><li><a href=\"https://html.spec.whatwg.org/multipage/scripting.html#the-script-element\" target=\"_blank\" rel=\"noopener noreferrer\">HTML Standard: элемент script</a> — <code>type=\"module\"</code>, root entry и загрузка зависимостей.</li><li><a href=\"https://tc39.es/ecma262/2026/multipage/ecmascript-language-expressions.html\" target=\"_blank\" rel=\"noopener noreferrer\">ECMAScript 2026: Meta Properties</a> — host-defined объект <code>import.meta</code> и его получение модулем.</li><li><a href=\"https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/import.meta\" target=\"_blank\" rel=\"noopener noreferrer\">MDN: import.meta</a> — практическое описание <code>import.meta.url</code>, включая query и hash.</li></ul>"
}