

1/1/1970
Jak rozwiązałem problem kaskadowych zmian hashy za pomocą Import Maps
Cześć! Z tym problemem żyłem od ponad 5 lat, ale dopiero teraz postanowiłem się z nim rozprawić, bo urósł do rozmiaru, którego nie mogłem już ignorować. Gdy zmieniałem jeden znak w pojedynczym pliku, połowa plików JavaScript w buildzie dostawała nowe zhashowane nazwy, mimo że ich faktyczna zawartość się nie zmieniała. To powodowało niepotrzebne unieważnianie cache, prawie uniemożliwiało śledzenie, co tak naprawdę zmieniło się między buildami, a co gorsza rozwalało mi buildy na Cloudflare Pages przez limit plików.
Niżej rozbiję problem na części, opowiem, dlaczego istniejące rozwiązania nie zadziałały u mnie i jak zbudowałem własny plugin do Vite z użyciem Import Maps, który w końcu to ogarnia.
Problem: kaskadowe zmiany hashy
Vite w trybie produkcyjnym używa hashy opartych na treści. Kiedy budujesz aplikację, każdy plik JavaScript dostaje w nazwie hash wyliczony z jego zawartości. Jeśli button.tsx kompiluje się do button-abc12345.js, a zawartość się zmieni, dostaniesz button-def45678.js. To świetne narzędzie do cache bustingu, bo użytkownicy pobierają nowy plik wtedy, gdy faktycznie się zmienia.
Problem zaczyna się w momencie, 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. Tylko że teraz zmienia się też main.js, bo zawiera dosłowny string "./button-abc12345.js", który jest już nieaktualny. Więc main.js też dostaje nowy hash, mimo że logika w main.js w ogóle się nie zmieniła.
To rozlewa się kaskadowo po całym grafie zależności. Zmieniasz jedną funkcję pomocniczą i nagle połowa plików js dostaje nowe hashe. U mnie zmiana jednego znaku w useBackgroundMusic.ts spowodowała ponowne zhashowanie ponad 500 plików.
W praktyce miało to spory wpływ. Bundlujemy 8 wersji assetów z poprzednich buildów, żeby użytkownicy na lekko przestarzałych wersjach klienta nadal mogli uruchomić swoją wersję, gdy wypuszczamy nową na Cloudflare Pages. Problem w tym, że Cloudflare Pages ma limit 20 000 plików, który zaczęliśmy regularnie dobijać przez naszą wcześniejszą zmianę i18n, po której liczba generowanych plików totalnie wystrzeliła.
Rozwiązanie problemu kaskadowych hashy pozwala nam trzymać znacznie więcej starych buildów bez wpadania na te limity, bo większość plików po prostu nie musi się już zmieniać. Zmniejsza to też szansę, że użytkownik na starej wersji klienta zobaczy błąd, bo dużo bardziej prawdopodobne jest, że poprosi o niezmieniony plik, który wciąż mamy na serwerze.
Dlaczego nie [alternatywne rozwiązania]?
Na początku, gdy zabierałem się za ten temat, rozważałem kilka podejść. Żadne nie pasowało tak, jak bym chciał.
Skrypty post-build
Pierwsza myśl: napisać skrypt uruchamiany po buildzie, który znormalizuje wszystkie ścieżki importów, przeliczy hashe i zaktualizuje referencje. Brzmiało prosto, wystarczy zrobić regexa, który podmieni zhashowane nazwy plików na stabilne nazwy, a potem przeliczyć hashe jeszcze raz.
Odpuściłem to podejście z powodu potencjalnych "Heisenbugów" i ryzyka wysypania cache. Nawet jeśli trzymamy stare buildy na Cloudflare Pages, ryzyko niespójnego cache'u było dla mnie za duże. Skrypt, który grzebie w plikach po zakończeniu builda, może wprowadzić delikatne bugi widoczne tylko na produkcji, a debugowanie czegoś takiego to koszmar.
Vite manualChunks
Inną opcją było użycie konfiguracji manualChunks w Vite, żeby oddzielić stabilny kod (na przykład node_modules) od niestabilnego (logika biznesowa). Idea była taka, że kod vendora zmienia się rzadziej, więc mniej plików będzie się zmieniało kaskadowo.
To jednak w ogóle nie rozwiązuje źródła problemu, tylko je trochę maskuje. Wciąż dostajesz kaskadowe hashe wewnątrz chunków z logiką biznesową. Chciałem rozwiązania, które trafi w sam środek problemu, a nie tylko sprawi, że będzie trochę mniej bolesny.
Import Maps: nowoczesne rozwiązanie
Import Maps to natywna funkcja przeglądarki (z polyfillem dla starszych przeglądarek), która rozdziela nazwy modułów od ścieżek do plików. Zamiast żeby plik A importował "./button-abc123.js", importuje po prostu "button". Przeglądarka korzysta wtedy z mapy importów, żeby zamienić "button" na faktyczną zhashowaną nazwę pliku.
Dokładnie tego potrzebowałem. Zawartość pliku A pozostaje identyczna (zawsze importuje "button"), więc jego hash też się nie zmienia. Nowe hashe dostaje tylko mapa importów i ten jeden zmieniony plik. Byłem szczerze zdziwiony, że nikt jeszcze nie zrobił sensownego pluginu, który by to ogarniał!
Jak to zaimplementowałem
Postanowiłem więc zbudować plugin do Vite, który będzie:
- zamieniał wszystkie względne importy na stabilne nazwy modułów
- generował import mapę, która mapuje te nazwy na faktyczne zhashowane nazwy plików
- wstrzykiwał tę mapę importów do HTML-a
Plugin jest już dostępny na GitHubie: @foony/vite-plugin-import-map
Pierwsze podejście
Na start zrobiłem plugin do Vite oparty na hooku generateBundle. W pierwszej wersji używałem regexów do wyszukiwania i podmiany ścieżek importów. Kod pisało się łatwo i dla naszego małego zespołu w Foony to działało, ale rozwiązanie było kruche i na pewno nie nadawało się do pluginu, gdzie mogą się pojawić fałszywe trafienia, które zostaną przez przypadek zmienione.
Podejście z regexami miało oczywiste problemy: co jeśli jakiś string w kodzie przypadkiem wygląda jak nazwa pliku? Co z dynamicznymi importami? Co z instrukcjami export? Potrzebowałem solidniejszego rozwiązania, jeśli ten plugin miał być używalny także przez innych.
Parsowanie AST
Musiałem więc porządnie sparsować kod JavaScript, żeby znaleźć wszystkie instrukcje importu. Najpierw sięgnąłem po es-module-lexer, który jest stworzony właśnie do parsowania modułów ES. Niestety w fazie analizy modułów w Vite powodował natywne crashe. Próba użycia wersji asm.js też nie pomogła ich zatrzymać.
Ostatecznie stanęło na Acorn, szybkim, lekkim parserze napisanym w czystym JavaScripcie. W połączeniu z acorn-walk do przechodzenia po AST dało mi to wszystko, czego potrzebowałem, bez problemów z natywnymi zależnościami.
Najważniejsze wyzwania
Obsługa wszystkich typów importów
Importy występują w wielu formach i w AST są reprezentowane na różne sposoby. 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"(na początku kompletnie to przegapiłem!) - re-eksport wszystkiego:
export * from "./file.js"
Przypadek z re-eksportem był szczególnie podstępny, bo długo go nie zauważyłem, dopóki nie trafiłem na plik, który w ogóle nie był transformowany. W kodzie było export{PoolBalls,PoolCues,PoolTables}from"./Items-Bd_KmSuk.js", a mój plugin całkowicie go ignorował, bo patrzyłem tylko na węzły typu ImportDeclaration i ImportExpression.
Tak obsługuję je teraz wszystkie:
walk(ast, {
ImportDeclaration(node: any) {
// Statyczne importy: import x from "spec"
const specifier = node.source.value;
// ... logika transformacji
},
ExportNamedDeclaration(node: any) {
// Nazwane eksporty ze źródłem: export { x, y } from "spec"
if (!node.source?.value) return;
// ... logika transformacji
},
ExportAllDeclaration(node: any) {
// Eksport wszystkiego: export * from "spec"
if (!node.source?.value) return;
// ... logika transformacji
},
ImportExpression(node: any) {
// Dynamiczne importy: import("spec")
// ... logika transformacji
},
});
Deterministyczne rozwiązywanie konfliktów
Gdy wiele plików ma tę samą nazwę bazową (na przykład kilka index.tsx w różnych katalogach), muszę je rozróżnić. Nie mogę wszystkim nadać po prostu nazwy "index".
Moje rozwiązanie: jeśli pojawia się konflikt, haszuję oryginalną ścieżkę do źródła plus nazwę bazową. Na przykład src/client/games/chess/index.tsx:index jest haszowany do czegoś w stylu index-abc123. Dzięki temu ten sam plik zawsze dostaje tę samą nazwę modułu między buildami, nawet jeśli dojdą albo znikną inne pliki o tej samej nazwie.
Jako głównego identyfikatora używam chunk.facadeModuleId (entry pointu), a jeśli go nie ma, spadam do chunk.moduleIds[0]. To daje mi stabilną ścieżkę źródłową do deterministycznego haszowania.
Łańcuchowanie source map
Kiedy transformuję kod, zrywam istniejący łańcuch source map. Obecna mapa źródeł prowadzi z oryginalnego kodu TypeScript przez Babela i minifikację do aktualnego kodu. Moje transformacje dodają kolejną warstwę, więc muszę ten łańcuch zachować.
Używam MagicString, żeby śledzić transformacje i wygenerować nową source mapę. Potem łączę ją z istniejącą mapą, zachowując oryginalne tablice sources i sourcesContent. Dzięki temu cały łańcuch zostaje zachowany: oryginalne źródło → (istniejąca mapa) → przetransformowany 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: użyj mapowań z nowej mapy, ale zachowaj oryginalne źródła
chunk.map = {
...newMap,
sources: existingMap.sources || newMap.sources,
sourcesContent: existingMap.sourcesContent || newMap.sourcesContent,
file: newFileName,
};
Ponowne haszowanie przetransformowanej treści
Potrzebuję stabilnej zawartości pliku. Żeby to osiągnąć, najpierw transformuję importy (podmieniam zhashowane importy Vite na moje stabilne importy), a potem usuwam komentarze z informacjami o source mapach z obliczania hashy (bo odwołują się do starych nazw plików).
Po tym wyliczam nowy hash i aktualizuję zarówno nazwę pliku, jak i wpis w mapie importów.
Ostateczna implementacja
Plugin korzysta z czteroprzebiegowej strategii:
- Przebieg zliczający: wykrywa kolizje nazw, zliczając, ile plików ma tę samą nazwę bazową
- Przebieg mapujący: tworzy mapowanie chunków (zhashowana nazwa pliku → nazwa modułu) i wstępną mapę importów
- Przebieg transformujący: przepisuje ścieżki importów w kodzie, przelicza hashe, aktualizuje source mapy
- Przebieg zmiany nazw: aktualizuje nazwy plików w bundlu i finalizuje mapę importów
Oto główna logika transformacji:
import {simple as walk} from 'acorn-walk';
// Parsujemy kod, żeby dostać AST
const ast = Parser.parse(chunk.code, {
ecmaVersion: 'latest',
sourceType: 'module',
locations: true,
});
const importsToTransform: Array<{start: number; end: number; replacement: string}> = [];
// Przechodzimy po AST, żeby znaleźć wszystkie importy/eksporty
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 żeby pominąć otwierający cudzysłów
end: node.source.end - 1, // -1 żeby pominąć zamykający cudzysłów
replacement: moduleSpec,
});
}
},
// ... obsługa innych typów węzłów
});
// Zastosuj transformacje w odwrotnej kolejności, żeby zachować pozycje
importsToTransform.sort((a, b) => b.start - a.start);
for (const transform of importsToTransform) {
magicString.overwrite(transform.start, transform.end, transform.replacement);
}
Do wstrzykiwania mapy importów do HTML-a używam API do wstrzykiwania tagów w Vite zamiast manipulacji regexami:
transformIndexHtml() {
return {
tags: [
{
tag: 'script',
attrs: {type: 'importmap'},
children: JSON.stringify(importMap, null, 2),
injectTo: 'head-prepend',
},
],
};
}
To jest dużo bardziej niezawodne niż próby dopasowywania tagów HTML regexami.
Liczby mówią
Żeby pokazać, co ten plugin faktycznie robi:
- ~1 000+ plików JavaScript przetwarzanych na każdy build
- ~2-3 sekundy dorzucone do czasu builda (akceptowalny kompromis)
- ~99% mniej niepotrzebnych zmian hashy (większość plików zmienia się teraz tylko wtedy, gdy faktycznie zmienia się ich treść)
- ~340 linii kodu pluginu (łącznie z komentarzami i obsługą błędów)
Plugin ogarnia wszystkie przypadki brzegowe, na które do tej pory trafiłem, a proces builda jest teraz dużo bardziej przewidywalny.
Czego się nauczyłem
Dlaczego parsowanie AST jest kluczowe
Regexy na zbundlowanym kodzie są niebezpieczne. Jeśli jakiś string w kodzie przypadkiem wygląda jak nazwa pliku, regex go podmieni. Parsowanie AST daje gwarancję, że modyfikujesz tylko prawdziwe instrukcje import/export.
Dlaczego Acorn zamiast es-module-lexer
es-module-lexer jest szybszy i bardziej wyspecjalizowany, ale problemy z natywnymi crashami sprawiły, że w kontekście mojego pluginu do Vite był nie do użycia. Acorn jest napisany w czystym JavaScripcie, więc nie ma żadnych natywnych zależności, którymi trzeba się przejmować. W przyszłości pewnie wrócę do es-module-lexer jako potencjalnej optymalizacji wydajności, ale na razie Acorn sprawdza się świetnie.
Dlaczego Import Maps zamiast alternatyw
Import Maps są standardem webowym z natywnym wsparciem w przeglądarkach. To "właściwy" sposób rozwiązania tego problemu. Polyfill (es-module-shims) ogarnia starsze przeglądarki (np. Safari < 16.4) w całkiem elegancki sposób, a całe rozwiązanie jest czyste i łatwe w utrzymaniu.
Podsumowanie
Plugin oparty na Import Maps skutecznie zatrzymuje kaskadowe zmiany hashy w moich buildach Vite. Pliki dostają nowy hash tylko wtedy, gdy faktycznie zmienia się ich zawartość, a nie gdy zmieniają się ich zależności. Dzięki temu buildy są bardziej przewidywalne, mniej rzeczy bez sensu psuje cache i łatwiej mieścimy się w limitach plików Cloudflare Pages.
Rozwiązanie jest proste, łatwe w utrzymaniu i bazuje na nowoczesnych standardach webowych. To dobry przykład na to, że czasami "właściwe" rozwiązanie jest też tym najprostszym, jeśli tylko wystarczająco dobrze zrozumiesz problem.
Plugin jest open source i dostępny na GitHubie: @foony/vite-plugin-import-map. Możesz go zainstalować komendą npm install @foony/vite-plugin-import-map i od razu używać w swoich projektach na Vite.
W przyszłości możliwe ulepszenia to na przykład optymalizacja z użyciem es-module-lexer, kiedy problemy z natywnymi crashami zostaną ogarnięte, albo dodanie wsparcia dla jeszcze bardziej złożonych scenariuszy importów. Na ten moment plugin robi dokładnie to, czego od niego potrzebuję.
A kto wie, może któregoś dnia Vite będzie miało coś takiego wbudowane prosto z pudełka.