diff --git a/editorial/agent-rewrites/320.json b/editorial/agent-rewrites/320.json index 3b75e57..2edf07d 100644 --- a/editorial/agent-rewrites/320.json +++ b/editorial/agent-rewrites/320.json @@ -3,5 +3,5 @@ "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 одинаково выглядит в исходнике, но его разрешают разные системы. Native-модуль передаёт спецификатор браузеру. Браузер превращает его в URL, загружает граф и проверяет ответы сервера. Webpack читает этот же исходник раньше, находит пакет по своим правилам и выпускает готовый asset. Поэтому диагноз начинается с вопроса: какой именно файл сейчас читает import — браузер или сборщик?

\n

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

\n

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

\n

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

\n

Webpack работает до браузера. Он читает import { format } from 'date-kit', проверяет alias, package exports, расширения и loaders, а затем включает код в bundle или отдельный chunk. В HTML браузер может увидеть только /assets/app.8f3c.js. Он не знает, как Webpack нашёл date-kit. Если пакет не разрешился, ошибка относится к сборке. Если asset загрузился, но браузер получил HTML или заблокировал cross-origin-запрос, ошибка относится к доставке.

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

Эта таблица разделяет наблюдения, а не предлагает универсальную конфигурацию. Сборщик может изменить любой результат до отправки к браузеру. Но его правила не становятся правилами native-модулей только потому, что исходная строка выглядит одинаково.

\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. Если написать в main.js import './config', браузер отправит запрос к адресу без суффикса. Сервер может ответить 404. SPA-правило может вернуть статус 200 и тело index.html. В обоих случаях ошибка находится в URL или в раздаче ассетов, а не в tree shaking.

\n

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

\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, динамические чанки и собственный порядок загрузки. Эти детали относятся к его asset-графу. Они не меняют смысл ошибки native import и не исправляют неверный HTTP-ответ.

\n

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

\n
СимптомПричинаПроверкаДействие
Failed to resolve module specifier на bare-имениNative-браузер не получил отображение имени пакета в URLОткрыть исходный HTML и Network; проверить тип запускаИспользовать 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-проверку модуляПроверить origin, заголовки и фактический URL ответаИсправить политику сервера или разместить asset в нужном origin
Пакет не найден при сборкеОшибка resolver, alias, версии или package exportsСохранить сообщение Webpack и stats той же сборкиИсправить конфигурацию или зависимость до публикации bundle
Инициализация повториласьДва entry, разные URL модуля или второй документСравнить import.meta.url, теги, iframe и NetworkОставить одного владельца запуска или явно разделить entry
\n

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

\n

Учебная проверка URL и identity

\n

Следующий фрагмент нужен только для локальной учебной страницы. Он показывает URL, который среда передала модулю, и число отметок для этого URL. Это не production-метрика и не замена Network.

\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, это два разных адреса, а значит, их нужно рассматривать как две module identity. Query может быть осознанным cache busting и не является ошибкой сам по себе. Ошибка возникает, когда второй адрес незаметно запускает тот же побочный эффект.

\n

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

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

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

\n
  1. Зафиксируйте симптом, адрес страницы и способ запуска: прямой type=\"module\" или production-bundle.
  2. Для native-сценария выпишите полный 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

Native-модули требуют браузерной поддержки module scripts. Для старого браузера можно выпускать отдельный classic-артефакт с nomodule, но это второй путь доставки, который проверяют отдельно. Наличие fallback не исправляет неверный native URL.

\n

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

\n

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

\n

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

\n" + "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" }