

1/1/1970
Как я победил каскадные изменения хэшей с помощью Import Maps
Привет! С этой проблемой я жил больше 5 лет, но занялся ей только сейчас, когда она стала совсем невыносимой. Стоило поменять один символ в одном файле, и половина JavaScript‑файлов в сборке получала новые имена с хешами, хотя их содержимое вообще не менялось. Это приводило к лишней инвалидизации кеша, почти невозможно было отследить, что именно поменялось между билдами, и, что хуже всего, ломало сборки на Cloudflare Pages из‑за лимита по количеству файлов.
Ниже я разберу саму проблему, почему готовые решения мне не подошли и как я сделал свой плагин для Vite с Import Maps, который наконец‑то решил это раз и навсегда.
Проблема: каскадные изменения хэшей
Vite использует хеширование по содержимому для production‑сборок. Когда вы собираете приложение, каждый JavaScript‑файл получает в имени хеш, который зависит от его содержимого. Если button.tsx компилируется в button-abc12345.js, а потом код меняется, файл превращается в button-def45678.js. Это отлично работает для cache busting, пользователи получают новый файл, когда он меняется.
Проблема начинается, когда файл A импортирует файл B. Допустим, у вас есть:
// main.js
import { Button } from "./button-abc12345.js";
Когда button.tsx меняется, Vite генерирует button-def45678.js. Но теперь меняется и main.js, потому что внутри есть строка "./button-abc12345.js", которая стала неактуальной. В итоге main.js тоже получает новый хеш, хотя его логика вообще не изменилась.
Это разлетается по всему графу зависимостей. Меняете одну утилитку, и внезапно половина js‑файлов получает новые хэши. В моем случае изменение одного символа в useBackgroundMusic.ts приводило к перехешированию больше чем 500 файлов.
В реальном мире это было очень заметно. Мы храним 8 версий ассетов прошлых билдов, чтобы пользователи на немного устаревших клиентах могли продолжать запускать свою версию, когда мы деплоим новую на Cloudflare Pages. Но у Cloudflare Pages есть лимит в 20 000 файлов, и мы начали в него упираться из‑за нашого перехода на i18n, который резко увеличил количество создаваемых файлов.
Решив проблему каскадных хэшей, мы можем хранить гораздо больше прошлых билдов, не упираясь в лимиты, потому что теперь большинство файлов просто не меняются. Плюс становится меньше шансов, что у пользователя на старой версии все развалится, так как он с куда большей вероятностью запросит файл, который остался прежним и у нас есть.
Почему не [альтернативные решения]?
Когда я впервые сел решать эту задачу, у меня было несколько идей. Ни одна не подошла как есть.
Post-build скрипты
Первая мысль была написать post-build скрипт, который нормализует все пути импортов, заново пересчитает хэши и обновит ссылки. На бумаге звучало просто: пройтись по файлам, с помощью regex заменить хешированные имена на стабильные, потом пересчитать хэши.
Я отказался от этого подхода из‑за хейзенбагов и риска отравления кеша. Даже несмотря на то, что мы храним прошлые билды в Cloudflare Pages, риск рассинхрона кеша того не стоил. Скрипт, который правит файлы уже после сборки, легко может добавить тонкие баги, которые проявятся только в проде, а отлаживать такое будет сущий кошмар.
Vite manualChunks
Второй вариант был использовать manualChunks в конфиге Vite, чтобы отделить более стабильный код (например node_modules) от изменчивого (бизнес‑логика). Идея в том, что vendor‑код меняется реже, значит каскада будет поменьше.
Но это не решает корень проблемы, только сглаживает ее. Каскадные хэши по‑прежнему остаются внутри чанков с бизнес‑логикой. Мне хотелось решения, которое чинит саму причину, а не просто делает последствия чуть менее болезненными.
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‑подхода были очевидные проблемы: а что если обычная строка в коде случайно выглядит как имя файла? А как быть с динамическими импортами? А с export‑выражениями? Если я хочу делать плагин для других, нужна куда более надежная основа.
AST‑парсинг
Мне нужно было нормально разобрать JavaScript‑код и найти все выражения import/export. Сначала я попробовал es-module-lexer, он как раз заточен под разбор ES‑модулей. К сожалению, он вызывал нативные panics во время анализа модулей внутри 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) {
// Именованные экспорты с source: 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 (точку входа), а если ее нет, падаю обратно на chunk.moduleIds[0]. Это дает стабильный путь источника для детерминированного хеширования.
Цепочка source map'ов
Когда я переписываю код, я ломаю существующую цепочку source map'ов. Текущий source map отображает оригинальный TypeScript‑код через Babel и минификацию к уже собранному файлу. Моя трансформация добавляет еще один слой, так что цепочку нужно сохранить.
Я использую MagicString, чтобы отслеживать изменения и генерировать новый source map. Потом мержу его с уже существующим, сохраняя оригинальные массивы sources и sourcesContent. В итоге цепочка остается полной: оригинальный источник → (старый source 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,
});
// Мержим: используем mappings из новой карты, но сохраняем старые 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 быстрее и более специализированный, но из‑за нативных panic'ов он оказался непригоден внутри моего 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, когда разберусь с нативными panic'ами, или добавлю поддержку более сложных сценариев импортов. Но прямо сейчас плагин делает ровно то, что мне нужно.
А там кто знает, может однажды что‑то подобное появится прямо в Vite из коробки.