Нативные ES-модули в браузере

Нативные ES-модули (ECMAScript Modules, ESM) — стандартная модульная система JavaScript, встроенная непосредственно в язык и поддерживаемая современными браузерами. До появления ESM разработчики использовали различные подходы к разделению кода:

  • глобальные переменные;
  • паттерн IIFE;
  • AMD;
  • CommonJS;
  • UMD;
  • сторонние загрузчики модулей.

Появление ES-модулей стандартизировало работу с зависимостями и позволило браузеру понимать структуру приложения без дополнительных библиотек.

Главная идея ESM — разделение программы на независимые файлы-модули с явно объявленными экспортами и импортами.

Пример простого модуля:

// math.js
export function sum(a, b) {
    return a + b;
}

Импорт:

// main.js
import { sum } from './math.js';

console.log(sum(2, 3));

Браузер самостоятельно загружает зависимости и выстраивает граф модулей.


Подключение модулей в браузере

Для работы ES-модулей используется атрибут type="module".

<script type="module" src="./main.js"></script>

После указания type="module" браузер начинает интерпретировать файл как ESM-модуль.

Это изменяет поведение Jav * aScript:

  • появляется поддержка import/export;
  • код работает в собственной модульной области видимости;
  • автоматически включается строгий режим (use strict);
  • зависимости загружаются асинхронно;
  • выполнение откладывается до загрузки импортов.

Изолированная область видимости

Обычные <script> помещают переменные в глобальный объект window.

<script>
    const message = 'hello';
</script>

Переменная становится глобальной.

Модули работают иначе:

// module.js
const message = 'hello';

message недоступна вне модуля.

Это устраняет множество проблем:

  • конфликты имен;
  • случайное переопределение переменных;
  • загрязнение глобальной области;
  • сложность поддержки больших проектов.

Экспорт значений

Именованный экспорт

Наиболее распространённый вариант.

export const API_URL = '/api';

export function request() {}

export class User {}

Импорт:

import { API_URL, request, User } from './api.js';

Экспорт в конце файла

const API_URL = '/api';

function request() {}

export { API_URL, request };

Такой стиль часто используется для улучшения читаемости.


Экспорт с переименованием

const API_URL = '/api';

export { API_URL as BASE_URL };

Импорт:

import { BASE_URL } from './api.js';

Экспорт по умолчанию

Модуль может иметь один default export.

export default function initApp() {
    console.log('init');
}

Импорт:

import initApp from './app.js';

Имя при импорте выбирается произвольно.

import start from './app.js';

Комбинирование default и named export

export default class App {}

export const version = '1.0.0';

Импорт:

import App, { version } from './app.js';

Импорт всего модуля

Иногда требуется получить объект со всеми экспортами.

import * as math from './math.js';

console.log(math.sum(2, 5));

Все экспорты становятся свойствами объекта math.


Переименование импортов

import { request as apiRequest } from './api.js';

Полезно при конфликтах имен.


Реэкспорт модулей

Модули могут переэкспортировать содержимое других файлов.

export { sum } from './math.js';
export { request } from './api.js';

Либо полностью:

export * from './utils.js';

Это позволяет создавать единые точки входа.

Пример:

// index.js
export * from './math.js';
export * from './date.js';
export * from './network.js';

Импорт:

import { sum, formatDate } from './index.js';

Статическая структура модулей

Одно из важнейших преимуществ ESM — статический анализ.

Импорты определяются до выполнения программы.

import { sum } from './math.js';

Браузер заранее знает:

  • какие файлы нужны;
  • какие зависимости существуют;
  • какие экспорты используются.

Это даёт множество преимуществ:

  • tree-shaking;
  • оптимизацию загрузки;
  • кэширование;
  • предсказуемость выполнения;
  • анализ зависимостей сборщиками.

Живые привязки (Live Bindings)

ES-модули экспортируют не копию значения, а ссылку.

// counter.js
export let count = 0;

export function increment() {
    count++;
}
// app.js
import { count, increment } from './counter.js';

console.log(count);

increment();

console.log(count);

После вызова increment() значение count изменится и в импортирующем модуле.

Это называется live bindings.


