DarkRiDDeR12 мин

ES-модули: почему import в браузере не равен import в Webpack

JavaScriptES modulesWebpackРазбор

Сборка проходит с import { format } from "date-kit", а страница с тем же исходным файлом отвечает Failed to resolve module specifier. Цена ошибки — двойная конфигурация: разработчик меняет Webpack, хотя браузер в этот момент вообще не получил URL зависимости.

Разберём один вопрос: почему одинаковый синтаксис import имеет разную доставку в нативном браузере и после Webpack. Учебные файлы ниже показывают причинную модель. Они не являются логом конкретной сборки и не утверждают, что любой bundler работает одинаково.

Один синтаксис, две разные границы

ECMAScript описывает, как модуль объявляет зависимость и что он импортирует из другого модуля. Браузер получает этот спецификатор и должен превратить его в URL ресурса. Webpack читает исходный граф раньше браузера, применяет свою конфигурацию и отдаёт уже созданные assets. В браузере после сборки часто нет исходной строки import "date-kit": есть entry-файл и чанки, адреса которых сгенерировал bundler.

Поэтому вопрос «почему import не работает» нужно разрезать. Если в исходнике страницы используется native module, проверяем URL-спецификатор, сервер и браузер. Если исходник прошёл через сборку, проверяем resolved-путь в конфигурации и выданный asset. Нельзя объявлять алиас Webpack свойством браузера или считать ошибку URL следствием tree shaking.

Запись importЧто видит браузер без дополнительного отображенияЧто может сделать WebpackПроверка границы
import "./format.js"Относительный URL от файла-импортёраОставить путь или включить файл в bundleNetwork показывает URL от importer.js
import "/assets/format.js"URL от origin сайтаОбработать как путь проекта по своей конфигурацииСравнить URL в браузере с public path сборщика
import "date-kit"Не URL-спецификатор для учебного native-сценарияНайти пакет, alias или поле package.jsonНе открывать Network до появления реального URL
import "./format"Запросить URL без автоматического .jsМожет подобрать расширение по настройке resolveПроверить точный URI и правило resolve отдельно

В последней колонке специально нет универсального рецепта. Конфигурация Webpack может подставлять aliases, расширения, loader или путь к пакету; браузер без отдельного механизма таких правил не получает. Для исторического сценария 2019 года в нативных примерах используем URL-подобные спецификаторы с расширением.

Учебная папка: URL считается от импортёра

Посмотрим на дерево, в котором адрес страницы и адрес модуля лежат в разных папках. Главная ошибка здесь — мысленно вычислять ./config.js от index.html. Его базой служит URL файла main.js, потому что именно он содержит import.

<!-- /demo/pages/index.html -->
<script type="module" src="../assets/app/main.js"></script>

// /demo/assets/app/main.js
import { apiRoot } from "./config.js";
console.log("API:", apiRoot);

// /demo/assets/app/config.js
export const apiRoot = "/api/v1";

// Browser request: /demo/assets/app/config.js

Если заменить строку на import { apiRoot } from "./config", нативный браузер не обязан угадать суффикс. Он идёт за URL /demo/assets/app/config. Сервер может честно вернуть 404 или ошибочно вернуть HTML страницы. Webpack, напротив, может по своей настройке найти config.js ещё на этапе сборки. Это два разных момента времени и два разных набора правил.

Такую разницу удобно видеть в двух коротких проверках. Для native-страницы открываем Network и копируем Request URL импорта. Для Webpack-сценария смотрим, какой модуль попал в статистику сборки и какой asset выдан в HTML. Результаты не спорят друг с другом: они отвечают на разные вопросы.

Порядок вычисления задаёт граф, а не строка после import

Статический import не является вызовом, который выполняется в середине тела модуля. Сначала среда подготавливает связи графа, затем зависимые модули вычисляются до модуля, который от них зависит. В учебном примере сообщение из config.js появится до сообщения из main.js, даже если строка import записана первой.

// config.js
console.log("1. вычисляется config.js");
export const apiRoot = "/api/v1";

