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, хотя проблема возникла ещё на этапе сборки.
Главный тезис прост: слово import одинаково выглядит в исходнике, но его разрешают разные системы. Native-модуль передаёт спецификатор браузеру. Браузер превращает его в URL, загружает граф и проверяет ответы сервера. Webpack читает этот же исходник раньше, находит пакет по своим правилам и выпускает готовый asset. Поэтому диагноз начинается с вопроса: какой именно файл сейчас читает import — браузер или сборщик?
В native-сценарии HTML подключает entry-файл как модуль. Например, браузер получает /demo/assets/main.js. Внутри файл содержит import { apiRoot } from './config.js'. Спецификатор разрешается относительно URL импортёра, то есть относительно /demo/assets/main.js. Запрос уйдёт на /demo/assets/config.js. Адрес страницы /demo/index.html здесь не является базой.
Браузер не ищет файл в node_modules и не применяет resolve.extensions из Webpack. Для учебного native-сценария ./config.js — URL-подобный адрес, а config не обязан автоматически превратиться в config.js. Bare-спецификатор вроде date-kit тоже не становится URL сам по себе. Для него нужен отдельный механизм отображения, например import map, либо сборка.
Webpack работает до браузера. Он читает import { format } from 'date-kit', проверяет alias, package exports, расширения и loaders, а затем включает код в bundle или отдельный chunk. В HTML браузер может увидеть только /assets/app.8f3c.js. Он не знает, как Webpack нашёл date-kit. Если пакет не разрешился, ошибка относится к сборке. Если asset загрузился, но браузер получил HTML или заблокировал cross-origin-запрос, ошибка относится к доставке.
| Запись import | Native-браузер | Webpack | Проверка |
|---|---|---|---|
./format.js | URL от файла-импортёра | Может оставить путь или включить файл в bundle | Сверить Request URL и путь entry |
/assets/format.js | URL от origin страницы | Может обработать как путь проекта | Сопоставить public path и URL ответа |
date-kit | Не готовый URL без дополнительного отображения | Ищет пакет, alias или поле package.json | Искать причину на этапе сборки |
./format | Не обязан добавлять .js | Может подобрать расширение по resolve | Проверить точный URI отдельно |
Эта таблица разделяет наблюдения, а не предлагает универсальную конфигурацию. Сборщик может изменить любой результат до отправки к браузеру. Но его правила не становятся правилами native-модулей только потому, что исходная строка выглядит одинаково.
\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.
Та же папка в Webpack может собраться без расширения. Это не противоречие. Webpack применил свой resolve.extensions до появления браузерного запроса. Чтобы увидеть границу, сравните исходную строку, сообщение сборщика и фактический Request URL. Нельзя использовать успешное разрешение в bundle как доказательство, что тот же файл можно подключить напрямую.
Статический import не является вызовом, который выполняется в середине тела файла. Среда сначала строит связи графа. Зависимость должна быть найдена и подготовлена до вычисления модуля, который её импортирует. Поэтому строка после import не может заранее создать глобальную переменную для импортируемого файла.
// 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-объект, результат становится хрупким. Надёжнее оставить одного владельца запуска и передать ему явную функцию.
У module script без async браузер учитывает готовность графа при запуске. Атрибут async меняет момент выполнения относительно документа и других скриптов. Webpack может добавить runtime, динамические чанки и собственный порядок загрузки. Эти детали относятся к его asset-графу. Они не меняют смысл ошибки native import и не исправляют неверный HTTP-ответ.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
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 |
Один текст ошибки может иметь несколько причин. Например, статус 200 не доказывает, что модуль загрузился: сервер мог вернуть HTML. Два тега с одинаковым URL тоже не доказывают двойное вычисление. Сначала зафиксируйте URL и тело ответа, затем делайте вывод о графе.
\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 и не является ошибкой сам по себе. Ошибка возникает, когда второй адрес незаметно запускает тот же побочный эффект.
Если один URL отмечен дважды, проверяйте не только module map. Посмотрите iframe, повторную загрузку документа, classic-скрипт с тем же действием и второй bootstrap. Лог помогает построить гипотезу. Окончательный вывод требует фактических URL, initiator и ответа сервера.
\ntype=\"module\" или production-bundle.Content-Type, Response и Initiator для entry и зависимости.import.meta.url, сравните URL и уберите отметку после диагноза.Native-модули требуют браузерной поддержки module scripts. Для старого браузера можно выпускать отдельный classic-артефакт с nomodule, но это второй путь доставки, который проверяют отдельно. Наличие fallback не исправляет неверный native URL.
CORS, redirect, CSP, service worker и серверный rewrite могут изменить наблюдаемую загрузку. Проверяйте итоговый ответ, а не только исходную строку в файле. Отключение защиты браузера не подтверждает рабочую политику сервера.
\nWebpack здесь служит конкретным примером сборщика. Другие инструменты иначе называют chunks и настраивают resolver, но граница сохраняется: до браузера инструмент строит свой граф, после браузер загружает опубликованные URL. Учебные фрагменты показывают механизм и форму наблюдения. Они не сообщают production-результаты и не заменяют трассу конкретного приложения.
\nНа учебном стенде разработчик открывает страницу после сборки и видит один из трёх симптомов: Failed to resolve module specifier, 404 на зависимость или ответ с index.html вместо JavaScript. Иногда после добавления второго тега script приложение запускается повторно. Ошибка выглядит как проблема Webpack, хотя браузер мог получить неверный URL. Бывает и наоборот: путь исправляют в HTML, хотя пакет не разрешился ещё при сборке.
Одна строка import проходит разные правила. Модульный скрипт без сборщика передаёт спецификатор браузеру: браузер строит URL, загружает граф модулей и проверяет HTTP-ответы. Webpack разбирает исходник раньше, применяет свой resolver и выпускает bundle или chunk. Поэтому первый вопрос в диагностике такой: этот import сейчас читает браузер или сборщик?
В браузерном сценарии HTML подключает entry-файл как модуль. Пусть браузер получил /demo/assets/main.js, а файл содержит import { apiRoot } from './config.js'. Относительный спецификатор разрешается от URL импортёра. Запрос уйдёт на /demo/assets/config.js; адрес страницы /demo/index.html базой не становится.
Браузер не ищет date-kit в node_modules и не применяет resolve.extensions Webpack. Запись ./config.js — относительный спецификатор с явным расширением. Запись ./config не обязана превратиться в ./config.js. Bare-имя вроде date-kit требует отображения в URL, например через import map, либо должно попасть в результат сборки.
Webpack сначала разрешает модуль по правилам resolve: учитывает alias, поля exports пакета и список расширений. После этого правила module/loaders обрабатывают найденный файл. В опубликованном HTML браузер может увидеть только /assets/app.8f3c.js и не знает, как resolver нашёл date-kit. Ошибка resolver относится к сборке; HTML вместо JavaScript или отказ CORS — к доставке.
| Запись import | Браузер без сборки | Webpack | Первая проверка |
|---|---|---|---|
./format.js | URL от файла-импортёра | Разрешает файл и включает его в bundle или chunk | Сверить Request URL с путём entry |
/assets/format.js | URL от origin страницы | Обрабатывает запись по правилам своего resolver | Сопоставить public path и URL ответа |
date-kit | Не разрешается без import map или другого отображения | Ищет пакет, alias и поля package.json | Искать причину на этапе сборки |
./format | Не обязан добавлять .js | Может подобрать расширение из resolve.extensions | Проверить точный URI отдельно |
Таблица разделяет два этапа, а не задаёт универсальную конфигурацию. Сборщик меняет то, что дойдёт до браузера. Его правила не становятся правилами браузерного модуля только потому, что исходная строка выглядит одинаково.
\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.
Webpack может собрать тот же исходник без расширения, если оно есть в resolve.extensions. Это не противоречие: Webpack применил свой resolver до появления браузерного запроса. Сравните исходную строку, сообщение сборщика и фактический Request URL. Успешная сборка не доказывает, что тот же исходник можно подключить напрямую.
Статический import не выполняется как вызов в середине тела файла. Среда сначала находит зависимости и подготавливает граф, затем вычисляет модули. Поэтому код ниже строки import не может заранее создать глобальную переменную для импортируемого файла.
// 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-объект, порядок становится хрупким. Оставьте одного владельца и передайте ему явную функцию.
У обычного внешнего module script без async загрузка идёт параллельно разбору документа, а выполнение ждёт готовности графа и окончания разбора. Атрибут async разрешает выполнить модуль и его зависимости, когда они готовы, поэтому момент запуска меняется. Webpack может добавить runtime и динамические chunks, но это уже его asset-граф. Эти детали не исправляют неверный HTTP-ответ native-модуля.
| Симптом | Вероятная причина | Проверка | Действие |
|---|---|---|---|
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 явно |
Один текст ошибки не доказывает одну причину. Статус 200 не означает, что модуль загрузился: тело может быть index.html. Два тега с одним URL также не доказывают двойное вычисление: в одном Window браузер может переиспользовать тот же модуль. Сначала зафиксируйте URL и тело ответа, затем делайте вывод о графе.
Карта модулей браузера связывает загруженный URL с модулем в текущем контексте. Для временной диагностики учебной страницы можно записать import.meta.url и число запусков. Это наблюдение, а не production-метрика.
// 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 незаметно запускает тот же побочный эффект.
Если один точный URL отмечен дважды, проверьте повторную загрузку документа, iframe, classic-скрипт с тем же действием и второй bootstrap. Лог строит гипотезу, но окончательный вывод требует фактических URL, Initiator и ответа сервера. После диагностики временную отметку удалите.
\ntype=\"module\" или production-bundle.Content-Type, Response и Initiator для entry и зависимости.import.meta.url, сравните URL и удалите отметку после диагноза.Module scripts требуют поддержки модульных скриптов в браузере. Для старого браузера можно выпустить отдельный classic-артефакт с nomodule, но это второй путь доставки, который проверяют отдельно. Fallback не исправляет неверный URL.
CORS, redirect, CSP, service worker и серверный rewrite меняют наблюдаемую загрузку. Проверяйте итоговый ответ, а не только строку в исходнике. Отключение защиты браузера не подтверждает рабочую политику сервера.
\nWebpack здесь служит конкретным примером сборщика. Другие инструменты иначе называют chunks и настраивают resolver, но граница та же: до браузера инструмент строит свой граф, после браузер загружает опубликованные URL. Учебные фрагменты показывают механизм и способ наблюдения; они не сообщают production-результат конкретного приложения.
\n