DarkRiDDeR11 мин

ES-модули в браузере: старт без сборщика

JavaScriptES modulesБраузерПрактика

Страница уже отдала HTML, но кнопка остаётся пустой: модуль не стартовал, а Console показывает ошибку загрузки или разрешения пути. Цена ошибки — не одна сломанная кнопка. Если сразу подменить модуль готовым bundle, причина останется в сервере или адресах и вернётся при следующем экране.

Разберём один вопрос: как запустить небольшой граф ES-модулей прямо в браузере и доказать, что его загрузил именно браузер, а не логика сборщика. Ниже учебная страница с тремя файлами. Она не является трассой настоящего сайта и не обещает поддержку любого старого браузера.

Граница задачи: модульный тег, а не новый bundle

Нативный модуль начинается с тега <script type="module">. Для браузера это другой вид скрипта: он получает entry-файл, находит его статические import, строит граф и загружает зависимости. Внутри модуля верхнеуровневые объявления не становятся случайными свойствами window; связь между файлами описывается импортом и экспортом.

Здесь полезно отделить две вещи. Спецификация ECMAScript задаёт синтаксис и связи модулей. Браузерная среда HTML задаёт, как получить файлы по URL, когда начать вычисление и как применить CORS. Webpack может подготовить другой набор файлов заранее, но его правила не появляются в браузере только от слова import.

НаблюдениеПричина в нативном сценарииПроверкаДействие
После загрузки HTML интерфейс пустойentry-модуль не запросился или не вычислилсяNetwork: есть ли запрос app.js; Console: есть ли ошибка модуляПроверить тег type="module" и точный URL entry
Виден 404 на импортОтносительный адрес указывает не в ту папкуОткрыть в Network фактический Request URLИсправить путь в файле-импортёре, не в HTML наугад
Вместо JavaScript приходит документМаршрут сервера вернул HTML fallbackСверить Response и заголовок Content-TypeОтделить путь ассета от маршрута приложения
Импорт с другого origin не загружаетсяУ модуля действует CORS-проверкаПосмотреть Console и CORS-заголовки ответаНастроить разрешённый origin или отдать модуль с того же origin

Таблица не заменяет чтение ошибки. Например, 404 и CORS иногда выглядят как одинаковый «модуль не работает». Сначала сохраняем фактический URL и текст Console. Только после этого меняем путь или заголовок.

Минимальная учебная страница

Соберём каталог, который можно отдать любым локальным HTTP-сервером. Открывать index.html через file:// не стоит: у файлового URL другой origin, а диагностика CORS и путей станет не похожа на доставку сайта. Пример использует только относительные URL и не требует пакета из node_modules.

<!-- index.html -->
<!doctype html>
<meta charset="utf-8">
<title>ES modules: учебная страница</title>
<main>
  <h1>Статус загрузки</h1>
  <output id="status">ожидание</output>
</main>
<script type="module" src="./assets/app.js"></script>

// assets/message.js
export function message(name) {
  return "модуль " + name + " получен";
}

// assets/app.js
import { message } from "./message.js";

const status = document.querySelector("#status");
status.textContent = message("app.js");
console.log("Учебный entry:", import.meta.url);

Проверка воспроизводима: после ответа сервера браузер должен запросить assets/app.js, затем assets/message.js, а в output появится строка из экспорта. В Console выводится URL самого app.js. Это учебная отметка, а не запись Network конкретного проекта.

Что браузер делает с type="module"

У entry без async браузер загружает сам модуль и его зависимости параллельно с разбором HTML, а вычисляет entry после завершения разбора документа. Поэтому в примере main уже существует к моменту обращения к document.querySelector. Атрибут defer для module-скрипта не добавляет отдельного режима: без async модуль уже ведёт себя как отложенный относительно разбора документа.

Это не значит, что любое действие разрешено писать в верхнем уровне. Модуль зависит от доступности всего его графа: если message.js вернул 404 или неподходящий ответ, app.js не должен считать себя готовым. Поэтому признаком успеха служат и DOM-результат, и два сетевых ответа, и отсутствие ошибки загрузки. Один console.log без Network не доказывает правильный путь.

