{ "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, хотя пакет не разрешился ещё при сборке.
Одна строка 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