

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, яка вибухово збільшила кількість створюваних файлів.
Розв'язання каскадних хешів дозволяє нам зберігати набагато більше попередніх збірок, не досягаючи цих лімітів, бо тепер більшості файлів не потрібно змінюватися. Це також зменшує ймовірність того, що користувач на застарілій збірці отримає помилку, оскільки набагато ймовірніше, що він запитуватиме незмінений файл, який у нас якраз є.
Чому не [альтернативні рішення]?
Коли я вперше шукав, як це розв'язати, я розглянув кілька підходів. Жоден з них зовсім не підійшов.
Скрипти після збірки
Першою думкою було написати скрипт після збірки, який нормалізував би всі шляхи імпортів, перехешував файли та оновив посилання. Це здавалося простим: просто regex-замінити хешовані імена на стабільні, потім перерахувати хеші.
Я відкинув цей підхід через побоювання щодо «гейзенбагів» та отруєння кешу. Навіть попри те, що ми зберігаємо попередні збірки в Cloudflare Pages, ризик неузгодженості кешу не вартував того. Скрипт, який модифікує файли після збірки, міг би внести тонкі баги, які з'являються тільки в продакшні, і їх налагодження було б кошмаром.
Vite manualChunks
Іншим варіантом було використати конфігурацію manualChunks від Vite, щоб відокремити стабільний код (наприклад, node_modules) від нестабільного (бізнес-логіка). Ідея була в тому, що код вендорів змінюється рідше, тож менше файлів каскадно перехешуватиметься.
Це насправді не розв'язує кореневу проблему, лише пом'якшує її. Ви все одно отримуєте каскадні хеші всередині чанків бізнес-логіки. Я хотів рішення, яке вирішує проблему по суті, а не просто робить її трохи менш поганою.
Import Maps: сучасне рішення
Import Maps це нативна функція браузера (з підтримкою поліфілів для старіших браузерів), яка відокремлює специфікатори модулів від шляхів до файлів. Замість того, щоб Файл A імпортував "./button-abc123.js", він імпортує "button". Браузер використовує import map, щоб розв'язати "button" у фактичне хешоване ім'я файлу.
Це саме те, що мені було потрібно. Вміст Файлу A залишається ідентичним (він завжди імпортує "button"), тож його хеш залишається той самий. Лише import map і змінений файл отримують нові хеші. Я був трохи шокований, що ніхто ще не зробив доброго плагіна для цього!
Будуємо Vite-плагін
Я вирішив побудувати Vite-плагін, який би:
- Перетворював усі відносні імпорти на стабільні специфікатори модулів
- Генерував import map, що мапить ці специфікатори на фактичні хешовані імена файлів
- Вставляв import map у HTML
Плагін тепер доступний на GitHub: @foony/vite-plugin-import-map
Початковий підхід
Я почав з Vite-плагіна, що використовує хук generateBundle. Моя перша спроба використовувала regex для пошуку та заміни шляхів імпортів. Це було легко закодувати і працювало для нашої маленької команди в Foony, але було крихким і точно не працювало б у плагіні, де можуть бути хибні спрацювання, які мутуються.
Підхід з regex мав очевидні проблеми: що, якщо рядок у коді випадково виглядатиме як ім'я файлу? А як щодо динамічних імпортів? А export-виразів? Мені потрібне було надійніше рішення, якщо я збирався робити плагін для інших.
Парсинг AST
Мені потрібно було належним чином парсити JavaScript код, щоб знайти всі import-вирази. Моєю першою спробою був 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) {
// Static imports: import x from "spec"
const specifier = node.source.value;
// ... transform logic
},
ExportNamedDeclaration(node: any) {
// Named exports with source: export { x, y } from "spec"
if (!node.source?.value) return;
// ... transform logic
},
ExportAllDeclaration(node: any) {
// Export all: export * from "spec"
if (!node.source?.value) return;
// ... transform logic
},
ImportExpression(node: any) {
// Dynamic imports: import("spec")
// ... transform logic
},
});
Детерміноване розв'язання конфліктів
Коли кілька файлів мають однакове базове ім'я (наприклад, кілька файлів index.tsx у різних каталогах), мені потрібно їх розрізняти. Я не можу просто використати "index" для всіх них.
Моє рішення: якщо є конфлікт, я хешую оригінальний шлях до джерела плюс базове ім'я. Наприклад, src/client/games/chess/index.tsx:index хешується на index-abc123. Це гарантує, що той самий файл завжди отримує той самий специфікатор модуля між збірками, навіть якщо інші файли з тим самим іменем додаються чи видаляються.
Я використовую chunk.facadeModuleId (точку входу) як основний ідентифікатор, повертаючись до chunk.moduleIds[0], якщо перший недоступний. Це дає мені стабільний шлях до джерела для детермінованого хешування.
Зчеплення source map
Коли я трансформую код, я ламаю ланцюжок source map. Існуюча source map мапить від оригінального TypeScript джерела через Babel і мініфікацію до поточного коду. Мої трансформації додають ще один шар, тож мені потрібно зберегти цей ланцюжок.
Я використовую MagicString, щоб відстежувати свої трансформації та генерувати нову source map. Потім зливаю її з існуючою мапою, зберігаючи оригінальні масиви sources та sourcesContent. Це підтримує повний ланцюжок: Оригінальне джерело → (існуюча мапа) → Трансформований код.
const existingMap = typeof chunk.map === 'string' ? JSON.parse(chunk.map) : chunk.map;
const newMap = magicString.generateMap({
source: fileName,
file: newFileName,
includeContent: true,
hires: true,
});
// Merge: use new map's mappings but preserve original sources
chunk.map = {
...newMap,
sources: existingMap.sources || newMap.sources,
sourcesContent: existingMap.sourcesContent || newMap.sourcesContent,
file: newFileName,
};
Перехешування трансформованого вмісту
Мені потрібен стабільний вміст файлу. Щоб цього досягти, я трансформую імпорти (замінюючи хешовані імпорти Vite на свої стабільні), а потім видаляю коментарі source map з обчислення хешу (вони посилаються на старі імена файлів).
Після цього я обчислюю новий хеш і оновлюю як ім'я файлу, так і запис в import map.
Фінальна реалізація
Плагін використовує стратегію з чотирьох проходів:
- Прохід підрахунку: виявлення колізій імен через підрахунок, скільки файлів мають кожне базове ім'я
- Прохід мапування: створення мапування чанків (хешоване ім'я файлу → специфікатор модуля) та початкової import map
- Прохід трансформації: переписування шляхів імпортів у коді, перерахунок хешів, оновлення source maps
- Прохід перейменування: оновлення імен файлів у бандлі та фіналізація import map
Ось основна логіка трансформації:
import {simple as walk} from 'acorn-walk';
// Parse the code to get an AST
const ast = Parser.parse(chunk.code, {
ecmaVersion: 'latest',
sourceType: 'module',
locations: true,
});
const importsToTransform: Array<{start: number; end: number; replacement: string}> = [];
// Traverse the AST to find all imports/exports
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 to skip opening quote
end: node.source.end - 1, // -1 to skip closing quote
replacement: moduleSpec,
});
}
},
// ... handle other node types
});
// Apply transformations in reverse order to preserve positions
importsToTransform.sort((a, b) => b.start - a.start);
for (const transform of importsToTransform) {
magicString.overwrite(transform.start, transform.end, transform.replacement);
}
Для вставки import map у HTML я використовую Vite API для вставки тегів замість 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 підтримуватиме щось подібне нативно.
(Оновлення: після випробування плагіна на збірці Foony в деяких користувачів виникли несподівані проблеми, тож я наразі його вимкнув. Можливо, повернуся до нього пізніше. Може бути. Я все ще вважаю, що це круте рішення.)