Строгий режим

Все ES-модули автоматически работают в strict mode.

x = 10;

Код вызовет ошибку:

ReferenceError

Повторное объявление переменных также запрещено.


Отложенное выполнение

Модульные скрипты ведут себя аналогично defer.

<script type="module" src="./main.js"></script>

Браузер:

  1. загружает HTML;
  2. скачивает модуль;
  3. загружает зависимости;
  4. выполняет код после парсинга документа.

Это улучшает производительность страницы.


CORS и ограничения модулей

ES-модули загружаются через механизм Fetch API и подчиняются политике CORS.

Файл нельзя просто открыть через:

file://

Во многих случаях модуль не загрузится.

Необходим HTTP-сервер:

npx vite

или:

python -m http.server

MIME-типы

Браузер проверяет MIME-тип модулей.

Сервер должен возвращать:

Content-Type: application/javascript

Некорректный MIME-тип приводит к ошибкам загрузки.


Последовательность загрузки модулей

Рассмотрим структуру:

main.js
 ├── api.js
 └── ui.js
      └── dom.js

Когда браузер загружает main.js:

  1. анализируются импорты;
  2. начинается параллельная загрузка зависимостей;
  3. строится граф модулей;
  4. зависимости выполняются раньше импортирующего файла.

Порядок выполнения:

dom.js
ui.js
api.js
main.js

Кэширование модулей

Модуль выполняется только один раз.

import './config.js';
import './config.js';

Файл config.js не будет выполнен повторно.

Все импорты получают один и тот же экземпляр модуля.


Циклические зависимости

ESM поддерживает циклические импорты.

// a.js
import { b } from './b.js';

export const a = 'A';
// b.js
import { a } from './a.js';

export const b = 'B';

Однако сложные циклы могут вызывать:

  • временно неопределённые значения;
  • ошибки инициализации;
  • проблемы порядка выполнения.

Поэтому циклические зависимости считаются архитектурным антипаттерном.


Динамический импорт

Помимо статического импорта существует динамический.

const module = await import('./math.js');

import() возвращает Promise.

import('./dialog.js')
    .then(module => {
        module.openDialog();
    });

Назначение динамического импорта

Динамический импорт используется для:

  • ленивой загрузки;
  • code splitting;
  • загрузки тяжёлых библиотек;
  • уменьшения стартового bundle;
  • условного подключения модулей.

Пример:

button.addEventListener('click', async () => {
    const chart = await import('./chart.js');

    chart.render();
});

Модуль загрузится только после клика.


Top-level await

Современные ES-модули поддерживают await на верхнем уровне.

const response = await fetch('/api/data');
const data = await response.json();

console.log(data);

Без обёртки в async-функцию.

Это особенно полезно:

  • при инициализации конфигурации;
  • загрузке переводов;
  • авторизации;
  • получении данных до старта приложения.

Import Maps

Import Maps позволяют задавать алиасы модулей прямо в браузере.

<script type="importmap">
{
    "imports": {
        "lodash": "/libs/lodash.js"
    }
}
</script>

Импорт:

import _ from 'lodash';

Без import maps браузер требует относительные или абсолютные пути.


Проблема расширений файлов

Браузер требует точный путь.

Неверно:

import { sum } from './math';

Верно:

import { sum } from './math.js';

В отличие от Node.js или сборщиков браузер не пытается автоматически искать расширение.


URL-адреса модулей

Модуль идентифицируется URL-адресом.

Разные query-параметры считаются разными модулями.

import './config.js?v=1';
import './config.js?v=2';

Это приведёт к двойной загрузке.


Атрибут nomodule

Для поддержки старых браузеров используется fallback.

<script type="module" src="main.js"></script>
<script nomodule src="legacy.js"></script>

Современные браузеры загрузят main.js, старые — legacy.js.


Производительность ES-модулей

Нативные модули обладают рядом преимуществ:

Параллельная загрузка

Браузер загружает зависимости одновременно.

Кэширование

Модули повторно не выполняются.

Tree-shaking

Неиспользуемый код может удаляться сборщиками.

Lazy Loading

Поддерживается через import().

Разделение кода

