background blurbackground mobile blur

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, яка сильно збільшила кількість файлів, що ми створюємо.

Розв'язання проблеми каскадних хешів дозволяє нам зберігати набагато більше минулих білдів і не впиратися в ці обмеження, бо тепер більшості файлів просто не потрібно змінюватися. Це також зменшує шанс того, що користувач на старішому білді отримає помилку, адже значно ймовірніше, що він запросить файл, який тепер не змінюється і який у нас є в наявності.

Чому не [альтернативні рішення]?

Коли я вперше сів розв'язувати цю проблему, я розглянув кілька підходів. Жоден з них мене повністю не влаштував.

Post-build скрипти

Перша ідея була проста: написати post-build скрипт, який би нормалізував усі шляхи імпортів, перехешував файли і оновив посилання. Звучало досить прямо: просто зробити regex-пошук і замінити імена з хешами на стабільні, а потім заново порахувати хеші.

Я відкинув цей підхід через ризик хитрих багів і можливе отруєння кешу. Хоча ми й зберігаємо минулі білди в Cloudflare Pages, ризик неузгодженого кешу того не вартий. Скрипт, який змінює файли вже після білда, може створити тонкі баги, що проявлятимуться тільки в продакшені, а відлагоджувати таке суцільний жах.

Vite manualChunks

Інший варіант був такий: використати в Vite опцію manualChunks, щоб відділити стабільний код (на кшталт node_modules) від нестабільного (бізнес-логіка). Ідея в тому, що вендорний код змінюється рідше, тож каскад буде меншим.

Але кореневу проблему це не прибирає, тільки трохи її пом'якшує. Усередині чанків з бізнес-логікою каскадні хеші нікуди не зникають. Хотілося рішення, яке б прибирало саму причину, а не просто робило наслідки трохи менш болючими.

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

Парсинг AST

Мені треба було нормально розбирати JavaScript-код, щоб знаходити всі оператори імпорту. Спочатку я спробував 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) {
    // Статичні імпорти: import x from "spec"
    const specifier = node.source.value;
    // ... логіка трансформації
  },
  ExportNamedDeclaration(node: any) {
    // Іменовані експорти з джерелом: 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 (entry point) як основний ідентифікатор, а якщо його немає, то падаю назад на chunk.moduleIds[0]. Так я отримую стабільний шлях до джерела для детермінованого хешування.

Ланцюжок source map-ів

Коли я трансформую код, я ламаю ланцюжок source map-ів. Поточний source map пов'язує оригінальний TypeScript-код з результатом після Babel і мініфікації. Мої зміни додають ще один шар, тож потрібно зберегти цей ланцюжок.

Я використовую MagicString, щоб відстежувати всі зміни й генерувати новий source map. Потім я зливаю його з уже існуючою картою: зберігаю масиви sources і sourcesContent з початкового source map. Так повністю зберігається ланцюжок: оригінальне джерело → (початковий 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,
});

// Мерджимо: беремо прив'язки з нового map, але зберігаємо оригінальні 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 швидший і спеціально заточений під цю задачу, але через нативні паніки він виявився непридатним у контексті 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.

8 Ball Pool online multiplayer billiards icon