

1/1/1970
Jak jsem vyřešil kaskádové změny hashů pomocí Import Maps
Zdravím! Tenhle problém mě trápí už víc než pět let, ale teprve teď jsem se rozhodl ho řešit, protože dospěl do bodu, kdy už ho nemůžu ignorovat. Když jsem změnil jediný znak v jednom souboru, polovina JavaScriptových souborů v mém buildu dostala nové hashované názvy, přestože se jejich skutečný obsah vůbec nezměnil. To způsobovalo zbytečnou invalidaci cache, prakticky znemožňovalo sledovat, co se mezi buildy skutečně změnilo, a co bylo nejhorší: rozbíjelo to mé buildy na Cloudflare Pages kvůli limitu počtu souborů.
Níže rozeberu problém, proč mi existující řešení nevyhovovala a jak jsem postavil vlastní Vite plugin pomocí Import Maps, který to vyřešil jednou provždy.
Problém: kaskádové změny hashů
Vite používá pro produkční buildy hashování založené na obsahu. Když buildujete aplikaci, každý JavaScriptový soubor dostane v názvu hash odvozený z jeho obsahu. Pokud se button.tsx zkompiluje na button-abc12345.js a obsah se změní, stane se z něj button-def45678.js. To je skvělé pro cache busting: uživatelé dostanou nový soubor, když se změní.
Problém nastává, když soubor A importuje soubor B. Řekněme, že máte:
// main.js
import { Button } from "./button-abc12345.js";
Když se button.tsx změní, Vite vygeneruje button-def45678.js. Jenže teď se změní i main.js, protože obsahuje řetězec "./button-abc12345.js", který je teď špatný. Takže main.js dostane také nový hash, i když se vlastní logika v main.js vůbec nezměnila.
To kaskádově prochází celým grafem závislostí. Změňte jednu utility funkci a najednou polovina vašich js souborů dostane nové hashe. V mém případě jediná změna jednoho znaku v useBackgroundMusic.ts způsobila re-hashování přes 500 souborů.
Dopad na reálném provozu byl značný. Bundlujeme 8 verzí assetů z minulých buildů, aby uživatelé na lehce zastaralých verzích našeho klienta mohli stále spustit svou verzi i poté, co nasadíme novou na Cloudflare Pages. Cloudflare Pages má ale limit 20 000 souborů, který jsme začali narážet kvůli naší nedávné změně i18n, která dramaticky zvýšila počet vytvářených souborů.
Vyřešení kaskádových hashů nám umožňuje uchovávat mnohem více minulých buildů, aniž bychom narazili na tyto limity, protože většina souborů se už nyní nemusí měnit. Zároveň to snižuje pravděpodobnost, že uživateli na zastaralém buildu vyskočí chyba, protože je mnohem pravděpodobnější, že bude požadovat nezměněný soubor, který náhodou máme.
Proč ne [alternativní řešení]?
Když jsem se na to poprvé díval, zvažoval jsem několik přístupů. Žádný z nich mi ale úplně nesedl.
Post-build skripty
Mou první myšlenkou bylo napsat post-build skript, který by normalizoval všechny importní cesty, znovu zhashoval soubory a aktualizoval reference. Vypadalo to přímočaře: prostě regexem nahradit hashované názvy stabilními a pak přepočítat hashe.
Tento přístup jsem zamítl kvůli „heisenbugům“ a obavám z otrávení cache. I když ukládáme minulé buildy v Cloudflare Pages, riziko nekonzistencí cache mi za to nestálo. Skript, který modifikuje soubory po buildu, by mohl zanést subtilní bugy, které se projeví až v produkci, a jejich debugování by byla noční můra.
Vite manualChunks
Další možností bylo využít Viteho konfiguraci manualChunks k oddělení stabilního kódu (jako node_modules) od nestabilního (byznys logika). Myšlenka byla, že vendor kód se mění méně často, takže by kaskádovalo méně souborů.
To ale neřeší jádro problému, jen ho zmírňuje. Kaskádové hashe pořád dostáváte v rámci chunků s byznys logikou. Chtěl jsem řešení, které řeší podstatu problému, ne ho jen trochu vylepšuje.
Import Maps: moderní řešení
Import Maps jsou nativní funkcí prohlížečů (s podporou polyfillu pro starší prohlížeče), která odděluje specifikátory modulů od cest k souborům. Místo aby soubor A importoval "./button-abc123.js", importuje "button". Prohlížeč použije import map k přeložení "button" na skutečný hashovaný název souboru.
Přesně tohle jsem potřeboval. Obsah souboru A zůstane identický (vždy importuje "button"), takže jeho hash zůstane stejný. Nový hash dostane pouze import map a samotný změněný soubor. Trochu mě šokovalo, že na to ještě nikdo neudělal pořádný plugin!
Stavba Vite pluginu
Rozhodl jsem se postavit Vite plugin, který by:
- Transformoval všechny relativní importy na stabilní specifikátory modulů
- Vygeneroval import map, která tyto specifikátory mapuje na skutečné hashované názvy souborů
- Vložil import map do HTML
Plugin je nyní k dispozici na GitHubu: @foony/vite-plugin-import-map
Počáteční přístup
Začal jsem Vite pluginem s hookem generateBundle. Můj první pokus používal regex k vyhledání a nahrazení importních cest. Bylo to snadné na napsání a fungovalo to pro náš malý tým ve Foony, ale bylo to křehké a v pluginu pro ostatní by to určitě nefungovalo, protože by mohly nastat falešné shody, které by se zmutovaly.
Regex přístup měl zjevné problémy: co když nějaký řetězec v kódu vypadá náhodou jako název souboru? Co dynamické importy? Co export statementy? Pokud jsem chtěl postavit plugin pro ostatní, potřeboval jsem robustnější řešení.
AST parsing
Potřeboval jsem JavaScriptový kód řádně parsovat, abych našel všechny import statementy. První pokus byl es-module-lexer, který je specificky navržený pro parsování ES modulů. Bohužel způsoboval nativní paniky během fáze analýzy modulů ve Vite. Ani zkušenost s asm.js buildem paniky nezastavila.
Skončil jsem u Acornu, rychlého, lehkého parseru v čistém JavaScriptu. V kombinaci s acorn-walk pro průchod AST mi dal vše, co jsem potřeboval, bez problémů s nativními závislostmi.
Vyřešené klíčové výzvy
Zpracování všech typů importů
Importy přicházejí v mnoha podobách a v AST jsou zpracovávány různě. Musel jsem zvládnout:
- Statické importy:
import x from "./file.js" - Dynamické importy:
import("./file.js") - Pojmenované re-exporty:
export { x } from "./file.js"(na tenhle jsem zpočátku zapomněl!) - Re-export všeho:
export * from "./file.js"
Případ re-exportu byl obzvláště záludný, protože jsem si ho neuvědomil, dokud jsem neviděl soubor, který se netransformoval. Kód obsahoval export{PoolBalls,PoolCues,PoolTables}from"./Items-Bd_KmSuk.js" a můj plugin ho úplně ignoroval, protože jsem hledal jen uzly ImportDeclaration a ImportExpression.
Takhle to teď zpracovávám:
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
},
});
Deterministické řešení konfliktů
Když má více souborů stejné základní jméno (například více souborů index.tsx v různých adresářích), musím je rozlišit. Nemůžu prostě použít "index" pro všechny.
Mé řešení: pokud existuje konflikt, hashuju původní zdrojovou cestu plus základní jméno. Například src/client/games/chess/index.tsx:index se zhashuje na index-abc123. Tím je zajištěno, že stejný soubor dostane mezi buildy vždy stejný specifikátor modulu, i když přidám nebo odstraním další soubory se stejným jménem.
Jako primární identifikátor používám chunk.facadeModuleId (vstupní bod) a v případě, že není k dispozici, sáhnu po chunk.moduleIds[0]. Tím získám stabilní zdrojovou cestu pro deterministické hashování.
Řetězení source map
Když transformuji kód, lámu řetězec source map. Existující source map mapuje z původního TypeScript zdroje přes Babel a minifikaci až k aktuálnímu kódu. Mé transformace přidávají další vrstvu, takže musím tento řetězec zachovat.
Používám MagicString ke sledování svých transformací a generování nové source map. Pak ji slučuji s existující mapou tak, že zachovávám původní pole sources a sourcesContent. Tím se udržuje plný řetězec: původní zdroj → (existující mapa) → transformovaný kód.
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,
};
Re-hashování transformovaného obsahu
Potřebuji stabilní obsah souborů. Abych toho dosáhl, transformuji importy (nahrazuji hashované importy z Vite svými stabilními) a poté z výpočtu hashe odstraňuji komentáře source map (odkazují na staré názvy souborů).
Pak vypočítám nový hash a aktualizuji jak název souboru, tak záznam v import map.
Finální implementace
Plugin používá strategii čtyř průchodů:
- Počítací průchod: detekuje kolize jmen tím, že počítá, kolik souborů sdílí jednotlivá základní jména
- Mapovací průchod: vytvoří mapování chunků (hashovaný název → specifikátor modulu) a počáteční import map
- Transformační průchod: přepíše importní cesty v kódu, přepočítá hashe, aktualizuje source mapy
- Přejmenovací průchod: aktualizuje názvy souborů v bundle a finalizuje import map
Tady je hlavní transformační logika:
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);
}
Pro injektování import map do HTML používám API pro vkládání tagů z Vite místo regex manipulace:
transformIndexHtml() {
return {
tags: [
{
tag: 'script',
attrs: {type: 'importmap'},
children: JSON.stringify(importMap, null, 2),
injectTo: 'head-prepend',
},
],
};
}
To je mnohem spolehlivější než snažit se HTML tagy regexovat.
V číslech
Abyste získali představu, co plugin dělá:
- ~1 000+ JavaScriptových souborů zpracovaných na build
- ~2-3 sekundy přidané k době buildu (přijatelný kompromis)
- ~99% snížení zbytečných změn hashů (většina souborů se nyní mění jen tehdy, když se mění jejich skutečný obsah)
- ~340 řádků kódu pluginu (včetně komentářů a ošetření chyb)
Plugin zvládá všechny okrajové případy, na které jsem dosud narazil, a build proces je teď mnohem předvídatelnější.
Co jsem se naučil
Proč je AST parsing nezbytný
Regex na bundlovaném kódu je nebezpečný. Pokud nějaký řetězec ve vašem kódu vypadá náhodou jako název souboru, regex ho přepíše. AST parsing zajišťuje, že transformujete pouze skutečné import/export statementy.
Proč Acorn místo es-module-lexer
es-module-lexer je rychlejší a více účelově navržený, ale problémy s nativními panikami ho ve mém kontextu Vite pluginu znemožnily použít. Acorn je čistý JavaScript, takže není třeba se starat o nativní závislosti. V budoucnu se na es-module-lexer chci podívat jako na optimalizaci rychlosti, ale pro teď Acorn funguje skvěle.
Proč Import Maps místo alternativ
Import Maps jsou webový standard s nativní podporou v prohlížečích. Jsou „správným“ způsobem, jak tento problém řešit. Polyfill (es-module-shims) elegantně zvládá starší prohlížeče (např. Safari < 16.4) a řešení je čisté a udržovatelné.
Závěr
Plugin Import Maps úspěšně zabraňuje kaskádovým změnám hashů v mých Vite buildech. Soubory teď dostávají nové hashe pouze tehdy, když se mění jejich skutečný obsah, ne když se mění jejich závislosti. Díky tomu jsou buildy předvídatelnější, snižuje se zbytečná invalidace cache a pomáhá nám to zůstat pod limity počtu souborů na Cloudflare Pages.
Řešení je jednoduché, udržovatelné a využívá moderní webové standardy. Je to dobrý příklad toho, jak je někdy „správné“ řešení zároveň tím nejjednodušším, jakmile problému porozumíte dostatečně do hloubky, abyste ho prohlédli.
Plugin je open source a je k dispozici na GitHubu: @foony/vite-plugin-import-map. Můžete ho nainstalovat příkazem npm install @foony/vite-plugin-import-map a začít ho používat ve svých vlastních Vite projektech.
Mezi budoucí vylepšení by mohla patřit optimalizace pomocí es-module-lexer, jakmile se vyřeší problémy s nativními panikami, nebo přidání podpory pro složitější scénáře importů. Pro teď ale plugin dělá přesně to, co od něj potřebuji.
A kdo ví? Možná Vite jednou něco takového bude podporovat nativně.
(Aktualizace: Po vyzkoušení pluginu na produkčním buildu Foony se u některých uživatelů objevily nečekané problémy, takže jsem ho prozatím vypnul. Vrátím se k němu později. Možná. Pořád si myslím, že je to elegantní řešení.)