Реализация Import Maps для управления модулями без бандлера
Import Maps — браузерный стандарт, позволяющий управлять разрешением ES-модулей без Webpack, Rollup или Vite. Вместо бандлинга в один файл браузер сам резолвит import 'react' в конкретный URL по карте. Спецификация стабильна с Chrome 89, Firefox 108, Safari 16.4.
Базовый принцип
<!-- Без Import Maps — импорт завершится ошибкой -->
<script type="module">
import React from 'react'; // ❌ bare specifier, браузер не знает URL
</script>
<!-- С Import Maps — работает -->
<script type="importmap">
{
"imports": {
"react": "https://esm.sh/[email protected]",
"react-dom/client": "https://esm.sh/[email protected]/client",
"react/": "https://esm.sh/[email protected]/"
}
}
</script>
<script type="module">
import React from 'react'; // ✅
import { createRoot } from 'react-dom/client'; // ✅
</script>
<script type="importmap"> должен быть объявлен до любых <script type="module">. Один importmap на страницу.
Реальный сценарий: мультистраничный сайт без бандлера
Типичная ситуация: маркетинговый сайт с несколькими интерактивными виджетами. Нет нужды в сложном pipeline — только общий vendor-кэш для React и несколько компонентов.
<!-- layouts/base.html -->
<script type="importmap">
{
"imports": {
"react": "https://esm.sh/[email protected]?dev",
"react-dom": "https://esm.sh/[email protected]?dev",
"react-dom/client": "https://esm.sh/[email protected]/client?dev",
"@/": "/static/js/",
"htm/react": "https://esm.sh/[email protected]/react"
},
"scopes": {
"/admin/": {
"react": "https://esm.sh/[email protected]"
}
}
}
</script>
scopes позволяет задать разные версии библиотек для разных путей — актуально при постепенной миграции.
Модульная структура без бандлера
/static/js/
├── components/
│ ├── Counter.js
│ ├── Tabs.js
│ └── SearchWidget.js
├── utils/
│ ├── api.js
│ └── format.js
└── app.js
// /static/js/components/Counter.js
import React, { useState } from 'react';
export function Counter({ initial = 0 }) {
const [count, setCount] = useState(initial);
return React.createElement('div', null,
React.createElement('button', { onClick: () => setCount(c => c - 1) }, '−'),
React.createElement('span', { style: { padding: '0 12px' } }, count),
React.createElement('button', { onClick: () => setCount(c => c + 1) }, '+'),
);
}
Без JSX — используем React.createElement или htm (tagged template literals как альтернатива JSX без компилятора):
// /static/js/components/SearchWidget.js
import { html } from 'htm/react';
import React, { useState, useCallback } from 'react';
import { debounce } from '@/utils/debounce.js';
export function SearchWidget({ endpoint }) {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const search = useCallback(
debounce(async (q) => {
if (q.length < 2) return;
const res = await fetch(`${endpoint}?q=${encodeURIComponent(q)}`);
setResults(await res.json());
}, 300),
[endpoint]
);
return html`
<div class="search">
<input
type="search"
value=${query}
onInput=${e => { setQuery(e.target.value); search(e.target.value); }}
placeholder="Поиск..."
/>
<ul>
${results.map(r => html`<li key=${r.id}>${r.title}</li>`)}
</ul>
</div>
`;
}
Подключение на страницах
<!-- /pages/catalog.html -->
<div id="search-root" data-endpoint="/api/search"></div>
<script type="module">
import { createRoot } from 'react-dom/client';
import { SearchWidget } from '@/components/SearchWidget.js';
const root = document.getElementById('search-root');
const endpoint = root.dataset.endpoint;
createRoot(root).render(
React.createElement(SearchWidget, { endpoint })
);
</script>
Управление версиями и производительность
CDN esm.sh и jsDelivr кешируют модули aggressively. Для production нужна явная пиннинг-стратегия:
<script type="importmap">
{
"imports": {
"react": "https://esm.sh/stable/[email protected]/es2022/react.mjs",
"react-dom/client": "https://esm.sh/stable/[email protected]/es2022/client.mjs"
}
}
</script>
/stable/ путь у esm.sh гарантирует, что URL не изменится — браузер будет использовать кэш. Без пиннинга esm.sh/react@18 может обновиться и инвалидировать кэш.
Для self-hosted: вендорные модули кладём в /static/vendor/ и версионируем по хэшу содержимого файла:
<script type="importmap">
{
"imports": {
"react": "/static/vendor/react.18.3.1.a4f2c1.mjs",
"react-dom/client": "/static/vendor/react-dom-client.18.3.1.b7e3a2.mjs"
}
}
</script>
Генерация Import Map из lock-файла
Для автоматизации — небольшой Node.js скрипт, который генерирует importmap из package.json:
// scripts/generate-importmap.js
import { readFileSync, writeFileSync } from 'fs';
const pkg = JSON.parse(readFileSync('./package.json', 'utf8'));
const CDN_BASE = 'https://esm.sh';
const imports = {};
for (const [name, version] of Object.entries(pkg.importmap ?? {})) {
const v = version.replace(/[\^~]/, '');
imports[name] = `${CDN_BASE}/${name}@${v}`;
imports[`${name}/`] = `${CDN_BASE}/${name}@${v}/`;
}
const map = JSON.stringify({ imports }, null, 2);
// Вставляем в base-шаблон
let template = readFileSync('./templates/base.html', 'utf8');
template = template.replace(
/<script type="importmap">[\s\S]*?<\/script>/,
`<script type="importmap">\n${map}\n</script>`
);
writeFileSync('./templates/base.html', template);
console.log('Import map обновлён');
Ограничения и когда не стоит использовать
Import Maps не подходят, если нужен: tree-shaking (каждый модуль грузится целиком), TypeScript без транспиляции (.ts расширения браузер не обрабатывает), поддержка Safari до 16.4 и Firefox до 108.
Для простых сайтов без сложного стека — отличная альтернатива Webpack с минимальным tooling. Для приложений с тысячами компонентов, типизацией и hot reload — бандлер всё равно предпочтительнее.
Сроки
Настройка Import Maps для существующего проекта — один день. Разработка генератора importmap из lock-файла и интеграция с деплоем — ещё день-два.







