8 lines
18 KiB
JSON
8 lines
18 KiB
JSON
{
|
||
"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><script></code>, а пару «документ → разрешённый URL модуля». Если пара отличается, браузер может загрузить и вычислить разные записи. Если URL один, ищите другой документ, второй entry или побочный эффект в classic-скрипте. Если ответ не JavaScript, проверяйте раздачу ресурса, даже когда HTTP-статус равен 200.</p>\n<h2>Как браузер строит граф модулей</h2>\n<p>Тег <code><script type=\"module\" src=\"./catalog/main.js\"></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 и тип модуля. Одинаковый разрешённый URL в одном документе не равен двум независимым модулям только потому, что импорт встретился дважды. Но query, fragment или другой путь меняют URL. Например, <code>/assets/init.js</code> и <code>/assets/init.js?entry=admin</code> — разные записи графа. Одинаковый файл на диске не делает их одной module identity.</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';\nmount(document.querySelector('#catalog'));\n\n// admin/main.js\nimport { mount } from '../assets/init.js?entry=admin';\nmount(document.querySelector('#admin'));</code></pre>\n<p>В этом учебном примере ожидаются две записи, потому что URL различаются query-параметром. Это не ошибка само по себе. Ошибка появляется, если оба entry должны обслуживать один контейнер и один побочный эффект. Тогда сначала убирают лишний entry или приводят импорты к одному намеренному URL. Запрещать query без проверки нельзя: его может добавлять версия ресурса или отдельный вариант загрузки.</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>Сначала сравните фактические URL. Только затем решайте, является ли второй запуск ошибкой, отдельным 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 и Content-Type.</li><li>Сгруппируйте записи по документу и URL. Разные URL ведут к поиску query, алиаса, другого каталога или второго entry.</li><li>Если 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 проверяйте отдельно: он может изменить тело ответа, но не отменяет необходимость сверить конечный 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, разрешённый URL, ответ, побочный эффект. Каждый шаг можно проверить отдельно. Это быстрее, чем менять обработчики и конфигурацию наугад.</p>\n<h2>Проверяемые источники</h2>\n<ul><li><a href=\"https://html.spec.whatwg.org/multipage/webappapis.html#javascript-module-scripts\" target=\"_blank\" rel=\"noopener noreferrer\">HTML Standard: JavaScript module scripts</a> — module map, URL-идентичность и загрузка графа модулей.</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>, entry и зависимости.</li><li><a href=\"https://262.ecma-international.org/\" target=\"_blank\" rel=\"noopener noreferrer\">ECMAScript Language Specification</a> — Module Record, связывание импортов и экспортов и вычисление графа.</li></ul>"
|
||
}
|