

1/1/1970
Як я вирішив проблему каскадної зміни хешів за допомогою Import Maps
Привіт! Ця проблема переслідувала мене вже понад 5 років, але взявся я за неї тільки зараз, бо вона доросла до стану, коли ігнорувати її вже не виходило. Варто було змінити один символ в якомусь файлі - і половина JavaScript-файлів у білді отримувала нові імена з хешами, хоча їхній реальний вміст взагалі не змінювався. Це призводило до зайвої інвалідації кешу, майже унеможливлювало зрозуміти, що саме змінилося між білдами, а ще гірше - ламало білди на Cloudflare Pages через ліміт на кількість файлів.
Нижче я розкладу по поличках саму проблему, поясню, чому готові рішення мені не підійшли, і розповім, як я зробив власний плагін для Vite з Import Maps, який закрив це питання раз і назавжди.
Проблема: каскадні зміни хешів
Vite використовує хешування на основі вмісту для продакшн-білдів. Коли ти збираєш застосунок, кожен JavaScript-файл отримує в імені хеш, який залежить від його вмісту. Якщо button.tsx компілюється в button-abc12345.js, а потім вміст змінюється, файл стає button-def45678.js. Це чудово для оновлення кешу: користувачі отримують новий файл, щойно він змінився.
Проблема починається, коли файл A імпортує файл B. Наприклад, у тебе є:
// main.js
import { Button } from "./button-abc12345.js";
Коли button.tsx змінюється, Vite генерує button-def45678.js. Але тоді змінюється і main.js, бо всередині нього є рядок "./button-abc12345.js", який тепер неправильний. Тому main.js теж отримує новий хеш, хоча логіка самого main.js взагалі не змінювалась.
І це каскадом розлітається по всьому графу залежностей. Змінив одну утилітну функцію - і раптом половина js-файлів отримує нові хеші. У моєму випадку зміна одного символу в useBackgroundMusic.ts призводила до перехешування понад 500 файлів.
У реальному житті це боліло відчутно. Ми пакуємо разом 8 версій асетів з попередніх білдів, щоб користувачі на трохи застарілих версіях клієнта все ще могли запускати свою версію, коли ми викочуємо нову на Cloudflare Pages. Але в Cloudflare Pages є ліміт у 20 000 файлів, і ми почали впиратися в нього через нашу попередню зміну з i18n, яка сильно збільшила кількість файлів, що ми створюємо.
Розв'язання проблеми каскадних хешів дозволяє нам зберігати набагато більше минулих білдів і не впиратися в ці обмеження, бо тепер більшості файлів просто не потрібно змінюватися. Це також зменшує шанс того, що користувач на старішому білді отримає помилку, адже значно ймовірніше, що він запросить файл, який тепер не змінюється і який у нас є в наявності.
Чому не [альтернативні рішення]?
Коли я вперше сів розв'язувати цю проблему, я розглянув кілька підходів. Жоден з них мене повністю не влаштував.
Post-build скрипти
Перша ідея була проста: написати post-build скрипт, який би нормалізував усі шляхи імпортів, перехешував файли і оновив посилання. Звучало досить прямо: просто зробити regex-пошук і замінити імена з хешами на стабільні, а потім заново порахувати хеші.
Я відкинув цей підхід через ризик хитрих багів і можливе отруєння кешу. Хоча ми й зберігаємо минулі білди в Cloudflare Pages, ризик неузгодженого кешу того не вартий. Скрипт, який змінює файли вже після білда, може створити тонкі баги, що проявлятимуться тільки в продакшені, а відлагоджувати таке суцільний жах.
Vite manualChunks
Інший варіант був такий: використати в Vite опцію manualChunks, щоб відділити стабільний код (на кшталт node_modules) від нестабільного (бізнес-логіка). Ідея в тому, що вендорний код змінюється рідше, тож каскад буде меншим.
Але кореневу проблему це не прибирає, тільки трохи її пом'якшує. Усередині чанків з бізнес-логікою каскадні хеші нікуди не зникають. Хотілося рішення, яке б прибирало саму причину, а не просто робило наслідки трохи менш болючими.
Import Maps: сучасне рішення
Import Maps - це вбудована фіча браузера (для старіших браузерів є поліфіли), яка розв'язує модульні імена окремо від шляхів до файлів. Замість того щоб файл A імпортував "./button-abc123.js", він просто імпортує "button". Браузер використовує import map, щоб зв'язати "button" з реальним файлом з хешем.
Це було саме те, що мені потрібно. Вміст файлу A залишається незмінним (він завжди імпортує "button"), тож його хеш теж не змінюється. Нові хеші отримують тільки сам змінений файл і import map. Я щиро здивувався, що ніхто ще не зробив нормальний плагін під це!
Як я це реалізував
Я вирішив зробити плагін для Vite, який би:
- Перетворював усі відносні імпорти на стабільні модульні імена
- Генерував import map, який зіставляє ці імена з реальними файлами з хешами
- Вставляв import map у HTML
Плагін уже доступний на GitHub: @foony/vite-plugin-import-map
Перший підхід
Я почав з плагіна для Vite, який використовував хук generateBundle. У першій версії я за допомогою regex шукав і замінював шляхи імпортів. Це було легко написати і працювало для нашої невеликої команди Foony, але рішення було крихким і точно не підійшло б для плагіна, де можуть бути помилкові збіги, які ми випадково змінимо.
У підходу з regex були очевидні проблеми: що, якщо якийсь рядок у коді випадково схожий на ім'я файлу? А як бути з динамічними імпортами? А з експортами? Якщо я вже роблю плагін для інших, потрібне було щось значно надійніше.
Парсинг AST
Мені треба було нормально розбирати JavaScript-код, щоб знаходити всі оператори імпорту. Спочатку я спробував es-module-lexer, бібліотеку, яка спеціально створена для парсингу ES-модулів. На жаль, вона спричиняла нативні паніки під час того, як Vite аналізує модулі. Навіть збірка asm.js не допомогла позбутися цих панік.
У підсумку я зупинився на Acorn - це швидкий, легкий парсер на чистому JavaScript. У парі з acorn-walk для обходу AST він дав мені все, що потрібно, без проблем із нативними залежностями.
Ключові нюанси, які довелося розв'язати
Обробка всіх типів імпортів
Імпорти бувають різні, і в AST вони виглядають по-різному. Мені потрібно було обробляти:
- Статичні імпорти:
import x from "./file.js" - Динамічні імпорти:
import("./file.js") - Іменовані реекспорти:
export { x } from "./file.js"(цей варіант я спочатку взагалі проґавив!) - Реекспорт усіх символів:
export * from "./file.js"
Випадок з реекспортом виявився особливо підступним, бо я про нього забув, поки не натрапив на файл, який не трансформувався. Усередині було щось на кшталт export{PoolBalls,PoolCues,PoolTables}from"./Items-Bd_KmSuk.js", і мій плагін повністю ігнорував це, бо я дивився тільки на вузли ImportDeclaration і ImportExpression.
Ось як я зараз обробляю всі ці варіанти:
walk(ast, {
ImportDeclaration(node: any) {
// Статичні імпорти: import x from "spec"
const specifier = node.source.value;
// ... логіка трансформації
},
ExportNamedDeclaration(node: any) {
// Іменовані експорти з джерелом: export { x, y } from "spec"
if (!node.source?.value) return;
// ... логіка трансформації
},
ExportAllDeclaration(node: any) {
// Реекспорт усіх символів: export * from "spec"
if (!node.source?.value) return;
// ... логіка трансформації
},
ImportExpression(node: any) {
// Динамічні імпорти: import("spec")
// ... логіка трансформації
},
});
Визначене розв'язання конфліктів імен
Коли кілька файлів мають однакову базову назву (наприклад, кілька index.tsx у різних директоріях), їх треба якось розрізняти. Я не можу просто використати "index" для всіх.
Моє рішення таке: якщо є конфлікт, я хешую початковий шлях до файлу разом із базовою назвою. Наприклад, src/client/games/chess/index.tsx:index хешується і перетворюється на щось на кшталт index-abc123. Це гарантує, що той самий файл завжди отримає однакове модульне ім'я в різних білдах, навіть якщо з'являться або зникнуть інші файли з такою ж назвою.
Я використовую chunk.facadeModuleId (entry point) як основний ідентифікатор, а якщо його немає, то падаю назад на chunk.moduleIds[0]. Так я отримую стабільний шлях до джерела для детермінованого хешування.
Ланцюжок source map-ів
Коли я трансформую код, я ламаю ланцюжок source map-ів. Поточний source map пов'язує оригінальний TypeScript-код з результатом після Babel і мініфікації. Мої зміни додають ще один шар, тож потрібно зберегти цей ланцюжок.
Я використовую MagicString, щоб відстежувати всі зміни й генерувати новий source map. Потім я зливаю його з уже існуючою картою: зберігаю масиви sources і sourcesContent з початкового source map. Так повністю зберігається ланцюжок: оригінальне джерело → (початковий map) → трансформований код.
const existingMap = typeof chunk.map === 'string' ? JSON.parse(chunk.map) : chunk.map;
const newMap = magicString.generateMap({
source: fileName,
file: newFileName,
includeContent: true,
hires: true,
});
// Мерджимо: беремо прив'язки з нового map, але зберігаємо оригінальні sources
chunk.map = {
...newMap,
sources: existingMap.sources || newMap.sources,
sourcesContent: existingMap.sourcesContent || newMap.sourcesContent,
file: newFileName,
};
Повторне хешування трансформованого вмісту
Мені потрібен стабільний вміст файлів. Для цього я спочатку трансформую імпорти (замінюю Vite-івські імпорти з хешами на свої стабільні), а потім прибираю з коду коментарі з source map, щоб вони не впливали на обчислення хешу (там згадуються старі імена файлів).
Після цього я рахую новий хеш і оновлюю і ім'я файлу, і запис у import map.
Фінальна реалізація
Плагін працює в чотири проходи:
- Прохід підрахунку: шукаємо колізії імен, рахуємо, скільки файлів мають одну й ту саму базову назву
- Прохід мапи: будуємо відповідність між чанками (ім'я з хешем → модульне ім'я) і початковий import map
- Прохід трансформації: переписуємо шляхи імпортів у коді, перерахуємо хеші, оновлюємо source map-и
- Прохід перейменування: оновлюємо імена файлів у бандлі й фіналізуємо import map
Ось основна логіка трансформації:
import {simple as walk} from 'acorn-walk';
// Парсимо код, щоб отримати AST
const ast = Parser.parse(chunk.code, {
ecmaVersion: 'latest',
sourceType: 'module',
locations: true,
});
const importsToTransform: Array<{start: number; end: number; replacement: string}> = [];
// Обходимо AST, щоб знайти всі імпорти та експорти
walk(ast, {
ImportDeclaration(node: any) {
const specifier = node.source.value;
const filename = specifier.split('/').pop()!;
const moduleSpec = chunkMapping.get(filename);
if (moduleSpec) {
importsToTransform.push({
start: node.source.start + 1, // +1, щоб пропустити відкривальну лапку
end: node.source.end - 1, // -1, щоб пропустити закривальну лапку
replacement: moduleSpec,
});
}
},
// ... обробка інших типів вузлів
});
// Застосовуємо трансформації у зворотному порядку, щоб зберегти позиції
importsToTransform.sort((a, b) => b.start - a.start);
for (const transform of importsToTransform) {
magicString.overwrite(transform.start, transform.end, transform.replacement);
}
Щоб вставити import map у HTML, я використовую API вставки тегів у Vite замість маніпуляцій з regex:
transformIndexHtml() {
return {
tags: [
{
tag: 'script',
attrs: {type: 'importmap'},
children: JSON.stringify(importMap, null, 2),
injectTo: 'head-prepend',
},
],
};
}
Це набагато надійніше, ніж намагатися парсити HTML через regex і шукати теги.
Трохи цифр
Щоб було зрозуміліше, що саме робить цей плагін:
- ~1 000+ JavaScript-файлів обробляється в одному білді
- ~2-3 секунди додаткового часу збірки (цілком нормальний компроміс)
- ~99% менше зайвих змін хешів (більшість файлів тепер змінюються тільки тоді, коли реально змінюється їхній вміст)
- ~340 рядків коду плагіна (разом з коментарями та обробкою помилок)
Плагін закриває всі крайні випадки, з якими я поки що стикався, і процес збірки став набагато передбачуванішим.
Що я з цього виніс
Чому без AST-парсингу ніяк
Regex поверх зібраного коду - це небезпечно. Якщо якийсь рядок випадково схожий на ім'я файлу, regex його перепише. AST-парсинг гарантує, що ти змінюєш тільки справжні оператори import/export, а не будь-що схоже на них.
Чому Acorn, а не es-module-lexer
es-module-lexer швидший і спеціально заточений під цю задачу, але через нативні паніки він виявився непридатним у контексті Vite-плагіна. Acorn написаний на чистому JavaScript, тож немає жодних нативних залежностей, про які треба хвилюватися. У майбутньому я ще хочу повернутися до es-module-lexer як до можливості прискорення, але зараз Acorn працює просто чудово.
Чому Import Maps кращі за альтернативи
Import Maps - це вебстандарт з нативною підтримкою в браузерах. Це правильний спосіб розв'язати цю проблему. Поліфіл (es-module-shims) спокійно покриває старіші браузери (наприклад, Safari < 16.4), а саме рішення виходить чистим і простим у підтримці.
Висновки
Плагін з Import Maps успішно прибирає каскадні зміни хешів у моїх білдах Vite. Файли тепер отримують нові хеші тільки тоді, коли реально змінюється їхній вміст, а не коли змінюються їхні залежності. Збірки стають передбачуванішими, зменшується кількість зайвих оновлень кешу, і ми спокійніше вкладаємося в ліміти Cloudflare Pages за кількістю файлів.
Рішення вийшло простим, зручним у підтримці й побудованим на сучасних вебстандартах. Гарний приклад того, що інколи правильне рішення ще й виявляється найпростішим - якщо достатньо глибоко розібратися в проблемі, щоб його побачити.
Плагін відкритий і лежить на GitHub: @foony/vite-plugin-import-map. Можна встановити його через npm install @foony/vite-plugin-import-map і почати використовувати у власних проєктах на Vite.
У майбутньому можна буде спробувати оптимізувати все за допомогою es-module-lexer, коли проблема з нативними паніками зникне, або додати підтримку ще складніших сценаріїв імпорту. Але зараз плагін робить рівно те, що мені потрібно.
Хто знає, можливо, одного дня щось подібне з'явиться вже прямо в самому Vite.