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. Это отлично подходит для сброса кеша: пользователи получают новый файл, когда он меняется.

Проблема возникает, когда Файл 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, который будет:

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

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

Плагин использует стратегию из четырёх проходов:

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

8 Ball Pool online multiplayer billiards icon