

1/1/1970
Jak rozwiązałem kaskadowe zmiany hashy za pomocą Import Maps
Cześć! Borykałem się z tym problemem ponad 5 lat, ale dopiero teraz postanowiłem się nim zająć, bo doszło do punktu, w którym nie mogłem go już dłużej ignorować. Kiedy zmieniałem jeden znak w jednym pliku, połowa plików JavaScript w moim buildzie dostawała nowe hashowane nazwy, mimo że ich faktyczna zawartość się nie zmieniła. Powodowało to niepotrzebne unieważnianie cache'a, sprawiało, że niemal niemożliwe było śledzenie tego, co naprawdę zmieniło się między buildami, a co najgorsze: psuło moje buildy na Cloudflare Pages z powodu limitu plików.
Poniżej rozłożę problem na czynniki pierwsze, opowiem, dlaczego istniejące rozwiązania mi nie odpowiadały, i jak zbudowałem własną wtyczkę do Vite z użyciem Import Maps, by raz na zawsze rozwiązać ten problem.
Problem: kaskadowe zmiany hashy
Vite używa hashowania opartego na zawartości dla buildów produkcyjnych. Gdy budujesz aplikację, każdy plik JavaScript otrzymuje hash w nazwie pliku oparty na jego zawartości. Jeśli button.tsx kompiluje się do button-abc12345.js, a zawartość się zmieni, plik staje się button-def45678.js. Świetnie sprawdza się to przy unieważnianiu cache'a: użytkownicy dostają nowy plik, gdy się zmieni.
Problem pojawia się, gdy plik A importuje plik B. Załóżmy, że masz:
// main.js
import { Button } from "./button-abc12345.js";
Gdy button.tsx się zmienia, Vite generuje button-def45678.js. Ale teraz main.js też się zmienia, ponieważ zawiera ciąg znaków "./button-abc12345.js", który jest już nieaktualny. Więc main.js również dostaje nowy hash, mimo że faktyczna logika w main.js w ogóle się nie zmieniła.
Kaskaduje to przez cały graf zależności. Zmień jedną funkcję narzędziową, a nagle połowa twoich plików js dostaje nowe hashe. W moim przypadku zmiana jednego znaku w useBackgroundMusic.ts spowodowała ponowne hashowanie ponad 500 plików.
Wpływ w praktyce był znaczący. Pakujemy 8 wersji zasobów z naszych poprzednich buildów, by użytkownicy z lekko nieaktualnymi wersjami klienta mogli nadal uruchamiać swoją wersję, gdy wdrażamy nową na Cloudflare Pages. Jednak Cloudflare Pages ma limit 20 000 plików, w który zaczęliśmy się wbijać przez naszą wcześniejszą zmianę i18n, która eksplodowała liczbę tworzonych plików.
Rozwiązanie kaskadowych hashy pozwala nam przechowywać znacznie więcej poprzednich buildów bez wpadania w te limity, ponieważ teraz większość plików nie musi się już zmieniać. Zmniejsza to też prawdopodobieństwo, że użytkownik z nieaktualnym buildem dostanie błąd, ponieważ znacznie częściej będzie żądał pliku, który się nie zmienił, a my akurat go mamy.
Dlaczego nie [alternatywne rozwiązania]?
Gdy po raz pierwszy zacząłem szukać rozwiązania, rozważałem kilka podejść. Żadne z nich do końca nie pasowało.
Skrypty post-build
Moim pierwszym pomysłem było napisanie skryptu post-build, który znormalizowałby wszystkie ścieżki importów, ponownie zhashował pliki i zaktualizował odniesienia. Wydawało się to proste: wystarczy podmienić regexem hashowane nazwy plików na stabilne, a potem przeliczyć hashe.
Odrzuciłem to podejście z powodu obaw o "Heisenbugi" i zatruwanie cache'a. Mimo że przechowujemy poprzednie buildy w Cloudflare Pages, ryzyko niespójności cache'a nie było tego warte. Skrypt modyfikujący pliki po buildzie mógłby wprowadzać subtelne błędy, które pojawiają się tylko na produkcji, a debugowanie ich byłoby koszmarem.
Vite manualChunks
Inną opcją było użycie konfiguracji manualChunks w Vite, by oddzielić stabilny kod (jak node_modules) od niestabilnego (logika biznesowa). Pomysł polegał na tym, że kod vendorów zmieniałby się rzadziej, więc mniej plików kaskadowałoby.
To w zasadzie nie rozwiązuje fundamentalnego problemu, tylko go łagodzi. Nadal dostajesz kaskadowe hashe wewnątrz chunków logiki biznesowej. Chciałem rozwiązania, które adresuje sedno problemu, a nie tylko sprawia, że jest trochę mniej źle.
Import Maps: nowoczesne rozwiązanie
Import Maps to natywna funkcja przeglądarki (z polyfillem dla starszych przeglądarek), która oddziela specyfikatory modułów od ścieżek plików. Zamiast importować "./button-abc123.js", plik A importuje "button". Przeglądarka używa import map, by rozwiązać "button" na faktyczną hashowaną nazwę pliku.
To było dokładnie to, czego potrzebowałem. Zawartość pliku A pozostaje identyczna (zawsze importuje "button"), więc jego hash się nie zmienia. Tylko import map i zmieniony plik dostają nowe hashe. Byłem trochę w szoku, że nikt jeszcze nie zrobił dobrej wtyczki do tego!
Budowanie wtyczki Vite
Postanowiłem zbudować wtyczkę Vite, która:
- Przekształci wszystkie względne importy, by używały stabilnych specyfikatorów modułów
- Wygeneruje import map mapującą te specyfikatory na faktyczne hashowane nazwy plików
- Wstrzyknie import map do HTML
Wtyczka jest teraz dostępna na GitHubie: @foony/vite-plugin-import-map
Pierwsze podejście
Zacząłem od wtyczki Vite z hookiem generateBundle. Moja pierwsza próba używała regexa do znajdowania i podmiany ścieżek importów. Było to łatwe do zakodowania i działało dla naszego małego zespołu Foony, ale było kruche i zdecydowanie nie sprawdziłoby się we wtyczce, gdzie mogą się pojawić fałszywie pozytywne dopasowania, które zostaną zmodyfikowane.
Podejście z regexem miało oczywiste problemy: co, jeśli jakiś ciąg znaków w kodzie przypadkiem wygląda jak nazwa pliku? Co z dynamicznymi importami? Co z deklaracjami eksportu? Potrzebowałem solidniejszego rozwiązania, jeśli miałem zbudować wtyczkę dla innych.
Parsowanie AST
Musiałem prawidłowo sparsować kod JavaScript, by znaleźć wszystkie deklaracje importu. Moją pierwszą próbą był es-module-lexer, specjalnie zaprojektowany do parsowania modułów ES. Niestety, powodował natywne paniki podczas fazy analizy modułów Vite. Nawet próba z buildem asm.js nie pomogła zatrzymać tych panik.
Zdecydowałem się na Acorn, szybki, lekki, czysto JavaScriptowy parser. W połączeniu z acorn-walk do przechodzenia po AST, dał mi wszystko, czego potrzebowałem, bez problemów z natywnymi zależnościami.
Kluczowe wyzwania, które rozwiązałem
Obsługa wszystkich typów importów
Importy występują w wielu formach i są różnie traktowane w AST. Musiałem obsłużyć:
- Statyczne importy:
import x from "./file.js" - Dynamiczne importy:
import("./file.js") - Nazwane re-eksporty:
export { x } from "./file.js"(początkowo to przegapiłem!) - Re-eksport wszystkiego:
export * from "./file.js"
Przypadek re-eksportu był szczególnie podchwytliwy, bo przegapiłem go, dopóki nie zobaczyłem pliku, który nie był przekształcany. Kod miał export{PoolBalls,PoolCues,PoolTables}from"./Items-Bd_KmSuk.js", a moja wtyczka kompletnie go ignorowała, ponieważ szukałem tylko węzłów ImportDeclaration i ImportExpression.
Oto jak teraz obsługuję wszystkie z nich:
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
},
});
Deterministyczne rozwiązywanie konfliktów
Gdy wiele plików ma tę samą nazwę bazową (jak wiele plików index.tsx w różnych katalogach), muszę je rozróżnić. Nie mogę po prostu używać "index" dla wszystkich.
Moje rozwiązanie: jeśli jest konflikt, hashuję oryginalną ścieżkę źródłową plus nazwę bazową. Na przykład src/client/games/chess/index.tsx:index jest hashowany, by stworzyć index-abc123. Zapewnia to, że ten sam plik zawsze dostaje ten sam specyfikator modułu między buildami, nawet jeśli inne pliki o tej samej nazwie zostaną dodane lub usunięte.
Używam chunk.facadeModuleId (punkt wejścia) jako głównego identyfikatora, z fallbackiem do chunk.moduleIds[0], jeśli ten nie jest dostępny. Daje mi to stabilną ścieżkę źródłową do deterministycznego hashowania.
Łańcuchowanie source map
Gdy przekształcam kod, łamię łańcuch source map. Istniejąca source map mapuje od oryginalnego źródła TypeScript przez Babel i minifikację do bieżącego kodu. Moje transformacje dodają kolejną warstwę, więc muszę zachować ten łańcuch.
Używam MagicString do śledzenia moich transformacji i generowania nowej source map. Następnie scalam ją z istniejącą mapą, zachowując oryginalne tablice sources i sourcesContent. Utrzymuje to pełny łańcuch: oryginalne źródło → (istniejąca mapa) → przekształcony kod.
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,
};
Ponowne hashowanie przekształconej zawartości
Potrzebuję stabilnej zawartości pliku. Aby to osiągnąć, przekształcam importy (zamieniając hashowane importy Vite na moje stabilne importy), a następnie usuwam komentarze source map z obliczania hasha (odwołują się do starych nazw plików).
Po tym obliczam nowy hash i aktualizuję zarówno nazwę pliku, jak i wpis w import map.
Końcowa implementacja
Wtyczka używa strategii czteroprzebiegowej:
- Przebieg liczący: Wykrywa kolizje nazw, licząc, ile plików dzieli każdą nazwę bazową
- Przebieg mapujący: Tworzy mapowanie chunków (hashowana nazwa pliku → specyfikator modułu) i początkową import map
- Przebieg transformujący: Przepisuje ścieżki importów w kodzie, przelicza hashe, aktualizuje source mapy
- Przebieg zmiany nazw: Aktualizuje nazwy plików w bundle'u i finalizuje import map
Oto główna logika transformacji:
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);
}
Do wstrzykiwania import map do HTML używam API wstrzykiwania tagów Vite zamiast manipulacji regexem:
transformIndexHtml() {
return {
tags: [
{
tag: 'script',
attrs: {type: 'importmap'},
children: JSON.stringify(importMap, null, 2),
injectTo: 'head-prepend',
},
],
};
}
Jest to znacznie bardziej niezawodne niż próby dopasowywania tagów HTML regexem.
W liczbach
Aby dać ci wyobrażenie, co robi ta wtyczka:
- ~1 000+ plików JavaScript przetworzonych na build
- ~2-3 sekundy dodane do czasu buildu (akceptowalny kompromis)
- ~99% redukcji niepotrzebnych zmian hashy (większość plików zmienia się teraz tylko wtedy, gdy ich faktyczna zawartość się zmienia)
- ~340 linii kodu wtyczki (włączając komentarze i obsługę błędów)
Wtyczka obsługuje wszystkie przypadki brzegowe, na które do tej pory natrafiłem, a proces buildu jest teraz znacznie bardziej przewidywalny.
Wnioski
Dlaczego parsowanie AST jest niezbędne
Regex na kodzie po bundlowaniu jest niebezpieczny. Jeśli jakiś ciąg znaków w kodzie przypadkiem wygląda jak nazwa pliku, regex go przepisze. Parsowanie AST zapewnia, że przekształcasz tylko faktyczne deklaracje importu/eksportu.
Dlaczego Acorn zamiast es-module-lexer
es-module-lexer jest szybszy i bardziej dedykowany temu zadaniu, ale problemy z natywnymi panikami uniemożliwiły jego użycie w kontekście mojej wtyczki Vite. Acorn to czysty JavaScript, co oznacza brak natywnych zależności, którymi trzeba się martwić. W przyszłości chcę przyjrzeć się es-module-lexer jako optymalizacji prędkości, ale na razie Acorn działa idealnie.
Dlaczego Import Maps zamiast alternatyw
Import Maps to standard webowy z natywnym wsparciem przeglądarek. To "właściwy" sposób rozwiązania tego problemu. Polyfill (es-module-shims) elegancko obsługuje starsze przeglądarki (np. Safari < 16.4), a rozwiązanie jest czyste i łatwe w utrzymaniu.
Podsumowanie
Wtyczka Import Maps skutecznie zapobiega kaskadowym zmianom hashy w moich buildach Vite. Pliki dostają teraz nowe hashe tylko wtedy, gdy ich faktyczna zawartość się zmienia, a nie gdy zmieniają się ich zależności. Sprawia to, że buildy są bardziej przewidywalne, redukuje niepotrzebne unieważnianie cache'a i pomaga nam pozostać poniżej limitów plików Cloudflare Pages.
Rozwiązanie jest proste, łatwe w utrzymaniu i wykorzystuje nowoczesne standardy webowe. To dobry przykład tego, jak czasem "właściwe" rozwiązanie jest też najprostsze, gdy tylko zrozumie się problem na tyle głęboko, by je dostrzec.
Wtyczka jest open source i dostępna na GitHubie: @foony/vite-plugin-import-map. Możesz ją zainstalować poleceniem npm install @foony/vite-plugin-import-map i zacząć używać we własnych projektach Vite.
Przyszłe ulepszenia mogłyby obejmować optymalizację z es-module-lexer, gdy problemy z natywnymi panikami zostaną rozwiązane, lub dodanie wsparcia dla bardziej złożonych scenariuszy importu. Ale na razie wtyczka robi dokładnie to, czego od niej potrzebuję.
A kto wie? Może kiedyś Vite będzie wspierać coś takiego natywnie.
(Aktualizacja: Po wypróbowaniu wtyczki na buildzie Foony, niektórzy użytkownicy mieli nieoczekiwane problemy, więc na razie ją wyłączyłem. Wrócę do tego później. Może. Wciąż uważam, że to fajne rozwiązanie.)