Крупное приложение можно разбить на независимые части.


Ограничения нативных ES-модулей

Несмотря на преимущества, у ESM есть ограничения.

Большое количество HTTP-запросов

При отсутствии сборщика приложение может загружать сотни файлов.

Отсутствие трансформации

Браузер не понимает:

  • TypeScript;
  • JSX;
  • SCSS;
  • Vue SFC;
  • нестандартный синтаксис.

Отсутствие оптимизации production-уровня

Нет:

  • минификации;
  • tree-shaking на уровне браузера;
  • объединения файлов;
  • оптимизации ассетов.

Именно поэтому инструменты вроде Vite используют нативные ES-модули в режиме разработки, но создают production-сборку через bundling.


Роль ES-модулей в Vite

Архитектура Vite напрямую основана на возможностях нативного ESM.

Во время разработки Vite:

  • не собирает весь проект в bundle;
  • запускает dev-сервер;
  • отдает файлы как ES-модули;
  • преобразует импорты «на лету».

Пример:

import { createApp } from 'vue';

Vite преобразует импорт в URL внутри node_modules.

Браузер получает уже готовые ES-модули и загружает их напрямую.

Это обеспечивает:

  • мгновенный старт dev-сервера;
  • быстрый HMR;
  • отсутствие полной пересборки;
  • высокую скорость разработки.

HMR и ES-модули

Hot Module Replacement тесно связан с ESM.

При изменении файла:

  1. Vite определяет изменённый модуль;
  2. отправляет обновление браузеру;
  3. браузер повторно импортирует только изменённый модуль.

Нет необходимости перезагружать всю страницу или пересобирать приложение целиком.


Отличие ES-модулей от CommonJS

ES-модули

import { sum } from './math.js';

export function calc() {}

Особенности:

  • статическая структура;
  • асинхронная загрузка;
  • поддержка браузером;
  • tree-shaking;
  • live bindings.

CommonJS

const math = require('./math');

module.exports = {};

Особенности:

  • синхронная загрузка;
  • ориентированность на Node.js;
  • динамическая структура;
  • отсутствие полноценного tree-shaking.

Разрешение импортов в браузере

Браузер умеет работать только с URL.

Корректно:

import './utils.js';
import '/src/app.js';
import 'https://cdn.site.com/lib.js';

Некорректно без import maps:

import 'lodash';

Именно поэтому Vite и другие инструменты преобразуют bare imports.


ES-модули и CDN

Современные CDN могут отдавать библиотеки как ESM.

Пример:

import { debounce } from 'https://esm.sh/lodash-es';

Популярные ESM-CDN:

  • esm.sh
  • skypack.dev
  • jspm.io
  • unpkg.com

Это позволяет запускать небольшие приложения вообще без сборки.


Безопасность модулей

Модульная система улучшает безопасность кода:

  • уменьшается количество глобальных объектов;
  • снижается вероятность конфликтов;
  • зависимости становятся явными;
  • структура приложения упрощается.

Кроме того, браузер строго контролирует происхождение модулей через CORS.


Практическая структура модульного приложения

Пример структуры:

src/
├── main.js
├── api/
│   ├── client.js
│   └── users.js
├── ui/
│   ├── modal.js
│   └── tabs.js
├── utils/
│   ├── date.js
│   └── math.js
└── store/
    └── state.js

main.js:

import { createTabs } from './ui/tabs.js';
import { fetchUsers } from './api/users.js';
import { formatDate } from './utils/date.js';

Каждый модуль отвечает только за собственную область ответственности.


Эволюция фронтенд-разработки после появления ESM

Нативные ES-модули существенно изменили экосистему JavaScript.

До ESM большинство приложений требовало:

  • Webpack;
  • Babel;
  • сложной конфигурации;
  • bundling даже в development-режиме.

После появления ESM стали возможны:

  • dev-серверы без bundle;
  • мгновенный запуск приложений;
  • быстрая перезагрузка модулей;
  • упрощение tooling-инфраструктуры.

Именно на этой архитектуре построены современные инструменты:

  • Vite
  • Snowpack
  • Nativefier
  • современные браузерные среды выполнения JavaScript.