

1/1/1970
Как я решил проблему каскадных изменений хешей с помощью Import Maps
Привет! Эта проблема преследовала меня больше 5 лет, но только сейчас я решил с ней разобраться, потому что дошло до точки, когда игнорировать её уже невозможно. Когда я менял один символ в одном файле, половина JavaScript-файлов в моей сборке получала новые хешированные имена, хотя их содержимое фактически не менялось. Это приводило к ненужной инвалидации кеша, делало почти невозможным отследить, что реально изменилось между сборками, и, что хуже всего, ломало мои сборки на Cloudflare Pages из-за лимита на количество файлов.
Ниже я разберу проблему, объясню, почему существующие решения мне не подошли, и расскажу, как я создал собственный плагин для Vite на основе Import Maps, чтобы решить её раз и навсегда.
Проблема: каскадные изменения хешей
Vite использует хеширование на основе содержимого для production-сборок. Когда вы собираете приложение, каждый 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, который резко увеличил количество создаваемых файлов.
Решение проблемы каскадных хешей позволяет хранить гораздо больше прошлых сборок, не упираясь в эти лимиты, потому что теперь большинство файлов больше не нужно менять. Это также снижает вероятность того, что у пользователя на устаревшей сборке возникнет ошибка, поскольку гораздо более вероятно, что он запросит ныне неизменённый файл, который у нас как раз есть.
Почему не [альтернативные решения]?
Когда я только начал думать над решением, я рассмотрел несколько подходов. Ни один из них толком не подошёл.
Скрипты после сборки
Моей первой мыслью было написать скрипт после сборки, который нормализовал бы все пути импортов, перехешировал бы файлы и обновил ссылки. Это казалось простым: просто регулярка, заменяющая хешированные имена файлов на стабильные, и затем пересчёт хешей.
Я отказался от этого подхода из-за опасений по поводу «гейзенбагов» и порчи кеша. Хотя мы и храним прошлые сборки в Cloudflare Pages, риск рассогласованности кеша того не стоил. Скрипт, модифицирующий файлы после сборки, мог бы внести едва заметные баги, которые проявляются только в production, а отладка таких вещей превратилась бы в кошмар.
manualChunks в Vite
Другим вариантом было использовать конфигурацию 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. Моя первая попытка использовала регулярки для поиска и замены путей импорта. Это было легко закодить, и оно работало для нашей маленькой команды Foony, но было хрупким и точно не подошло бы для плагина, где могут возникнуть ложные срабатывания, которые мутируют код.
У подхода с регулярками были очевидные проблемы: что если в коде окажется строка, похожая на имя файла? А динамические импорты? А 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 map
- Переименование: Обновление имён файлов в бандле и финализация 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 я использую API внедрения тегов Vite вместо манипуляций с регулярками:
transformIndexHtml() {
return {
tags: [
{
tag: 'script',
attrs: {type: 'importmap'},
children: JSON.stringify(importMap, null, 2),
injectTo: 'head-prepend',
},
],
};
}
Это гораздо надёжнее, чем пытаться сопоставлять HTML-теги регулярками.
В цифрах
Чтобы дать вам представление о том, что делает плагин:
- ~1 000+ JavaScript-файлов обрабатывается за сборку
- ~2-3 секунды добавляется ко времени сборки (приемлемый компромисс)
- ~99% сокращение ненужных изменений хешей (большинство файлов теперь меняются только при реальном изменении содержимого)
- ~340 строк кода плагина (включая комментарии и обработку ошибок)
Плагин справляется со всеми пограничными случаями, с которыми я сталкивался, и процесс сборки теперь намного более предсказуем.
Извлечённые уроки
Почему парсинг AST необходим
Регулярки по бандленному коду опасны. Если строка в вашем коде окажется похожей на имя файла, регулярка её перепишет. Парсинг 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, у некоторых пользователей возникли неожиданные проблемы, поэтому я пока его отключил. Вернусь к нему позже. Может быть. Я всё ещё считаю, что это изящное решение.)