// main.js
import { apiRoot } from "./config.js";
console.log("2. main.js получил " + apiRoot);

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

У module-скрипта без async вычисление ждёт конца разбора документа и готового графа. С async момент запуска меняется. Webpack может добавить свой runtime, динамические чанки и порядок подключения assets, но это его доставка; стандарт браузера не знает о splitChunks, alias или ProvidePlugin.

Что происходит после Webpack

Сборщик строит граф на машине разработки или CI: разрешает имена пакетов, применяет конфигурацию и записывает результат в один или несколько файлов. Затем браузер загружает уже эти файлы по URL из HTML или runtime. Поэтому диагностировать нужно на правильной стороне границы. Ошибка вида «модуль не найден при сборке» живёт в решении Webpack. Ошибка native module в Console живёт в URL, MIME, CORS или исходном спецификаторе страницы.

Полезный практический приём — записать в issue четыре поля: исходная строка import, кто её читает первым, какой URL или asset получился и на каком шаге появился сбой. Этого достаточно, чтобы не смешать причины. Фраза «браузер не нашёл пакет в node_modules» почти всегда указывает, что в разговор незаметно попала логика bundler.

Схема границы: браузер переводит URL-подобный import в сетевой запрос от URL импортёра, а Webpack заранее разрешает пакет и создаёт asset
Одинаковая строка import проходит разные этапы: нативная страница идёт к URL, а сборщик сначала строит свой граф и отдаёт готовые assets.

Диагностический маршрут без смешения инструментов

  1. Зафиксировать, запускается ли файл напрямую как type="module" или входит в production-bundle. Не использовать оба сценария одновременно для одной проверки.
  2. Для native-страницы выписать полный URL entry и каждого failed import из Network. Считать относительный адрес от модуля-импортёра.
  3. Проверить, что спецификатор содержит нужный .js и не является bare-именем пакета, если для него не создан отдельный URL-маршрут.
  4. Для Webpack-сценария получить stats или сообщение компилятора на той же версии lock-файла. Найти resolved-путь, alias и asset, не переносить эти правила в browser Console.
  5. Если порядок важен, сделать учебный лог из зависимого модуля и entry. Затем убрать побочный эффект из верхнего уровня либо оставить один явный bootstrap.
  6. Повторить после одной правки. Готовность: у native-страницы есть корректный URL и ответ, у сборки есть осмысленный resolved-путь и загруженный asset.

Ограничения и исторический контекст

  • Текст описывает статические import 2019 года и намеренно не предлагает современные import maps как решение для исторической страницы. В нативном примере путь должен быть виден в исходнике.
  • Webpack — конкретный пример bundler. Другой инструмент может иначе называть chunks и по-другому разрешать aliases, но это всё равно отдельный этап до браузера.
  • Порядок зависимость → потребитель не освобождает от ошибок циклического импорта. Если два файла ждут инициализации друг друга, нужно упростить граф или вынести запуск в явную функцию.
  • CORS, MIME и rewrite сервера способны прервать native-модуль до вычисления. Правильный alias в сборщике их не исправит.
  • Учебные console-сообщения показывают форму наблюдения. Они не являются измерением задержки или browser trace чужого приложения.

Итог

Синтаксис import общий, но ответственность разная. Браузер резолвит URL от импортёра и загружает модульный граф. Webpack заранее находит пакеты и выпускает assets. Если сначала назвать сторону границы, ошибка перестаёт быть загадочным «ES-модули не работают»: остаётся конкретная проверка URL, графа, конфигурации или порядка инициализации.

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

  • ECMAScript 2019: Modules — нормативная модель Module Record, статических import/export и выполнения связанного графа модулей
  • HTML Living Standard: the script element — тип module, загрузка графа зависимостей, отличие async и nomodule, CORS для внешних модулей
  • HTML Living Standard: JavaScript module scripts — module map, URL-идентичность модуля и разрешение module specifier в браузере
  • URL Standard — модель URL, на которой основано разрешение относительных адресов модулей