Реалізація 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 потребується явна pinning-стратегія:
<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 не змінюється — браузер буде використовувати кеш. Без pinningu 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-файлу та інтеграція з деплоєм — ще день-два.







