background blurbackground mobile blur

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, который будет:

  1. Преобразовывать все относительные импорты в стабильные модульные идентификаторы
  2. Генерировать import map, который мапит эти идентификаторы на реальные хешированные файлы
  3. Вкладывать 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.

Финальная реализация

Плагин работает в четыре прохода:

  1. Проход "счетчик": считаю, сколько файлов делят одно и то же базовое имя, чтобы найти конфликты
  2. Проход "карта": создаю маппинг чанков (хешированное имя файла → модульный идентификатор) и черновой import map
  3. Проход "трансформация": переписываю пути импортов в коде, пересчитываю хэши, обновляю source map'ы
  4. Проход "переименование": переименовываю файлы в бандле и финализирую 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 из коробки.

8 Ball Pool online multiplayer billiards icon