Если добавить async к module-скрипту, браузер сможет вычислить граф, как только он готов, потенциально до конца разбора HTML. Для виджета, которому нужен элемент в документе, это меняет условие запуска. Не ставим async как ускоритель, пока не проверили, что код не обращается к ещё не разобранной разметке.

Относительный путь читается от файла-импортёра

В примере ./message.js находится внутри assets/app.js, поэтому браузер ищет assets/message.js. Он не считает путь от адреса страницы index.html и не перебирает расширения. Запись ./message означает URL без добавленного .js; если сервер не имеет такого ресурса, запрос закончится 404 или HTML fallback.

// Структура учебного каталога
// /demo/index.html
// /demo/assets/app.js
// /demo/assets/message.js

// В /demo/assets/app.js:
import { message } from "./message.js";

// Браузер запросит /demo/assets/message.js.
// Не /demo/message.js и не /demo/assets/message автоматически.

Такая проверка полезнее правки с несколькими ../ наугад. Открываем ошибочный Request URL, находим в нём путь entry-файла и вычисляем рядом с ним адрес импорта. Если ожидаемый файл лежит в другом месте, меняем спецификатор или структуру каталога. Если файл есть, но ответ содержит HTML, исправляем правило раздачи статических файлов.

Сервер тоже входит в минимальный контракт

Браузер получает модуль через HTTP. Для своего origin ответ должен быть JavaScript-ресурсом, а для другого origin дополнительно пройти CORS. На практике проверяем статус, итоговый URL после redirect и Content-Type. Ошибка MIME или CORS не лечится перестановкой импортов: это уже граница между модулем и серверной конфигурацией.

Не подставляем в native import имя пакета вроде date-fns, если сервер не сделал для него URL-маршрут. В учебной странице используем только ./, ../, абсолютный путь или полный URL. Правила пакетов, aliases и автоматических расширений принадлежат сборщику и будут разобраны отдельно.

Учебный граф нативных ES-модулей: HTML загружает app.js, тот запрашивает message.js, после готовности графа app.js меняет output
Нативный запуск проверяем по трём наблюдениям: entry и зависимость запрошены по правильным URL, а код меняет ожидаемый элемент страницы.

Порядок ручной проверки

  1. Отдать учебный каталог через локальный HTTP-сервер и открыть адрес страницы, а не файл на диске.
  2. В HTML оставить ровно один <script type="module" src="./assets/app.js">; не добавлять bundle «для надёжности».
  3. В Network включить сохранение записей, перезагрузить страницу и записать статус, Request URL и Content-Type для app.js и message.js.
  4. Сверить Console: не должно быть ошибки разрешения, MIME или CORS; затем проверить строку в элементе #status.
  5. Если импорт не найден, считать относительный путь от app.js, а не от адреса вкладки. Исправить один спецификатор и повторить тот же сценарий.
  6. Только после успешного нативного примера переносить схему в страницу приложения и отдельно решать, нужен ли ей fallback для старых браузеров.

Совместимость и ограничения

  • Пример рассчитан на браузер с поддержкой module scripts. Для старого браузера можно держать отдельный classic-артефакт с nomodule; этот fallback надо тестировать как отдельный путь.
  • Код не измеряет скорость и не доказывает порядок в чужом приложении. Он показывает только минимальную форму загрузки и точки наблюдения.
  • Cross-origin модуль требует корректной CORS-политики. Отключение защиты в браузере не является проверкой production-конфигурации.
  • Маршрутизатор SPA может возвращать index.html на неизвестный URL. Для модуля это ошибка раздачи ассета, даже если ответ имеет статус 200.
  • Пример не использует import map, aliases и package resolution. Для 2019 года это намеренное ограничение: путь должен быть виден в самом спецификаторе.

Итог

Нативный ES-модуль стартует не после «подключения JavaScript вообще», а после успешной загрузки графа URL. В первую очередь проверяем тег entry, адреса двух файлов, их HTTP-ответы и DOM-результат. Когда этот маршрут работает без сборщика, становится понятно, где заканчивается браузерный контракт и где начинаются правила конкретного инструмента доставки.

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