{"index":321,"slug":"editorial-2019-02-practice-es-modules","title":"ES-модули в браузере: как найти причину 404, MIME и CORS","excerpt":"Страница загрузила HTML, но интерфейс не запустился: браузер не нашёл entry-модуль или его зависимость. Разбираем нативный граф ES-модулей, проверяем URL и ответ сервера, а затем фиксируем критерий готовности без сборщика.","contentHtml":"

HTML открылся, но кнопка не появилась. В Console видна ошибка импорта, а в Network — 404, ответ с HTML или отказ CORS. Цена ошибки — не только один пустой экран. Если заменить модуль готовым bundle наугад, причина останется в URL или сервере и проявится на следующем экране. Команда потратит время на повторные сборки, хотя браузер не получил нужный JavaScript.

\n

Тезис простой: нативный ES-модуль запускается только после успешной загрузки всего графа. Нужно проверить entry, URL каждого импорта, HTTP-ответ и результат выполнения. Синтаксис import не настраивает Webpack, не ищет файл в node_modules и не исправляет SPA-маршрутизацию. Ниже — самостоятельный учебный пример. Он показывает механизм и порядок проверки, но не утверждает, что так устроен конкретный production-сайт.

\n

Что именно загружает браузер

\n

В HTML ставят <script type=\"module\" src=\"./assets/app.js\">. Значение module меняет режим скрипта. Браузер получает URL entry, читает его статические импорты, строит граф и загружает зависимости. Затем он вычисляет модули в порядке их связей. Объявления верхнего уровня модуля не становятся случайными свойствами window. Связь между файлами задают экспорт и импорт.

\n

Здесь есть две границы. ECMAScript описывает модуль, его экспорты и зависимости. HTML и Fetch описывают загрузку ресурса, URL, CORS и момент запуска. Сборщик может заранее разрешить имя пакета, объединить файлы и создать chunk. Нативный браузер этого не делает только потому, что встретил слово import.

\n

Статический импорт — это не вызов функции в середине тела модуля. Зависимость должна быть доступна до вычисления импортёра. Поэтому ошибка в message.js не позволяет считать app.js готовым. Один console.log в entry не закрывает проверку: лог может не появиться из-за неверного URL, а появившийся лог не доказывает правильный ответ всех зависимостей.

\n

Симптом → причина → проверка → действие

\n
Матрица диагностики нативного графа
СимптомПричинаПроверкаДействие
HTML есть, интерфейс пустEntry не загрузился или не вычислилсяNetwork: запрос app.js; Console: текст ошибкиПроверить type=\"module\", URL, статус и ответ entry
Импорт отвечает 404Относительный путь указывает не в ту папкуСкопировать фактический Request URL из NetworkИсправить спецификатор в файле-импортёре
Статус 200, но модуль не запускаетсяСервер вернул HTML fallback или неверный MIMEОткрыть Response и проверить Content-TypeОтделить маршрут assets от маршрута приложения
Другой origin блокирует импортОтвет не проходит CORS-проверку модуляПроверить origin и CORS-заголовки ответаРазрешить нужный origin или отдать файл с того же origin
Работает после сборки, не работает напрямуюСборщик добавил resolution, alias или расширениеСравнить исходный import с URL native-запросаЛибо дать браузеру URL, либо запускать проверенный bundle
\n

404 и MIME-ошибку нельзя лечить одной перестановкой импортов. Сначала сохраняют Request URL, статус, redirect, Response и Content-Type. Если ответ содержит разметку страницы, сервер вернул не модуль. Статус 200 не превращает HTML в JavaScript.

\n

Минимальный пример с тремя файлами

\n

Пример рассчитан на локальный HTTP-сервер. Открытие через file:// не является проверкой веб-доставки: у файлового URL другая модель origin, а поведение доступа к зависимостям отличается от HTTP. В каталоге должны лежать index.html, assets/app.js и assets/message.js.

\n
<!-- index.html -->\n<!doctype html>\n<meta charset=\"utf-8\">\n<main>\n  <h1>Статус загрузки</h1>\n  <output id=\"status\">ожидание</output>\n</main>\n<script type=\"module\" src=\"./assets/app.js\"></script>\n\n// assets/message.js\nexport function message(name) {\n  return \"модуль \" + name + \" получен\";\n}\n\n// assets/app.js\nimport { message } from \"./message.js\";\n\nconst status = document.querySelector(\"#status\");\nstatus.textContent = message(\"app.js\");\nconsole.log(\"Учебный entry:\", import.meta.url);
\n

Это учебная схема, а не трасса реального проекта. После запуска браузер должен запросить assets/app.js, затем assets/message.js. В #status появляется строка из экспорта. В Console виден URL entry. Для готовности нужны все три наблюдения: два корректных сетевых ответа, DOM-результат и отсутствие ошибки разрешения.

\n

Как вычисляется относительный путь

\n

Спецификатор ./message.js считается от URL файла, который его содержит. Если entry находится по адресу /demo/assets/app.js, браузер запрашивает /demo/assets/message.js. Он не считает путь от index.html и не обязан добавлять расширение. Поэтому ./message — это другой URL, а не сокращённая запись ./message.js.

\n
// /demo/pages/index.html\n<script type=\"module\" src=\"../assets/app.js\"></script>\n\n// /demo/assets/app.js\nimport { message } from \"./message.js\";\n\n// Браузер ищет: /demo/assets/message.js\n// Он не ищет: /demo/message.js\n// и не добавляет .js к import \"./message\"
\n

Практическая проверка не требует догадки. Откройте failed request, определите URL импортёра и сравните его каталог с каталогом ожидаемого файла. Если файл лежит в другом месте, исправьте один спецификатор или структуру каталогов. Если файл существует, но приходит HTML, ищите правило раздачи статических файлов и SPA fallback.

\n

Bare-имя вроде date-fns не является обычным URL для этого учебного native-сценария. Webpack может найти пакет по node_modules, alias и полю resolve. Браузер без import map или другого явно настроенного механизма не получает эти правила. Не переносите конфигурацию сборщика в Console браузера.

\n

Сервер входит в контракт модуля

\n

Браузер получает модуль как ресурс по URL. Для того же origin всё равно важны статус, итоговый URL после redirect и тип содержимого. Для другого origin добавляется CORS-проверка. Ошибка MIME или CORS находится на границе доставки. Её не исправит добавление ещё одного import и не объяснит отсутствие файла в JavaScript-коде.

\n

Особенно часто ломается SPA fallback. Маршрутизатор знает, что неизвестный путь страницы надо заменить на index.html. Но запрос /assets/message.js должен получить JavaScript, а не тот же HTML. Если Network показывает 200, откройте Response. Содержимое важнее одного status code.

\n

При cross-origin загрузке проверяйте точный origin страницы и заголовки ответа. Не отключайте защиту браузера как способ доказать исправность production. Такая настройка скрывает проблему, а не проверяет серверный контракт. Надёжнее временно отдать учебные файлы с того же origin и затем отдельно проверить разрешённый cross-origin путь.

\n
\"Учебный
Граф проверяют по цепочке: entry, зависимость, ответ сервера и изменение DOM. Иллюстрация показывает учебный маршрут, а не измерение production.
\n

Момент запуска и async

\n

Module script без async ведёт себя как отложенный скрипт относительно разбора документа: браузер может загружать граф параллельно, а вычисляет его после разбора HTML. Поэтому в примере элемент #status уже существует. Это не повод считать любой верхнеуровневый код безопасным. Если модулю нужен элемент, состояние или другой bootstrap, условие должно быть видно в коде.

\n

Атрибут async меняет момент вычисления. Скрипт может выполниться сразу после готовности графа, ещё до конца разбора документа. Для виджета это создаёт отрицательный путь: querySelector вернёт null, хотя URL и MIME исправны. Не добавляйте async как универсальное ускорение. Сначала проверьте, что код не зависит от ещё не разобранной разметки.

\n

Для старых браузеров можно держать classic-версию с nomodule. Это отдельный артефакт и отдельный сценарий. Нельзя считать fallback проверенным только потому, что современный браузер успешно выполнил module script.

\n

Порядок проверки

\n
  1. Зафиксировать симптом: пустой элемент, ошибка Console, 404, MIME или CORS. Сохранить адрес страницы и время перезагрузки.
  2. Отдать пример через HTTP-сервер и открыть URL страницы. Не использовать file:// как эквивалент веб-среды.
  3. Проверить HTML: оставить один entry с type=\"module\" и точным src. Не подключать bundle «для надёжности».
  4. В Network включить сохранение запросов и перезагрузить страницу. Записать Request URL, статус, redirect, Response и Content-Type для entry и каждой зависимости.
  5. Для каждого относительного импорта считать путь от файла-импортёра. Проверить расширение и фактическое расположение файла.
  6. Сверить Console и DOM. Успехом считать отсутствие ошибок загрузки, строку в #status и лог только как дополнительную отметку, а не как единственное доказательство.
  7. Если ответ пришёл с другого origin, проверить CORS-заголовки. Если ответ содержит HTML, исправить раздачу assets, а не менять JavaScript наугад.
  8. Повторить тот же сценарий после одной правки. Затем удалить учебные отметки и отдельно проверить fallback, если он нужен продукту.
\n

Ограничения и отрицательный путь

\n

Нативные модули не заменяют сборку во всех проектах. Старый браузер может не поддерживать module scripts. Приложению могут требоваться transpile, polyfill, code splitting, tree shaking или обработка пакетов. Эти задачи принадлежат сборщику и не решаются добавлением type=\"module\".

\n

Даже успешный минимальный пример не доказывает производительность страницы. Он не измеряет размер ответа, кэш, CDN, время CPU и реальный порядок виджетов. Он доказывает только маршрут загрузки простого графа. Учебные логи и import.meta.url нельзя переносить в production без отдельного решения о наблюдаемости.

\n

Если entry загрузился, но DOM не изменился, не объявляйте виноватым URL. Проверьте исключение внутри модуля, наличие элемента, порядок запуска и данные функции. Если два запроса имеют 200, но один ответ — HTML, причина всё ещё на серверной границе. Если native-сценарий не поддерживается целевой средой, отрицательный результат честно ведёт к сборке и fallback, а не к бесконечным правкам пути.

\n

Проверяемый критерий готовности

\n

Проверка готова, когда другой инженер повторяет её с чистой перезагрузки и получает тот же результат. В Network видны entry и все статические зависимости с ожидаемыми URL, допустимыми статусами и JavaScript-ответами. Console не содержит ошибок разрешения, MIME и CORS. DOM содержит ожидаемую строку. Отдельно зафиксирован отрицательный путь: неверный URL даёт обнаруживаемую ошибку, а HTML fallback не принимается за рабочий модуль.

\n

Для production-страницы критерий дополняют целевой браузер, политика cross-origin, нужный fallback и способ доставки. Если эти условия не заданы, готовым считается только учебный маршрут, а не вся система. Такой результат проверяем: он связывает симптом с конкретным запросом, запрос с причиной, а действие — с наблюдаемым изменением.

\n

Проверяемые источники

\n"}