{ "index": 320, "slug": "editorial-2019-02-mechanism-es-modules", "title": "ES-модули в браузере и Webpack: где разрешается import", "excerpt": "Одинаковый синтаксис import проходит разные границы. Разбираем, как браузер строит URL графа модулей, что добавляет Webpack и как быстро найти причину 404, MIME-ошибки или «модуль не найден».", "contentHtml": "

На учебном стенде разработчик открывает страницу после сборки и видит один из трёх симптомов: Failed to resolve module specifier, 404 на зависимость или ответ с index.html вместо JavaScript. Иногда после добавления второго тега script приложение запускается повторно. Ошибка выглядит как проблема Webpack, хотя браузер мог получить неверный URL. Бывает и наоборот: путь исправляют в HTML, хотя пакет не разрешился ещё при сборке.

\n

Одна строка import проходит разные правила. Модульный скрипт без сборщика передаёт спецификатор браузеру: браузер строит URL, загружает граф модулей и проверяет HTTP-ответы. Webpack разбирает исходник раньше, применяет свой resolver и выпускает bundle или chunk. Поэтому первый вопрос в диагностике такой: этот import сейчас читает браузер или сборщик?

\n

Две границы одного import

\n

В браузерном сценарии HTML подключает entry-файл как модуль. Пусть браузер получил /demo/assets/main.js, а файл содержит import { apiRoot } from './config.js'. Относительный спецификатор разрешается от URL импортёра. Запрос уйдёт на /demo/assets/config.js; адрес страницы /demo/index.html базой не становится.

\n

Браузер не ищет date-kit в node_modules и не применяет resolve.extensions Webpack. Запись ./config.js — относительный спецификатор с явным расширением. Запись ./config не обязана превратиться в ./config.js. Bare-имя вроде date-kit требует отображения в URL, например через import map, либо должно попасть в результат сборки.

\n

Webpack сначала разрешает модуль по правилам resolve: учитывает alias, поля exports пакета и список расширений. После этого правила module/loaders обрабатывают найденный файл. В опубликованном HTML браузер может увидеть только /assets/app.8f3c.js и не знает, как resolver нашёл date-kit. Ошибка resolver относится к сборке; HTML вместо JavaScript или отказ CORS — к доставке.

\n
Запись importБраузер без сборкиWebpackПервая проверка
./format.jsURL от файла-импортёраРазрешает файл и включает его в bundle или chunkСверить Request URL с путём entry
/assets/format.jsURL от origin страницыОбрабатывает запись по правилам своего resolverСопоставить public path и URL ответа
date-kitНе разрешается без import map или другого отображенияИщет пакет, alias и поля package.jsonИскать причину на этапе сборки
./formatНе обязан добавлять .jsМожет подобрать расширение из resolve.extensionsПроверить точный URI отдельно
\n

Таблица разделяет два этапа, а не задаёт универсальную конфигурацию. Сборщик меняет то, что дойдёт до браузера. Его правила не становятся правилами браузерного модуля только потому, что исходная строка выглядит одинаково.

\n

Сценарий с неверным относительным путём

\n

Минимальный каталог ниже не зависит от Webpack и использует явные расширения. Путь в комментариях нужен, чтобы расчёт можно было повторить.

\n
<!-- /demo/pages/index.html -->\n<script type=\"module\" src=\"../assets/app/main.js\"></script>\n\n// /demo/assets/app/main.js\nimport { apiRoot } from './config.js';\nconsole.log('API:', apiRoot);\n\n// /demo/assets/app/config.js\nexport const apiRoot = '/api/v1';
\n

При открытии страницы Network должен показать сначала /demo/assets/app/main.js, затем /demo/assets/app/config.js. Если заменить строку на import './config', браузер запросит URL без суффикса. Сервер может ответить 404. SPA-fallback может вернуть 200 и тело index.html. В обоих случаях сначала проверяют URL и раздачу статики, а не tree shaking.

\n

Webpack может собрать тот же исходник без расширения, если оно есть в resolve.extensions. Это не противоречие: Webpack применил свой resolver до появления браузерного запроса. Сравните исходную строку, сообщение сборщика и фактический Request URL. Успешная сборка не доказывает, что тот же исходник можно подключить напрямую.

\n

Граф модулей и порядок вычисления

\n

Статический import не выполняется как вызов в середине тела файла. Среда сначала находит зависимости и подготавливает граф, затем вычисляет модули. Поэтому код ниже строки import не может заранее создать глобальную переменную для импортируемого файла.

\n
// config.js\nconsole.log('1. вычисляется config.js');\nexport const apiRoot = '/api/v1';\n\n// main.js\nimport { apiRoot } from './config.js';\nconsole.log('2. main.js получил ' + apiRoot);
\n

В этой учебной странице сначала появится сообщение из config.js, затем сообщение из main.js. Это не делает побочные эффекты верхнего уровня хорошим владельцем запуска. Если два entry меняют один window-объект, порядок становится хрупким. Оставьте одного владельца и передайте ему явную функцию.

\n

У обычного внешнего module script без async загрузка идёт параллельно разбору документа, а выполнение ждёт готовности графа и окончания разбора. Атрибут async разрешает выполнить модуль и его зависимости, когда они готовы, поэтому момент запуска меняется. Webpack может добавить runtime и динамические chunks, но это уже его asset-граф. Эти детали не исправляют неверный HTTP-ответ native-модуля.

\n

Симптомы, причины и действия

\n
СимптомВероятная причинаПроверкаДействие
Failed to resolve module specifier на bare-имениБраузер не получил отображение имени в URLОткрыть исходный HTML и проверить режим запускаИспользовать URL, import map или bundle
404 на ./config.jsПуть считают от HTML или файл не раздаётсяСравнить URL main.js, Request URL и дерево assetsИсправить спецификатор или маршрут статики
200, но MIME-ошибкаСервер вернул HTML или неверный MIME; возможен SPA-fallbackПосмотреть Response, Content-Type и redirectНастроить раздачу JavaScript; не менять alias
Cross-origin import заблокированОтвет не прошёл CORS-проверку module scriptПроверить origin, заголовки и итоговый URLИсправить политику сервера или origin asset
Пакет не найден при сборкеОшибка resolver, alias, версии или exportsСохранить сообщение Webpack и stats той же сборкиИсправить конфигурацию или зависимость до публикации
Инициализация повториласьДва entry, разные URL или второй bootstrapСравнить import.meta.url, теги, iframe и NetworkОставить одного владельца или разделить entry явно
\n

Один текст ошибки не доказывает одну причину. Статус 200 не означает, что модуль загрузился: тело может быть index.html. Два тега с одним URL также не доказывают двойное вычисление: в одном Window браузер может переиспользовать тот же модуль. Сначала зафиксируйте URL и тело ответа, затем делайте вывод о графе.

\n

Проверка URL и module identity

\n

Карта модулей браузера связывает загруженный URL с модулем в текущем контексте. Для временной диагностики учебной страницы можно записать import.meta.url и число запусков. Это наблюдение, а не production-метрика.

\n
// assets/init.js — временная учебная диагностика\nconst url = import.meta.url;\nconst runs = window.__moduleRuns || (window.__moduleRuns = {});\nruns[url] = (runs[url] || 0) + 1;\nconsole.log('[module-check]', { url, count: runs[url] });\n\nexport function startWidget(root) {\n  root.textContent = 'widget started';\n}\n\n// assets/main.js\nimport { startWidget } from './init.js';\nstartWidget(document.querySelector('#widget'));
\n

При одном entry ожидается одна запись с URL вроде .../assets/init.js. Если появились /assets/init.js и /assets/init.js?variant=second, это разные URL. Query-параметр может быть осознанным cache busting и сам по себе не является ошибкой. Риск появляется, когда второй URL незаметно запускает тот же побочный эффект.

\n

Если один точный URL отмечен дважды, проверьте повторную загрузку документа, iframe, classic-скрипт с тем же действием и второй bootstrap. Лог строит гипотезу, но окончательный вывод требует фактических URL, Initiator и ответа сервера. После диагностики временную отметку удалите.

\n
\"Граница
Одна строка import проходит два этапа. Браузер идёт от URL импортёра к сетевому графу. Webpack сначала разрешает зависимости, затем отдаёт браузеру опубликованный asset.
\n

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

\n
  1. Зафиксируйте симптом, адрес страницы и режим запуска: прямой type=\"module\" или production-bundle.
  2. Для браузерного сценария выпишите полный URL entry и каждого failed import. Относительный путь считайте от файла-импортёра.
  3. В Network проверьте статус, итоговый URL после redirect, Content-Type, Response и Initiator для entry и зависимости.
  4. Если спецификатор — bare-имя или не содержит расширения, решите, должен ли его обработать import map или Webpack.
  5. Для сборки сохраните сообщение resolver и stats той же версии lock-файла. Проверьте resolved-путь, alias, chunk и asset, не перенося эти правила в Console браузера.
  6. Если важен порядок запуска, добавьте временную отметку с import.meta.url, сравните URL и удалите отметку после диагноза.
  7. Повторите проверку после одной правки. Успех — чистая загрузка, корректный ответ каждого модуля и один понятный владелец инициализации.
\n

Ограничения

\n

Module scripts требуют поддержки модульных скриптов в браузере. Для старого браузера можно выпустить отдельный classic-артефакт с nomodule, но это второй путь доставки, который проверяют отдельно. Fallback не исправляет неверный URL.

\n

CORS, redirect, CSP, service worker и серверный rewrite меняют наблюдаемую загрузку. Проверяйте итоговый ответ, а не только строку в исходнике. Отключение защиты браузера не подтверждает рабочую политику сервера.

\n

Webpack здесь служит конкретным примером сборщика. Другие инструменты иначе называют chunks и настраивают resolver, но граница та же: до браузера инструмент строит свой граф, после браузер загружает опубликованные URL. Учебные фрагменты показывают механизм и способ наблюдения; они не сообщают production-результат конкретного приложения.

\n

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

\n" }