Страница уже отдала 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 и автоматических расширений принадлежат сборщику и будут разобраны отдельно.
Порядок ручной проверки
- Отдать учебный каталог через локальный HTTP-сервер и открыть адрес страницы, а не файл на диске.
- В HTML оставить ровно один
<script type="module" src="./assets/app.js">; не добавлять bundle «для надёжности». - В Network включить сохранение записей, перезагрузить страницу и записать статус, Request URL и
Content-Typeдляapp.jsиmessage.js. - Сверить Console: не должно быть ошибки разрешения, MIME или CORS; затем проверить строку в элементе
#status. - Если импорт не найден, считать относительный путь от
app.js, а не от адреса вкладки. Исправить один спецификатор и повторить тот же сценарий. - Только после успешного нативного примера переносить схему в страницу приложения и отдельно решать, нужен ли ей 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-результат. Когда этот маршрут работает без сборщика, становится понятно, где заканчивается браузерный контракт и где начинаются правила конкретного инструмента доставки.
Проверяемые источники
- 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 в браузере
- Fetch Standard: CORS protocol and credentials — ограничения межсайтовой загрузки, которые относятся и к импортам модулей